大多数公司都是每个月定期提交报销,一般报销用的发票都是电子发票发到邮箱,每次要报销时都需要登录邮箱,点开邮件,一个个下载整理,工作量不大,但是发票多了也着实很烦。这个月终于下决心把这个过程自动化一下。
思路
查看了一下邮箱里的发票邮件,虽然主题内容格式不固定,但是基本都包含“发票”,所以可以用“发票”关键词将发票邮件筛选出来。然后解析发票邮件内容,将发票pdf文件提取下载,并整理到指定文件夹。
嗯,就这么简单。
实现
这种事还是用python比较快吧,搞起。
EMAIL相关库
在Python中,有一些常用的库可以用来操作和处理邮件(email):
-
smtplib
和email
模块:Python标准库中的
smtplib
和email
模块可以用来发送邮件。smtplib
模块负责连接到SMTP服务器并发送邮件,而email
模块负责创建和解析邮件内容。这两个模块结合使用可以实现邮件的发送。示例代码:
import smtplib from email.message import EmailMessage msg = EmailMessage() msg.set_content('This is a test email sent from Python.') msg['Subject'] = 'Test Email' msg['From'] = 'sender@example.com' msg['To'] = 'recipient@example.com' server = smtplib.SMTP('smtp.example.com') server.send_message(msg) server.quit()
-
imaplib
和poplib
模块:Python的
imaplib
和poplib
模块可以用来接收邮件。imaplib
模块用于连接到IMAP服务器,而poplib
模块用于连接到POP3服务器。这两个模块可以用来检索邮件。示例代码:
import imaplib import email mail = imaplib.IMAP4_SSL('imap.example.com') mail.login('username', 'password') mail.select('inbox') status, messages = mail.search(None, 'ALL') messages = messages[0].split() for mail_id in messages: _, msg = mail.fetch(mail_id, '(RFC822)') for response_part in msg: if isinstance(response_part, tuple): email_message = email.message_from_bytes(response_part[1]) print(email_message['From']) print(email_message['Subject']) print(email.utils.parsedate(email_message['Date'])) mail.close() mail.logout()
-
yagmail
库:yagmail
是一个简化了发送和接收邮件的Python库。它简化了使用SMTP发送邮件和IMAP接收邮件的过程,提供了更加友好的API。示例代码:
import yagmail yag = yagmail.SMTP('sender@example.com', 'password') contents = ['This is the body of the email', '/path/to/attachment.pdf'] yag.send('recipient@example.com', 'Subject', contents) yag.close()
yagmail
库会处理SMTP服务器的连接和邮件发送,同时也可以方便地添加附件。
这些库提供了不同的功能和灵活性,可以根据自己的需求选择合适的库来操作邮件。
筛选发票邮件
这里一开始走了一点弯路。最初的想法是通过程序筛选发票邮件,然后将发票邮件移动到"发票"文件夹。
但是这样有两个问题,一是这样需要读取全部邮件,筛选出发票邮件,不符合最小权限原则,筛选完还需要把非发票邮件重新标记为“未读”;二是移动到"发票"文件夹这个操作的兼容性不太好,并不是所有邮箱服务商都支持这个操作。
后来想到其实邮箱都有设置收信规则的功能,可以设置一个规则,将收到的发票邮件移动到"发票"文件夹。这样我们就完全可以用自己常用的邮箱来接收发票邮件。
建议还是给接收发票邮件单独设置一个邮箱。
有了前面的基础,程序中就只需要检查指定的“发票”文件夹中的邮件就好了。
这部分功能被封装到EmailClient
类中:
class EmailClient:
def __init__(self, server, username, password):
self.server = server
self.username = username
self.password = password
self.client = None
def connect(self):
try:
self.client = imapclient.IMAPClient(self.server, ssl=True)
self.client.login(self.username, self.password)
self.client.id_({"name": "IMAPClient", "version": "1.0.0"})
return True
except Exception as e:
print(f"Failed to connect to the email server: {e}")
return False
def list_folders(self):
if not self.client:
print("Not connected to the email server. Call 'connect' method first.")
return []
folder_list = self.client.list_folders()
return [folder_info[2] for folder_info in folder_list]
def fapiao_folder_exists(self):
if "发票" in self.list_folders():
return True
else:
print("'发票'文件夹不存在,正在创建...")
self.client.create_folder("发票")
print("已创建'发票'文件夹,请登录邮箱创建收信规则。")
return False
def get_fapiao_emails(self, search_criteria=["UNSEEN"]):
fapiao_emails = []
if not self.client:
print("Not connected to the email server. Call 'connect' method first.")
return []
self.client.select_folder("发票")
email_ids = self.client.search(search_criteria)
for email_id in email_ids:
email_message = self.fetch_email_content(email_id)
decoded_subject = email.header.decode_header(email_message["Subject"])
# 初始化一个空字符串来存储解码后的主题文本
subject_text = ""
# 遍历解码结果
for part, encoding in decoded_subject:
# 如果编码为None,则假定使用UTF-8编码
if encoding is None:
subject_text += part
else:
# 使用指定编码解码部分
subject_text += part.decode(encoding, errors="ignore")
# 检查解码后的主题文本是否包含"发票"
if "发票" in subject_text:
fapiao_emails.append([email_id, email_message])
else:
print(f"邮件主题不包含'发票',已标记'未读',请登录邮箱检查")
print(f"email_id: {email_id}")
print(f"email_subject: {subject_text}")
print("-" * 80)
self.set_email_unread(email_id)
return fapiao_emails
以上代码定义了一个名为EmailClient
的Python类,用于连接到邮件服务器,检索特定文件夹中的未读邮件,并再次检查主题是否包含“发票”关键词。
-
__init__(self, server, username, password)
:- 这是类的构造函数,用于初始化EmailClient对象。它接受三个参数:
server
(邮件服务器地址)、username
(邮箱用户名)和password
(邮箱密码)。 - 它将这些参数存储在类的实例变量中,以便在整个类中使用。
- 这是类的构造函数,用于初始化EmailClient对象。它接受三个参数:
-
connect(self)
:- 这个方法用于连接到邮件服务器。它使用
imapclient
库创建了一个IMAP客户端连接,该连接使用SSL加密。 - 如果连接成功,方法返回
True
,否则返回False
。
- 这个方法用于连接到邮件服务器。它使用
-
list_folders(self)
:- 这个方法用于列出邮箱中的所有文件夹。它首先检查是否已连接到邮件服务器,如果没有连接,它会打印一条错误消息并返回一个空列表。
- 如果已连接,它使用IMAP客户端的
list_folders()
方法获取文件夹列表,并返回这些文件夹的名称。
-
fapiao_folder_exists(self)
:- 这个方法用于检查是否存在名为“发票”的文件夹。如果存在,它返回
True
。如果不存在,它尝试创建这个文件夹,并返回False
。 - 如果文件夹不存在,它会打印一条消息,然后使用IMAP客户端的
create_folder()
方法创建一个名为“发票”的文件夹。
- 这个方法用于检查是否存在名为“发票”的文件夹。如果存在,它返回
-
get_fapiao_emails(self, search_criteria=["UNSEEN"])
:- 这个方法用于获取未读邮件中主题包含“发票”的邮件。它首先检查是否已连接到邮件服务器,如果没有连接,它会打印一条错误消息并返回一个空列表。
- 如果已连接,它选择“发票”文件夹,并使用IMAP客户端的
search()
方法获取满足指定搜索条件(默认为未读邮件)的邮件ID。 - 然后,它遍历这些邮件,解码邮件主题,并检查主题中是否包含“发票”关键词。如果包含,将邮件的ID和消息存储在一个列表中,并返回这个列表。
- 如果主题不包含“发票”,它会将邮件标记为“未读”状态,然后打印一条消息。
下载发票
获取发票邮件的内容后,接下来就是对发票内容进行解析,找到发票文件并下载。
这里必须吐槽下,不同商家的发票邮件真的是五花八门,邮件内容格式各有特色。其实也是带给我们一些思考,像类似发票这种票据,应该从政府或行业层面出台统一标准,规定好数据交换格式,这样才能最大化数据流通效率。当然了,标准的制定通常是滞后行业发展的,欧美发达经济体那么标准体系那么健全,依然有大量企业使用自定义格式,导致数据交换效率低下。这就只能靠全社会一起努力了,尽早实现标准化、系统化。
目前下载发票使用了下面3中解析策略:
- 以附件发送发票pdf文件的邮件最好处理,这个符合邮件标准,不管正文说了啥,我们直接下载pdf附件就好了。
- 没有pdf文件附件的邮件,需要解析正文中的链接,找到pdf文件链接,然后下载。
- 最坑的就是正文中的链接连下载链接都不是,而是一个版式文件预览,然后还得手动点击按钮下载。这种目前就只能浏览器打开链接,模拟点击下载按钮了。这个过程可能需要人工干预。
下载了发票pdf文件后就比较好办了,把他们存到对应文件夹就好,按照日期整理到对应"年份/月份"文件夹。
发票下载被封装到FapiaoDownloader
这个类:
class FapiaoDownloader:
def __init__(
self,
download_dir=os.path.abspath(
os.path.join(os.path.dirname(__file__), "../fapiao")
),
):
# 创建输出目录
if not os.path.exists(download_dir):
os.makedirs(download_dir)
self.download_dir = download_dir
def download_fapiao(self, fapiao_emails):
fapiao_pdfs = []
for fapiao_email in fapiao_emails:
# 'Mon, 9 Oct 2023 17:05:39 +0800'
fapiao_email_date_str = decode_header(fapiao_email[1]["Date"])[0][0]
fapiao_email_date = datetime.strptime(
fapiao_email_date_str, "%a, %d %b %Y %H:%M:%S %z"
)
fapiao_email_month = fapiao_email_date.strftime("%Y/%m")
download_dir = os.path.abspath(os.path.join(self.download_dir, fapiao_email_month))
if not os.path.exists(download_dir):
os.makedirs(download_dir)
# 解析邮件附件
pdf_attachments = self._download_attachments(
fapiao_email, download_dir, fapiao_email_date
)
if pdf_attachments:
# 邮件包含附件,跳过解析邮件正文
fapiao_pdfs.extend(pdf_attachments)
continue
pdfs = self._download_url(fapiao_email, download_dir, fapiao_email_date)
if pdfs:
fapiao_pdfs.extend(pdfs)
return fapiao_pdfs
下载后的效果:
未来路线
这个小工具的代码量不大,但是已经基本可以满足我的需求了。
未来主要的更新方向有3个:
- 跟进发票改革,适应新政策,比如即将全面实施的全电发票。
- 减少漏收漏下,提高发票下载的准确性和成功率。
- 增加发票信息提取功能,提取发票中的关键信息,比如发票金额、发票时间、发票类型、发票抬头等。这个功能对个人意义不大,但可以用于企业发票管理,比如发票到期提醒、发票金额统计等。良好的财务实践必要要求规范的票据管理,同时还要积极处理流程中的数据要素。
获取方式
- 无痛使用可移步 发票自动下载整理机器人
- 贡献源码可访问 github仓库
题外:fapiao vs invoice
因为国内的发票和国外的invoice有些细节上的差别,并不完全等同,所以这个小工具的命名中没有使用invoice,而是fapiao😀
公众号 | FunIO
微信搜一搜 “funio”,发现更多精彩内容。
个人博客 | blog.boringhex.top