用python编写网页更新提醒程序

本学期的很多课程依赖课程网页发布通知、课件、作业、OJ。因为不想每天手动上这堆网站查看更改,我用python写了一个程序来自动检测和提醒课程网站的更改。

这篇文章将讲一下如何订阅我已经写好的提醒服务、如何写一个自己的提醒服务。

项目代码已经开源到GitHub

订阅已有服务

注意:本服务仅针对南京大学人工智能学院20级

订阅:发送任意邮件到 subscribe20ai@caomingjun.com

取消订阅:发送任意邮件到 cancel20ai@caomingjun.com

一旦订阅,程序会在网页更改时通过邮箱 notifier20ai@caomingjun.com 向您发送信息。

如果没有出现bug,订阅或取消订阅后,你可以在5分钟内收到自动回复邮件。在收到自动回复邮件之前,请不要进行任何操作(比如订阅后在没有收到回复的情况下取消订阅),虽然这种行为不会导致程序出现异常,但是会导致你不知道自己订阅了没有。

重复订阅、不在订阅列表中的邮箱取消订阅不会收到回复。

程序每3分钟访问一次受监视网页,如果发生更改会以邮件提醒。因此最大延迟为3分钟。

需要任何帮助(或者发现程序出现奇怪的举动),请联系 support@caomingjun.com,这个邮箱由人类管理。

目前受监视的网页列表为:

2022年2月28日更新:

列表已更新为2021-2022学年第二学期的内容

名称 URL
机器学习导论公开网站 https://www.lamda.nju.edu.cn/yehj/ml2022/ml2022.html
机器学习导论内部网站 https://www.lamda.nju.edu.cn/ml2022/home.htm
机器学习导论课件 https://www.lamda.nju.edu.cn/ml2022/index.html
智能系统设计与应用 https://www.lamda.nju.edu.cn/hanlu/course/is2022/is2022.html
操作系统主页 http://114.212.80.195:8170/os_ai2022/
操作系统实验课 http://114.212.80.195:8170/os_ai2022/oslab/

如果有任何遗漏欢迎发送邮件到 support@caomingjun.com 指出。

后面的内容是关于如何编写这个提醒程序的,如果你只是想订阅提醒服务,就不需要再往下看了

准备工作

本地还是服务器?

我的程序用到了一台腾讯云的轻量应用服务器以实现实时监测,这也可以由其他的服务器或者任何24小时开机的计算机完成。

如果你愿意牺牲实时性,改为定时(或者在个人电脑开机启动时)检查网页的更改,也可以不需要服务器。这样需要对程序做一些修改(主要是简化),这些修改会在文末提出。

需求分析

我们需要实现以下几个功能模块:

  • 读取网页数据并分析与之前数据的区别
  • 保存网页数据以便下一次比较
  • 发送邮件
  • 接受邮件并将发件人加入或移出订阅列表(如果你只是写给自己用,可以忽略)

我希望在更改配置(发件和收件邮箱、监视网页列表)时保持程序运行,并且在程序中断(更新维护或者程序报错)时保存数据,所以我把数据存储在数据库中,这里使用 MongoDB(这不是关系数据库)。如果不需要这些功能,你也可以不使用数据库而改用文件(如 json)甚至直接将数据保存在程序的变量中。

依赖

  • 一台服务器(可选)
  • 在服务器上安装宝塔面板并在其应用商店中安装python项目管理器(如果你不想要图形化界面,可以不装这两个)
  • 安装 MongoDB(如果不使用数据库,可以不装)
  • 在本地安装 MongoDB Compass(如果不使用数据库或者不想用图形化界面管理数据库,可以不装)
  • 在运行程序的机器上安装 python3,我的版本是 3.7.9
  • 在项目文件夹下新建文件夹(随便什么名字,我的是myMdmail)将 GitHub 项目 mdmail 的这个文件夹下的所有内容下载到新建的文件夹中。不直接安装的原因是我准备对它进行更改
  • 还有若干 python 包,在编写程序过程中会提到,到时候再安装。(其实是我懒得整理并列在这里)

数据库交互

如果不使用数据库,这一部分改为与文件的交互

建立数据库

安装好 MongoDB 后,可以直接用 MongoDB Compass 管理数据库,连接方式非常简单,在后者的连接处输入 mongodb://8.8.8.8:27017/ 即可(将 8.8.8.8 更改为你的服务器的公网IP)

为了数据库的安全性,通常会为数据库建立账户并将数据库改为只能通过账户访问。具体方式请 STFW 。

首先建立一个数据库,建议用项目的名字命名,比如 20AI_reminder

我把用户列表、监视网站列表、基本配置放进了数据库,因此在 20AI_reminder 下创建以下 Collection

  • websites,监视网站列表,包括其名称、URL、读取类型(后面会提到)、内容(程序保存以便下次比较的网页数据)。如果是网盘链接,还要存储密码。
  • users,用户列表,仅包括用户邮箱。
  • config,程序配置,包括发件和收件邮箱等。整个 Collection 只有一个行。

python与数据库交互

MongoDB 提供了 pymongo 包来实现 python 与 MongoDB 的交互。教程见菜鸟教程

这个部分应当包括:

  • 获取程序配置信息
  • 获取监视网站列表
  • 获取用户列表
  • 增加和删除用户
  • 更新网站内容
  • 刷新数据库内容

建议把数据库交互写成一个单独的 .py 文件,其他各文件都导入它,以实现配置信息在文件间的共享。

获取网页

这个模块会访问网页,以Markdown格式(比HTML代码更加用户友好)输出更改。

我们需要获取多个网页的内容,包括普通的HTML网页、RSS、网盘,因此要为不同类型的网页设计不同的函数。

这些函数统一以网站(一个字典,包括其名称、URL、读取类型、内容)作为输入,输出发送给用户的更改提示。如果没有更改,输出空字符串。如果网页发生更改,函数还要调用数据库交互的模块以更新网页内容。

注意网络连接可能超时,一定要设置一定的重试次数。

普通网页

可以使用 markdown 包的函数 markdown.Markdown,它可以很轻松地将HTML代码转换为Markdown。

在使用过程中,我发现在转换部分网页时, markdown.Markdown 的输出只有一行(所有换行都消失了),所以为这类网页单独设计了一个函数,逐行将HTML代码传入 markdown.Markdown 来进行转换。

获取网页的Markdown之后,与数据库中存储的数据进行比较,如果不同,调用 difflib.unified_diff 以输出 diff 格式的文本。

diff 格式的文本并不是非常用户友好,但是也还凑合。或许以后会改。

RSS

RSS的获取简单得多。feedparser 包提供了 feedparser.parse 函数来解析RSS,STFW并使用即可。

然后就是进行比较了,我只对每一项的标题进行比较,输出的是非常用户友好的文本。

南大网盘

这部分比较复杂,因为涉及到了密码的验证。

先定义 token ,这是为链接中的一部分,比如分享链接为 https://box.nju.edu.cn/d/12345/ ,那么 token12345

整个获取过程如下:

首先 GET https://box.nju.edu.cn/d/<token>/ 得到 sfcsrftoken (在cookies中)

再从上面的返回HTML中找到 csrfmiddlewaretoken (通过 regex 包进行正则表达式匹配)

然后以 sfcsrftoken 为cookies;body为 csrfmiddlewaretokentoken(在网址中)、password为内容进行请求。

body例子:

csrfmiddlewaretoken=1234&token=1234&password=1234

得到两个cookies:(新的)sfcsrftokensessionid

以这两个cookies向 https://box.nju.edu.cn/api/v2.1/share-links/<token>/dirents/?thumbnail_size=48&path=%2F 发出请求并获得一个JSON文件。这个JSON包括了分享的文件夹根目录下的文件(文件夹)列表。

我们可以使用 request 包提供的 request.Session 类,它会自动帮我们处理cookies。

获取JSON后进行比较并输出即可。我的程序中这部分输出的文本也是非常用户友好的。

发送邮件

在网站发生更改、有新订阅者、有人取消订阅时,程序都需要发送邮件。

由于无法直接发送Markdown格式邮件,我们要先把Markdown转为HTML。

发送邮件采用更改过的 mdmail 模块,我们前面已经下载了原版的 mdmail,接下来对它进行更改。

首先在 api.py 中更改 EmailContent 类的初始化函数,让它支持显示代码:

点击展开代码 >folded
1
2
3
4
5
6
self._md = markdown.Markdown(extensions=[
'markdown.extensions.tables',
'markdown.extensions.meta',
+ 'markdown.extensions.codehilite',
+ 'fenced_code'
])

然后,由于邮件是群发的,为了保护用户的隐私,我不希望让程序在发送邮件时显示收件人,所以使用邮件的密送功能(BCC),这样收到邮件的人就不知道还有谁订阅了服务。mdmail 使用了 emails 包来发送邮件,但是我没有找到如何用 emails 发送只密送的邮件(可以发送密送,但是不能没有普通收件人),所以我将其改为使用 smtplib。这需要对 api.py 中的函数 send 作更改:

点击展开代码 >folded
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
-	message_args = {
- 'html': email.html,
- 'text': email.text,
- 'subject': (subject or email.headers.get('subject', '')),
- 'mail_from': from_email,
- 'mail_to': to_email
- }
- if cc:
- message_args['cc'] = cc
- if bcc:
- message_args['bcc'] = bcc
- if reply_to:
- message_args['headers'] = {'reply-to': reply_to}

- message = emails.Message(**message_args)

- for filename, data in email.inline_images:
- message.attach(filename=filename, content_disposition='inline', data=data)

- message.send(smtp=smtp)

+ msg=MIMEText(email.html,'html','utf-8')
+ msg["From"] = Header(from_email, 'utf-8')
+ msg["To"] = Header("订阅者", 'utf-8')
+ msg["Subject"] = Header((subject or email.headers.get('subject', '')), 'utf-8')
+ smtp_obj = smtplib.SMTP_SSL(host=r"smtp.qiye.aliyun.com", port=465)
+ smtp_obj.connect(host=r"smtp.qiye.aliyun.com", port=465) # 25 为 SMTP 端口号
+ smtp_obj.login(smtp["user"], smtp["password"])
+ r = smtp_obj.sendmail(from_email, bcc, msg.as_string())
+ smtp_obj.quit()
+ return r

注意

  • 将两处 smtp.qiye.aliyun.com 更改为你使用的 smtp 服务器,端口也要改
  • 与原来相比,函数现在会返回一个值。这个值是一个字典,当邮件没有成功发送给所有收件人(包括密送)的时候,这个字典会对每一个失败者返回一个键值对。我把这个字典记录到日志以便辨别和处理,你也可以忽略。如果邮件发送成功,字典为空,如果全部失败,程序直接报错(我还没有遇到过)
  • 很多邮箱会对发送邮件进行限制,不要在短时间内发送多封邮件,尤其是标题相同的邮件。

读取邮件

程序需要从订阅和取消邮箱中读取邮件以增删用户。这部分使用 imaplib 完成。

需要注意:

  • imaplib.IMAP4_SSL 在初始化时可能出现连接错误,需要设置重试次数(其实就是异常处理)
  • 要考虑在程序的一个运行周期内有人进行多次操作的情况(比如订阅后没有收到回复就取消),因此要根据收到邮件的时间进行排序

示例代码如下,这个函数读入收件邮箱、密码、操作类型(添加或删除)并输出一个包含操作类型、发件人、时间的元组组成的列表。外层函数会合并两个列表,进行排序并逐个操作。

点击展开代码 >folded
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
def read_email(self, receiver, password, op_type):
for i in range(10):
try:
email_server = imaplib.IMAP4_SSL(host="imap.qiye.aliyun.com", port=993)
except Exception as e:
if i < 9:
log_debug_pure(f"发生错误:{repr(e)},正在进行第{i + 2}次尝试")
else:
raise e
else:
break
email_server.login(user=receiver, password=password)
email_server.select(mailbox='INBOX', readonly=False)
status, data = email_server.search(None, 'UnSeen')
email_list = data[0].split()
r = []
for email_id in email_list:
email_type, row_email = email_server.fetch(email_id, '(RFC822)')
msg = email.message_from_bytes(row_email[0][1])
t = msg.get("Date")
r.append((op_type, email.utils.parseaddr(msg.get('from'))[1],
time.strptime(msg.get("Date")[0:31],
"%a, %d %b %Y %H:%M:%S %z")))
email_server.store(email_id, '+FLAGS', r'(\Seen)')
email_server.logout()
return r

尾声

运行周期与异常处理

主程序使用一个 try-except 包裹,except 将异常通过邮件发送给程序管理员(就是你自己)。

运行周期通过 crontab 实现。

部署

在服务器上安装 python 和需要的包,使用 crontab 定时运行。如果需要,也可以为它创建对应的虚拟环境。

本地版本

如果你只想在本地手动或者开机启动时运行一次网页更改检测,你可以:

  • 不写数据库部分,而是用文件储存数据
  • 不写订阅和取消部分(包括读取邮件),因为只有你一个人用
  • 不写发送邮件部分,而是采用命令行输出
  • 不写运行周期,甚至不写异常处理,因为运行的时候你在看着输出

升级

提醒方式升级

你可以把提醒方式改为使用微信公众号

我暂时没有做这部分功能,而且由于非技术原因(腾讯政策),做好了也很可能不会正式上线,而是仅小范围使用。

获取网页 meta property

我未来给自己做小说更新提醒程序的时候要用到,这里是教程

鸣谢

感谢 OrangeX4 参与程序的测试工作并提出建议

感谢在网上发布各种开源程序包和教程的不认识的大佬们

作者

Cao Mingjun

发布于

2021-09-29

更新于

2022-02-28

许可协议

评论