0x01 前言

在某些直播中计划进行抽奖活动,参加活动就得先报名,统计报名人员的信息是一个非常繁琐的工作。对于“懒到出汁”的我来说,手动统计是不可能的,这辈子都不可能的。

最近一次抽奖活动将在11号的直播中进行,而报名工作在我写这篇文章前就已经结束了,详细规则和其他内容请留意以下文章:

为了实现统计报名人数和“懒”的基本需求,我特意写了一个渣渣的python脚本。

0x02 思路与准备

为了方便使用脚本统一处理报名邮件,我针对我的实际情况制定了报名规则。需要编写标题格式如下的邮件并发送到指定的邮箱:

然后在邮件系统中新建一个文件夹用于存放报名邮件,还需要利用电子邮箱的自动规则功能识别邮件标题,并自动将报名邮件移动至该文件夹中。

因为我用的是office 365,所以这一切只需要在outlook中配置即可:

为了能通过脚本获取邮件,还需要开通邮箱系统的imap协议,以便通过imap协议登入邮件系统。

准备好之后即可开始编写脚本。

0x03 脚本

0x03.1 配置文件

首先需要准备配置文件,文件内容如下:

{
  "email": {
    "host": "outlook.office365.com",
    "port": "993",
    "username": "[email protected]",
    "passwd": ""
  },
  "subj_prefix": "NGX Proj第二期抽奖活动",
  "subj_count": 7
}

配置文件中包含电子邮箱的基本信息,因为我用的是office 365服务,所以信息如上。

还需要指定邮件标题的前缀,方便脚本中调用以便核查是否符合相关报名要求。最后是奖品类型的数量,这个数量的详情可以查看这次抽奖规则的文章。

0x03.2 定义基本信息

#!/usr/bin/python3
# -*- coding=utf-8 -*-

import email
import email.header
import imaplib
import os
import sys
from datetime import datetime

# 定义目录
local_path = sys.path[0]

# 邮箱账户信息配置文件
config_path = local_path + '/config.json'

# 读取配置文件
r_conf = open(config_path, 'r')
config_content = r_conf.read()
r_conf.close()

# 格式化配置文件
config_json = eval(config_content)

# 邮件配置
imap_host = config_json['email']['host']
imap_port = config_json['email']['port']
imap_username = config_json['email']['username']
imap_passwd = config_json['email']['passwd']
subj_prefix = config_json['subj_prefix']
subj_count = config_json['subj_count']

# 通过IMAP登入邮箱服务器
comm = imaplib.IMAP4_SSL(imap_host, imap_port)
comm.login(imap_username, imap_passwd)

# 获取邮箱文件夹列表
# print(comm.list())

# 定义邮箱文件夹,list格式
mail_folder = ['INBOX/NGXProj&eyxOjGcfYr1ZVm07Uqg-']

脚本一开始是读取配置文件并将相关信息赋值给变量,方便后面的函数调用。

这个脚本需要用到email和imaplib这两个模块,imaplib主要实现与邮件服务器的imap通讯;而email则负责处理邮件内容。

首先调用imaplib模块登入邮件服务器,并获取邮箱文件夹列表,赋值给变量:mail_folder。如果邮箱文件夹的名称包含英文以外的文字,则会被编码,赋值的时候不需要解码,它输出什么就填写什么,如上所示。

0x03.3 获取邮件

电子邮件在文件夹中是有独一无二的编号的,而且这编号是自动增长的,根据收到邮件的先后顺序自行分配,就算其中的邮件被删除也不会变更其他在此文件夹内的邮件的编号。

所以获取邮件的第一步需要向邮件服务器索取这个邮件id列表,相关函数如下:

# 获取邮件uid
def get_uid_list(folder_name):
    comm.select(folder_name)
    response, uid_list = comm.uid('search', None, 'ALL')
    uid_list = uid_list[0].decode().split(' ')
    return uid_list

# 邮件服务器返还的内容如下
[b'1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 32 33 34 35 37']

# 该函数输出的内容如下
['1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '16', '17', '18', '32', '33', '34', '35', '37']

有了邮件id,就可以通过id和文件夹名称向服务器拉去邮件主体:

# 获取邮件内容与标题(subject)
def get_mail_data(folder_name, mail_uid):
    comm.select(folder_name)
    response, mail_data = comm.uid('fetch', mail_uid, '(RFC822)')

    mail_data = email.message_from_string(mail_data[0][1].decode())

    msg_subj = email.header.decode_header(mail_data['Subject'])
    msg_subj = msg_subj[0][0].decode(msg_subj[0][1])

    msg_sender = email.header.decode_header(mail_data['From'])

    if len(msg_sender) == 1:
        msg_sender = msg_sender[0][0].strip("'").split('<')[1][:-1]
    elif len(msg_sender) == 2:
        msg_sender = msg_sender[1][0].decode()[2:-1]
    else:
        msg_sender = msg_sender[2][0].decode()[2:-1]

    return mail_data, msg_subj, msg_sender

一封邮件中包含的内容非常多,但我只需要邮件的内容、标题和发件人等信息。通过email.message_from_string函数可以很轻易地获取邮件不同部件的内容,然后加以处理即可得到我想需要的内容。

拉取邮件的时候需要注意的是,我的需求是拉取邮件的全部内容,在本地自行解析,这时候需要使用:

response, mail_data = comm.uid('fetch', mail_uid, '(RFC822)')

上面代码的意义为通过邮件的uid执行“fetch”命令,拉取该uid邮件的全部内容。

还有一种拉取邮件部分内容的方式:

IMAP4.fetch(message_set, message_parts)

通过这种方式可以减少数据的传输,比如需要拉取邮件的主题时,只需要拉取“Subject”即可,但在需要拉取多部份内容的情况下会导致请求次数过多,很久可能超过服务器的限制而导致脚本执行失败。

剩下的内容则是一些字符上的处理,没什么特别的。

0x03.4 文件存储与文件夹的建立

获取到邮件id列表后需要将最后的邮件id记录到一个临时文件中,当下次运行脚本时需要与之进行比对,以确认是否有新的邮件。

这个脚本本来是支持多个邮件文件夹的,为适配抽奖,我将大部分功能都删减了。但某些老旧的代码依然保留下来,下面的临时文件写入函数就是其中一个:

# 写入日志(N年前的代码片段,能用,但无心修改)
def write_log_to_file(folder, mail_uid):
    if os.path.exists(log_file_path):
        if os.path.getsize(log_file_path):
            f = open(log_file_path, 'r')
            f_dict = f.readline()
            f_dict = eval(f_dict)
            f_dict[folder] = mail_uid
            f = open(log_file_path, 'w')
            f.write(str(f_dict))
            f.close()
        else:
            f_dict = dict()
            f = open(log_file_path, 'w')
            f_dict[folder] = mail_uid
            f.write(str(f_dict))
            f.close()
    else:
        f_dict = dict()
        f = open(log_file_path, 'w')
        f_dict[folder] = mail_uid
        f.write(str(f_dict))
        f.close()

它会将传入的文件夹名称与邮件uid组合成字典的形式并写入临时文件中,内容格式如下:

{'INBOX/NGXProj&eyxOjGcfYr1ZVm07Uqg-': '37'}

本来我是计划将邮件保存功能一并删除的,但最后还是保留下来,以便日后查验邮件内容,以下是邮件内容的函数:

# 邮件保存函数
def write_content_to_file(folder, mail_id, file_content):
    mail_file_path = data_store_dir + folder + '/' + str(mail_id) + '.eml'
    w_file = open(mail_file_path, 'w')
    w_file.write(str(file_content))
    w_file.close()

对数据保持一个简洁明了的文件夹结构是非常重要的,所以还针对数据存储路径做了简单规划:

# 获取日期
today_date = datetime.today().date()
today_date = str(today_date).replace('-', '_')

# 定义目录,用于存储邮件数据
data_store_dir = local_path + '/datastore/' + today_date + '/'

# 建立数据存储目录
for i_folder_name in mail_folder:
    mail_content_dir = data_store_dir + i_folder_name
    if os.path.exists(mail_content_dir):
        if not os.path.exists(mail_content_dir):
            os.mkdir(mail_content_dir)
    else:
        os.makedirs(mail_content_dir)

以上代码会生成以下文件与目录结构:

[root@web tmp]# tree -L 5
.
├── config.json
├── datastore
│   └── 2018_11_10
│       └── INBOX
│           └── NGXProj&eyxOjGcfYr1ZVm07Uqg-
│               ├── 10.eml
│               ├── 11.eml
│               ├── 12.eml
│               ├── 13.eml
│               ├── 14.eml
│               ├── 15.eml
│               ├── 16.eml
│               ├── 17.eml
│               ├── 18.eml
│               ├── 1.eml
│               ├── 2.eml
│               ├── 32.eml
│               ├── 33.eml
│               ├── 34.eml
│               ├── 35.eml
│               ├── 37.eml
│               ├── 3.eml
│               ├── 4.eml
│               ├── 5.eml
│               ├── 8.eml
│               └── 9.eml
├── main.py
├── output.csv
└── temp.log

4 directories, 25 files

0x03.5 uid的判断

拉取邮件前判断uid是否有效是非常重要的,可以避免重复性工作。以下是相关函数:

# 检查邮件uid是否已经存在
def check_last_mail_uid(folder, mail_uid):
    temp_file_path = local_path + '/' + '.temp.log'
    try:
        temp_content = open(temp_file_path, 'r')
    except FileNotFoundError:
        return None, 0
    else:
        temp_content = temp_content.read()
        temp_dict = eval(str(temp_content))
        if folder in temp_dict:
            mail_old_uid = temp_dict[folder]
            if int(mail_uid) > int(mail_old_uid):
                return True, mail_old_uid
            else:
                return False, 0
        else:
            return None, 0

这里分3中情况:

  • 临时文件不存在:这说明从未进行过有效的邮件拉取动作,所以没有生成临时文件
  • 有临时文件但文件夹名称不在字典内:这说明之前进行过有效的拉取动作,但未拉取过当前的目标目录
  • 有临时文件且字典内有文件夹名称,且有与之对应的邮件id:这说明以前成功拉取过当前目标目录的邮件。

上面的函数会分别给三种不同的情况返还不同的内容,以便进行后续的工作。

0x03.6 邮件标题的格式化

在邮件源码内获取到邮件标题并不能直接使用,还需要对字符串进行处理:

# 格式化邮件标题,输出QQ号码与奖品列表
def format_subj(mail_subj):
    mail_subj_list = mail_subj.split('/')
    if mail_subj_list[0] == subj_prefix and len(mail_subj_list) == 3:
        applicant_id = mail_subj_list[1]
        subj_id_list = list(mail_subj_list[2])
        return applicant_id, subj_id_list
    else:
        return False

我要求的邮件主题格式如下:

NGX Proj第二期抽奖活动/21999888/2367

首先以“/”为元素对字符串进行分割,形成列表,然后判断列表的第一个元素是否与配置文件中“subj_prefix”的值相匹配,且列表共有3个元素。如果是则进一步处理,否则返还False,告知程序跳过这个邮件的处理流程。

当邮件主题符合要求后,会将第三个元素列表化,与第二个元素一起返还。

0x03.7 csv

逗号分隔文档–csv的格式是最简单的,直接写入到文本文档即可,以下函数就是干这工作的:

# 输出csv文件
def csv_output(mail_sender, applicant_id, subj_id_list):
    csv_output_front_part = mail_sender + ',' + applicant_id + ','
    count_start = 1
    while subj_count + 1 > count_start:
        if str(count_start) in subj_id_list:
            csv_output_front_part += 'True,'
        else:
            csv_output_front_part += 'False,'
        count_start += 1

    csv_file_path = local_path + '/output.csv'

    if os.path.exists(csv_file_path):
        csv_output_content = csv_output_front_part[:-1] + '\n'
    else:
        first_row_front_part = 'mail_sender' + ',' + 'applicant_id' + ','
        count_start = 1
        while subj_count + 1 > count_start:
            first_row_front_part += str(count_start) + ','
            count_start += 1
        csv_output_content = first_row_front_part[:-1] + '\n' + csv_output_front_part[:-1] + '\n'

    w_csv = open(csv_file_path, 'a')
    w_csv.write(csv_output_content)
    w_csv.close()

在写入之前需要组合每行的内容,通过遍历传入的奖品项目,然后将其转换为“True”和“False”即可。

接下来需要判断csv文件是否存在,如果不存在还需要组合首行内容,再和主要内容一起写入文件;如果文件存在则将主要内容写入即可。

写入后的内容如下图:

使用csv的一大好处是可以通过excel打开,打开后稍作修饰即可保存为excel的格式:

0x03.8 RUN

最后是run函数:

def run():
    for i in mail_folder:
        mail_uid_list = get_uid_list(i)
        mail_uid_new = mail_uid_list[-1]
        check_mail = check_last_mail_uid(i, mail_uid_new)
        check_mail_uid = check_mail[0]
        check_mail_value = check_mail[1]

        # 该uid较temp文件中的大,说明有新邮件
        if check_mail_uid:
            mail_uid_old = int(check_mail_value)
            while mail_uid_old < int(mail_uid_new):
                mail_content = get_mail_data(i, str(mail_uid_old))
                format_subj_tube = format_subj(mail_content[1])

                if mail_content[0] is None or format_subj_tube is False:
                    write_log_to_file(i, mail_uid_old)
                else:
                    write_content_to_file(i, mail_uid_old, mail_content[0])
                    csv_output(mail_content[2], format_subj_tube[0], format_subj_tube[1])
                    write_log_to_file(i, mail_uid_old)
                mail_uid_old += 1
        # 临时文件不存在或临时文件中没有相关邮件文件夹的key,说明从未下载过邮件
        elif check_mail_uid is None:
            for k in mail_uid_list:
                mail_content = get_mail_data(i, k)
                format_subj_tube = format_subj(mail_content[1])

                if mail_content[0] is None or format_subj_tube is False:
                    write_log_to_file(i, k)
                else:
                    write_content_to_file(i, k, mail_content[0])
                    csv_output(mail_content[2], format_subj_tube[0], format_subj_tube[1])
                    write_log_to_file(i, k)
        # uid与临时文件中的值一致,说明无新邮件
        else:
            pass

逻辑比较清晰,但是功能实现可能不太理想,主要是调用上面所说的各类函数。

0x04 结语

经过半天的调试,终于完成基本功能,估计以后的抽奖活动也会采用这种报名方式。在后续可能会增加实时展示功能,通过该功能可以将报名情况写入数据库,在前端显示。

生命在于折腾,其实我发现wordpress是由相关功能的插件的,但我就是不用!

通过我私有的gitlab可以找到最新的代码,还可以查看更新记录噢: