用Python向Kindle推送电子书

使用Kindle的同学应该知道Amazon官方有个Send To Kindle的插件可以方便的把文档推送到你的设备上,可惜的是,这个插件只能用美亚的账号登陆,何不自己做一个?

我从昨天睡觉之前萌生了这个想法,到现在做出来原型整整用了一天,惭愧不已,看来还需多加练习。

简单说下原理

完整的源码我会放在最后,具体的细节实现可以看源码。这里我就简单的说下我的思路以及过程中遇到的值得记录一下的”难点“。

过程其中很简单,就是用SMTP协议把文档以邮件的形式发送给Kindle邮箱。那么,我们需要做的其实只有:

  • 一个UI用来收集Kindle邮箱、推送邮箱、推送邮箱密码和要推送的文档
  • 使用SMTP协议

邮件服务商

SMTP协议是应用层的网络协议,由TCP协议支持,换句话说,它是在保证了可靠传输的基础上通过一定的”暗号“交接来传递邮件,过程大概是:打招呼(hello),确认交流方式(加密吗?用什么加密协议?),身份认证(采用base64编码的用户名和密码),传递内容,结束。具体的指令可能会因为邮件服务商所用的加密协议有所不同,但过程基本就是这样。这样的文章网上一搜一大堆:SMTP协议--在cmd下利用命令行发送邮件,有兴趣的可以自己打一打指令,对理解协议有好处。

但有一点我想说的是这些文章中使用的都是端口25(SMTP的默认端口,传输不加密),而经过我的测试,QQ、163甚至Gmail都已经不再开放25号端口了,用的最多的是TLS和SSL的端口进行加密传输。在SMTP交互过程中,对应的指令变化就是在auth login之前要先发送starttls指令,同时在连接服务器的时候使用端口587。

这里我要吐槽一下QQ和163邮箱所谓的授权码,我不知道是出于什么原因要让第三方邮件客户端使用这个授权码。它真的让你的邮箱更安全了吗?在使用SMTP协议时,QQ和163在验证身份时都要求提供采用base64编码的授权码。Hmm...

汉字转拼音库

在使用smtplib库时,我发现当附件名是中文的时候,收件方收到的邮件中附件会变成一个bin文件。在尝试调整编码无果之后,我想到了一个把中文都转化为拼音的方案。接着去搜了一下还真有一个第三方库:Pinyin

安装这个库的方法是,将整个github库下载到本地,解压缩,用cmd切换到有setup.py那个目录,然后执行:python setup.py install

其他

除了上述两点,本项目中还有几个值得一提的问题或者说我学到的新知识:

  • Python中将键盘鼠标的操作和函数绑定:Events and Bindings,它可以用来实现一个带有超链接效果的Label。
  • 使用文件对话框选取一个文件
  • 怎样清空一个Entry插件

都在代码里了。

目前使用的一些约束

因为只是一个初版,难免有些考虑不到的地方或者说bug,考虑到这只是个练手的项目,我应该也不会继续完善它了,目前我能想到的一些约束包括:

  • 只支持三个邮件服务商:QQ、163和Gmail。目前会对用户输入的推送邮箱和密码做一些简单的校验,但是仅限于判断其是不是以qq.com, 163.comgmail.com结尾,用例如13@3gmail.com就是校验不出来的。kindle邮箱也有类似的问题。同时如果邮箱或密码错误,也不能返回相应的错误消息。
  • 推送的文件大小不能超过50MB,这个其实不是bug,超过50MB的文件即使推送了也会被Amazon退回。同样文件的类型我也不能控制,如果用户选择了kindle不支持的文件类型,软件仍然会推送,只不过同样会被Amazon退回。
  • 在正常流程下,点击发送按钮之后程序会”停滞“很长时间,多长取决于文件的大小。考虑应该添加一个类似于进度条的东西用来缓解用户等待时产生的”焦虑感“。
  • 目前一次只能推送一个文件。考虑将”选择文件“那个Frame做成一个可以进行CRUD操作的LIstbox,但要校验总文件大小不能超过50MB。
  • 还有一些诸如邮箱必须开启SMTP服务,推送邮箱必须处于Amazon账户信任列表里的条件不属于本软件范畴,但是确实必要的,我就不一一列出来的。总之,如果你手动发邮件能成功推送文档,那么软件也可以, 亲测有效。

完整代码

效果图

SentToKindle效果图.jpg

smtp.py

import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.header import Header
 
def SendToKindle(mail_host, mail_user, mail_pass, receiver, fullpath, bookname):
    message = MIMEMultipart()
    message['From'] = Header("SentToKindle", 'utf-8')
    message['To'] =  receiver 
    message['Subject'] = Header('convert')

    att = MIMEText(open(fullpath, 'rb').read(), 'base64', 'utf-8')
    att["Content-Type"] = 'application/octet-stream'
    att["Content-Disposition"] = 'attachment; filename=%s' % bookname
    message.attach(att)

    smtpObj = smtplib.SMTP(mail_host, 587)
    smtpObj.ehlo()
    smtpObj.starttls()
    smtpObj.login(mail_user, mail_pass)
    smtpObj.sendmail(mail_user, [receiver], message.as_string())
    smtpObj.quit()

ui.py

import tkinter as tk
import tkinter.messagebox
import tkinter.filedialog
from tkinter import END
import webbrowser
import os.path

import smtp
import pinyin

mail_host = ''
mail_user = ''
mail_pass = ''
receiver  = ''
fullpath  = ''
bookname  = ''

class SentToKindleUI(object):
    def __init__(self, object):
        # 推送信息
        self.lf_sendinfo = tk.LabelFrame(object, width=256, height=144, text='推送信息')  
        self.lf_sendinfo.grid(row=0, column=0, sticky='w',padx=10)
        
        self.label_sendinfo_kindlemail = tk.Label(self.lf_sendinfo, width=12, text='Kindle邮箱:')
        self.label_sendinfo_kindlemail.place(x=5,y=2)
        self.label_sendinfo_entry1 = tk.Entry(self.lf_sendinfo, relief='solid')
        self.label_sendinfo_entry1.place(x=100, y=2)

        self.label_sendinfo_sendmail = tk.Label(self.lf_sendinfo, width=12, text='推送邮箱:')
        self.label_sendinfo_sendmail.place(x=5,y=30)
        self.label_sendinfo_entry2 = tk.Entry(self.lf_sendinfo, relief='solid')
        self.label_sendinfo_entry2.place(x=100, y=30)

        self.label_sendinfo_password = tk.Label(self.lf_sendinfo, width=12, text='推送邮箱密码:')
        self.label_sendinfo_password.place(x=5,y=58)
        self.label_sendinfo_entry3 = tk.Entry(self.lf_sendinfo, relief='solid',show='*')
        self.label_sendinfo_entry3.place(x=100, y=58)

        # 校验三个Entries的内容
        def label_sendinfo_bt_click():
            global mail_host, receiver, mail_user, mail_pass
            receiver  = self.label_sendinfo_entry1.get()
            mail_user = self.label_sendinfo_entry2.get()
            mail_pass = self.label_sendinfo_entry3.get()

            # 检查kindle邮箱
            if receiver.endswith('kindle.com') or receiver.endswith('kindle.cn'):
                pass
            else:
                tk.messagebox.showinfo(title='HI', message='Kindle邮箱必须以kindle.com或kindle.cn结尾。')
                self.label_sendinfo_entry1.delete(0, END)
                return

            # 检查推送邮箱    
            if mail_user.endswith('gmail.com'):
                mail_host = 'smtp.gmail.com'
            elif mail_user.endswith('163.com'):
                mail_host = 'smtp.163.com'
            elif mail_user.endswith('qq.com'):
                mail_host = 'smtp.qq.com'
            else:
                tk.messagebox.showinfo(title='HI', message='目前仅支持QQ、163和Gmail邮箱作为推送邮箱。')
                self.label_sendinfo_entry2.delete(0, END)
                self.label_sendinfo_entry3.delete(0, END)
                return

            # 如果能进行到这,说明内容校验都没问题
            tk.messagebox.showinfo(title='HI', message='输入没有问题!')


        varCheck = tk.IntVar()

        def label_sendinfo_checkbutton_click():
            if varCheck.get() == 1:
                self.label_sendinfo_entry3.config(show='')
            else:
                self.label_sendinfo_entry3.config(show='*')

        self.label_sendinfo_checkbutton = tk.Checkbutton(self.lf_sendinfo,
            text = '显示密码',
            variable = varCheck,
            onvalue = 1,
            offvalue = 0,
            command = label_sendinfo_checkbutton_click
            )
        self.label_sendinfo_checkbutton.place(x=90,y=86)

        self.label_sendinfo_bt = tk.Button(self.lf_sendinfo,
            text='校验',
            width=8,
            command=label_sendinfo_bt_click
            )
        self.label_sendinfo_bt.place(x=175,y=86)

        # 文件选择
        self.lf_file = tk.LabelFrame(object, width=256, height=128, text='文件选择')  
        self.lf_file.grid(row=1, column=0, sticky='w', padx=10)

        self.lf_file_label = tk.Label(self.lf_file, 
            width=34, 
            text='已选择:(空)',
            anchor='w', 
            justify='left',
            wraplength=240
            )
        

        def lf_file_bt_click():
            global bookname, fullpath
            SupportedFiletypes = [('所有文件','*.*'), ('mobi文件','*.mobi'), ('文本文件','*.txt'), ('pdf文件','*.pdf')] 
            filename = tk.filedialog.askopenfilename(filetypes=SupportedFiletypes)

            if filename != '':
                filesize = os.path.getsize(filename)/float(1024*1024)  # MB
                if float(filesize) > 50.00:
                    tk.messagebox.showinfo(title='HI', message='文件大小不得超过50MB。')
                    self.lf_file_label.config(text = '已选择:(空)')
                    return
                self.lf_file_label.config(text = '已选择: '+ filename)
                fullpath = filename
                bookname = pinyin.get(os.path.basename(fullpath), format="numerical")

        self.lf_file_bt = tk.Button(self.lf_file,
            text = '选择文件',
            command=lf_file_bt_click
            )
        self.lf_file_bt.place(x=2, y=2)
        self.lf_file_label.place(x=2, y=42)

        # 描述信息
        self.lf_desc = tk.LabelFrame(object, width=256, height=96, text='说明')  
        self.lf_desc.grid(row=2, column=0, sticky='w', padx=10)

        def callback(event):
            webbrowser.open_new(r"https://journal.ethanshub.com/post/category/gong-cheng-shi/-python-kindledian-zi-shu-tui-song#toc_4")

        self.tmp = "目前一些使用的约束"
        self.lf_desc_label = tk.Label(self.lf_desc, 
            fg='blue',
            cursor='hand2',
            width=34, 
            text=self.tmp, 
            anchor='w', 
            justify='left',
            wraplength=250
            )
        self.lf_desc_label.place(x=2, y=2)
        self.lf_desc_label.bind("<Button-1>", callback)
        
        # 按钮
        self.lf_button = tk.Frame(object, width=256, height=96)  
        self.lf_button.grid(row=3, column=0, sticky='w', padx=10)

        def lf_button_bt1_click():
            global mail_host, mail_user, mail_pass, receiver, bookname
            
            smtp.SendToKindle(mail_host, mail_user, mail_pass, receiver, fullpath, bookname)

        self.lf_button_bt1 = tk.Button(self.lf_button,
            text='发送',
            width=12,
            height=2,
            command=lf_button_bt1_click
            )
        self.lf_button_bt1.place(x=20,y=5)

        self.lf_button_bt2 = tk.Button(self.lf_button,
            text='取消',
            width=12,
            height=2,
            command=self.lf_sendinfo.quit
            )
        self.lf_button_bt2.place(x=123,y=5)

# 初始化窗口
root = tk.Tk()
root.title('Sent to Kindle')

width = 276
height = 432
screenwidth = root.winfo_screenwidth()  
screenheight = root.winfo_screenheight()  
size = '%dx%d+%d+%d' % (width, height, (screenwidth - width)/2, (screenheight - height)/2)
root.geometry(size)

SentToKindleUI(root)
root.mainloop()

main.py

import ui as myUI

# 初始化窗口
root = myUI.tk.Tk()
root.title('Sent to Kindle')

width = 276
height = 432
screenwidth = root.winfo_screenwidth()  
screenheight = root.winfo_screenheight()  
size = '%dx%d+%d+%d' % (width, height, (screenwidth - width)/2, (screenheight - height)/2)
root.geometry(size)

myUI.SentToKindleUI(root)
root.mainloop()
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,716评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,558评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,431评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,127评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,511评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,692评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,915评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,664评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,412评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,616评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,105评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,424评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,098评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,096评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,869评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,748评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,641评论 2 271

推荐阅读更多精彩内容