建立一个简单的 Telegram Bot 来存档 Web 内容

在正式内容开始之前,我先来说一下上一篇文章提到的新博客,地址在 https://next.jimmy0w0.me 可以体验到

造一个 Telegram Bot 来存档网页

为什么?事情起因在好几个星期前一名友人找我帮忙存档一些容易 404 的网站快照;可能是一些新闻,或者是在国内论坛上的一些敏感发言。我最初是使用 archive.org 来建立网站快照,但是不知道为什么当时 archive.org 即使我翻了墙访问速度依然很慢,可能是当时线路不好的原因 (现在不慢了)

于是我就萌生了一个想法,既然当时访问 archive.org 的速度那么慢,然后用的还是人家的服务,那为什么不自己写一个

存档网站这种事情方法有非常多种,有的存储下网页的源代码,有的用截屏,然后我选择用 PDF 来存储一个网页的内容

首先是 PDF 可以极大程度的还原网站的样貌,同时可以直接合并成单一文件,文件大小也可以接受;另一个是,PDF 同样具有可以交互的特性,比如你可以复制里面的文字,点击里面的超链接等等;最骚的是,你还可以通过一些工具 (比如 Windows 资源管理器自带的搜索功能) 来搜索 PDF 文件里面的内容,顺便还可以具有高亮文字,注记的特性

用户在 Telegram 中使用特定的命令 + 网页地址参数,就可以在服务端创建出 PDF 文件并且存档到一个地方

大致的构想有了,接下来就是实现了

我不会很详细的说实现的部分,不过我会说一些用 Headless Chromium 时候的一些些坑

Telegraf 到 node-telegram-bot-api

我最早写 Bot 的时候用的是一个叫做 Telegraf 的 Telegram Bot 依赖,这个东西大倒是挺大的,不能直接接收命令参数倒是挺拉跨的

当时我用的版本是 3.x 的,有一个现成的中间件,安装之后就可以接收命令参数了,后来到了 4.x 就不兼容了,报错儿

于是这次写 Bot 我就换了一个依赖,找了一下,node-telegram-bot-api 似乎不错

官方文档直接演示了如何接收命令参数

1
2
3
4
5
6
7
8
9
10
11
12
// Matches "/echo [whatever]"
bot.onText(/\/echo (.+)/, (msg, match) => {
// 'msg' is the received Message from Telegram
// 'match' is the result of executing the regexp above on the text content
// of the message

const chatId = msg.chat.id;
const resp = match[1]; // the captured "whatever"

// send back the matched "whatever" to the chat
bot.sendMessage(chatId, resp);
});

match 是一个数组,按需截取即可

Bot 依赖的部分找到了,我还挺喜欢这个依赖的,接下来就是如何生成 PDF 和存储在哪里的问题了

puppeteer

经过之前的搜索,我还是使用了 puppeteer 这个依赖来生成网站的 PDF

puppeteer 是一个 Headless 的 Chromium;简单来说,就是没有 GUI 界面的浏览器

它可以被代码所操控,例如建立一个浏览器实例,打开一个页面,打开 URL,最后生成出 PDF

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
const puppeteer = require("puppeteer");

const createPDF = async (url) => {
const browser = await puppeteer.launch({
pipe: true,
args: [
"--headless",
"--disable-gpu",
"--full-memory-crash-report",
"--unlimited-storage",
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
],
});

const page = await browser.newPage();

await page.setUserAgent(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36"
);

await page.goto(url, {
waitUntil: ["load", "domcontentloaded", "networkidle0", "networkidle2"],
});

await page.pdf({
path: "content.pdf",
format: "a2",
printBackground: true,
});

await browser.close();
};

createPDF("https://moe.jimmy0w0.me");

这样就可以生成出一个名字为 content.pdf 的 PDF 文件,当然,你想保存在一个文件夹里统一管理也是没问题,自己改一下路径即可

page.pdf 那里有几个参数,路径,格式和打印背景

可以参考官方文档来自行修改,反正我就这么写了,目前比较适合用于存档网页快照

顺带一提,page.pdf 也支持使用 widthheight 参数来调整生成大小,但是当你设置了 format 参数之后,widthheight 参数会失效

Google Firebase

Firebase Storage

我没有太多的精力来构建一个存储文件的服务器,我也不想买服务器养着供着,所以我会考虑使用一个靠谱的 BaaS (后端即服务)

前面说过,网页存档可能会存档一些容易 404 的内容,这也就说明了内容肯定是非常违规国内国情的

因此像是国内的腾讯云开发 Cloudbase,LeanCloud 我都直接不会考虑使用

剩下来的只有 Google 的 Firebase

好在 Google 还算大方,Firebase 的 Storage 功能免费计划就可以存 5 GB 的内容 (跟现在普通的国外网盘服务差不多,比 Google Drive 小 10 GB)

比较影响 PDF 大小的还是图片,如果单单是文字,其实一般不会达到 1 MB;图文并茂的,例如一篇微信公众号文章,大概也可以稳定在 5 MB 以下

除非是那种大型网站,非常图文并茂,少张图都没办法说明件事的时候,基本上才会达到 10 MB 左右或者更大;不过目前还没见到有存档到这么大的单个 PDF 文件

所以目前,5 GB 可以说是非常宽裕

然后有些事情需要注意,为了让用户可以直接访问到他刚刚存档的文件,通常我们需要返回文件的地址

为了构建出可以直接访问的地址,使用 Firebase Storage Bucket 的 Upload 方法的时候需要自定义文件的访问 Token

Like this

1
2
3
4
5
6
7
8
await bucket.upload(path, {
metadata: {
metadata: {
firebaseStorageDownloadTokens: fileAccessToken,
},
},
});
});

这个 fileAccessToken 你可以用 uuid 依赖下的 v4 方法来生成

最终地址应该像是这样的

1
https://firebasestorage.googleapis.com/v0/b/{BUCKET_SPOT_NAME}.appspot.com/o/{FILE_NAME}?alt=media&token={FILE_ACCESS_TOKEN}

然后按照这样的格式返回给用户,用户就可以直接访问到刚刚存储的 PDF 文件了

Firebase Firestore

然后我还弄了一个文件访问代码的功能,大概就是通过 /get 命令 + 文件访问代码就可以获取回之前的文件

完全就是卵用不大的功能,但是用户在记录存档的文件的时候,长长的 URL 可以被这个文件访问代码取代

文件访问代码是由 7 个随机字符组成的,用的是 random-string 这个依赖

在存储的时候像是这样

1
2
3
await db.collection("archive").doc(fileAccessCode).set({
fileAccessUrl,
});

这个 fileAccessCode 就是通过 random-string 生成出来的七个代码

然后 fileAccessUrl 是上面用户可以直接访问到文件的 URL

查询的时候使用

1
2
3
const data = (await db.collection("archive").doc(fileAccessCode).get()).data();

console.log(data.fileAccessUrl);

就可以访问到文件链接,然后返回给用户

其他功能

我还写了几个逻辑来丰富 Bot 的功能,例如成功上传 PDF 文件之后自动清理 cache 文件夹下的 PDF 文件;以及在出错的时候自动向我的 Telegram 频道汇报错误

在开发到部署的时候碰到了两个坑

  1. 懒加载资源

  2. 中文字体

1. 懒加载资源

在我朋友存档一篇微信公众号的文章的时候,我发现上面的图片正确显示,但是下面的图片竟然还是加载的状态

经过实验,微信公众号确实是用了懒加载

也就是,当用户的设备没有往下滑动到图片位置的时候,图片不会加载

这种设计方案是用来减少腾讯服务器的资源浪费

于是我去搜索了一下解决方案,参考这篇 GitHub Issue 目前解决了公众号图片加载问题

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
const puppeteer = require("puppeteer");

const createPDF = async (url) => {
const browser = await puppeteer.launch({
pipe: true,
args: [
"--headless",
"--disable-gpu",
"--full-memory-crash-report",
"--unlimited-storage",
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-dev-shm-usage",
],
});

const page = await browser.newPage();

await page.setUserAgent(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36"
);

await page.goto(url, {
waitUntil: ["load", "domcontentloaded", "networkidle0", "networkidle2"],
});

await page.setViewport({
width: 1920,
height: 1080,
});

await page.evaluate(async () => {
await new Promise((resolve, reject) => {
let totalHeight = 0;
let distance = 100;
let timer = setInterval(() => {
let scrollHeight = document.body.scrollHeight;
window.scrollBy(0, distance);
totalHeight += distance;

if (totalHeight >= scrollHeight) {
clearInterval(timer);
resolve();
}
}, 100);
});
});

await page.pdf({
path: "content.pdf",
format: "a2",
printBackground: true,
});

await browser.close();
};

createPDF("https://mp.weixin.qq.com/s/dmn03D8KRjWwsQ5V_lsDFA");

目前测试下来,下面的图片都可以正确加载

2. 字体问题

首先感谢 Maverick5g 友情提供的香港 Google Cloud Platform 服务器来部署我的 Bot

GCP 服务器用的 Ubuntu 系统是 English 的,没有安装任何中文字体

因此在首次部署测试的时候,发现出来的 PDF 全部都是方块字

这种就是没有中文字体的后果

解决方案也十分简单,去 Google 搜索如何给 Ubuntu 安装中文字体即可,按照自己喜好安装;我安装的是微软的雅黑字体,因为可以直接从我的 Windows 系统中复制出来,转换成普通的 ttf 字体文件,然后丢到 Ubuntu 中安装

结尾

好,至此,我的简单的存档网页用的 Bot 就开发的差不多了

截止到这篇文章撰写完毕,这个 Bot 已经有了 22 次的 commits

有兴趣了解的,可以来 Telegram 玩玩,搜索 nothing 即可

注:因为在中国某些案件原因,本 Bot 不再向公众开放服务