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

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

思路

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

嗯,就这么简单。

实现

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

EMAIL相关库

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

  1. smtplibemail 模块

    Python标准库中的smtplibemail模块可以用来发送邮件。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()
    
  2. imaplibpoplib 模块

    Python的imaplibpoplib模块可以用来接收邮件。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()
    
  3. 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类,用于连接到邮件服务器,检索特定文件夹中的未读邮件,并再次检查主题是否包含“发票”关键词。

  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这个类:

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😀

公众号 | FunIO
微信搜一搜 “funio”,发现更多精彩内容。
个人博客 | blog.boringhex.top

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值