又要报销了,还在手动下载整理发票吗?

大多数公司都是每个月定期提交报销,一般报销用的发票都是电子发票发到邮箱,每次要报销时都需要登录邮箱,点开邮件,一个个下载整理,工作量不大,但是发票多了也着实很烦。这个月终于下决心把这个过程自动化一下。

思路

查看了一下邮箱里的发票邮件,虽然主题内容格式不固定,但是基本都包含“发票”,所以可以用“发票”关键词将发票邮件筛选出来。然后解析发票邮件内容,将发票pdf文件提取下载,并整理到指定文件夹。

嗯,就这么简单。

实现

这种事还是用python比较快吧,搞起。

EMAIL相关库

在Python中,有一些常用的库可以用来操作和处理邮件(email):

  1. smtplibemail 模块

    Python标准库中的smtplibemail模块可以用来发送邮件。smtplib模块负责连接到SMTP服务器并发送邮件,而email模块负责创建和解析邮件内容。这两个模块结合使用可以实现邮件的发送。

    示例代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    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()
  2. imaplibpoplib 模块

    Python的imaplibpoplib模块可以用来接收邮件。imaplib模块用于连接到IMAP服务器,而poplib模块用于连接到POP3服务器。这两个模块可以用来检索邮件。

    示例代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    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()
  3. yagmail

    yagmail是一个简化了发送和接收邮件的Python库。它简化了使用SMTP发送邮件和IMAP接收邮件的过程,提供了更加友好的API。

    示例代码:

    1
    2
    3
    4
    5
    6
    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类中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
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类,用于连接到邮件服务器,检索特定文件夹中的未读邮件,并再次检查主题是否包含“发票”关键词。

  1. __init__(self, server, username, password):

    • 这是类的构造函数,用于初始化EmailClient对象。它接受三个参数:server(邮件服务器地址)、username(邮箱用户名)和password(邮箱密码)。
    • 它将这些参数存储在类的实例变量中,以便在整个类中使用。
  2. connect(self):

    • 这个方法用于连接到邮件服务器。它使用imapclient库创建了一个IMAP客户端连接,该连接使用SSL加密。
    • 如果连接成功,方法返回True,否则返回False
  3. list_folders(self):

    • 这个方法用于列出邮箱中的所有文件夹。它首先检查是否已连接到邮件服务器,如果没有连接,它会打印一条错误消息并返回一个空列表。
    • 如果已连接,它使用IMAP客户端的list_folders()方法获取文件夹列表,并返回这些文件夹的名称。
  4. fapiao_folder_exists(self):

    • 这个方法用于检查是否存在名为“发票”的文件夹。如果存在,它返回True。如果不存在,它尝试创建这个文件夹,并返回False
    • 如果文件夹不存在,它会打印一条消息,然后使用IMAP客户端的create_folder()方法创建一个名为“发票”的文件夹。
  5. get_fapiao_emails(self, search_criteria=["UNSEEN"]):

    • 这个方法用于获取未读邮件中主题包含“发票”的邮件。它首先检查是否已连接到邮件服务器,如果没有连接,它会打印一条错误消息并返回一个空列表。
    • 如果已连接,它选择“发票”文件夹,并使用IMAP客户端的search()方法获取满足指定搜索条件(默认为未读邮件)的邮件ID。
    • 然后,它遍历这些邮件,解码邮件主题,并检查主题中是否包含“发票”关键词。如果包含,将邮件的ID和消息存储在一个列表中,并返回这个列表。
    • 如果主题不包含“发票”,它会将邮件标记为“未读”状态,然后打印一条消息。

下载发票

获取发票邮件的内容后,接下来就是对发票内容进行解析,找到发票文件并下载。

这里必须吐槽下,不同商家的发票邮件真的是五花八门,邮件内容格式各有特色。其实也是带给我们一些思考,像类似发票这种票据,应该从政府或行业层面出台统一标准,规定好数据交换格式,这样才能最大化数据流通效率。当然了,标准的制定通常是滞后行业发展的,欧美发达经济体那么标准体系那么健全,依然有大量企业使用自定义格式,导致数据交换效率低下。这就只能靠全社会一起努力了,尽早实现标准化、系统化。

目前下载发票使用了下面3中解析策略:

  1. 以附件发送发票pdf文件的邮件最好处理,这个符合邮件标准,不管正文说了啥,我们直接下载pdf附件就好了。
  2. 没有pdf文件附件的邮件,需要解析正文中的链接,找到pdf文件链接,然后下载。
  3. 最坑的就是正文中的链接连下载链接都不是,而是一个版式文件预览,然后还得手动点击按钮下载。这种目前就只能浏览器打开链接,模拟点击下载按钮了。这个过程可能需要人工干预。

下载了发票pdf文件后就比较好办了,把他们存到对应文件夹就好,按照日期整理到对应”年份/月份”文件夹。

发票下载被封装到FapiaoDownloader这个类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
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

下载后的效果:

发票PDF文件

未来路线

这个小工具的代码量不大,但是已经基本可以满足我的需求了。

未来主要的更新方向有3个:

  1. 跟进发票改革,适应新政策,比如即将全面实施的全电发票。
  2. 减少漏收漏下,提高发票下载的准确性和成功率。
  3. 增加发票信息提取功能,提取发票中的关键信息,比如发票金额、发票时间、发票类型、发票抬头等。这个功能对个人意义不大,但可以用于企业发票管理,比如发票到期提醒、发票金额统计等。良好的财务实践必要要求规范的票据管理,同时还要积极处理流程中的数据要素。

获取方式

  1. 无痛使用可移步 发票自动下载整理机器人
  2. 贡献源码可访问 github仓库

题外:fapiao vs invoice

因为国内的发票和国外的invoice有些细节上的差别,并不完全等同,所以这个小工具的命名中没有使用invoice,而是fapiao😀