背景
笔者最近主导了公司内部的一个跨多团队的技术项目,大家都知道,这种多团队协作的项目,要点之一就是要写好文档。否则你将面临每天不同的人来询问你相同的问题,然后你把答案无数次的重复回答,根本没有时间干别的。更何况一些较复杂的技术方案,很难用语言表述清楚。
总之,这次项目极其重视文档。我们用的飞书,建了专门的知识库,积累了很多文档。最后,在汇报的时候,碰到了一个小难题:怎么来加深大家对于文档重要性的认识呢?
用数字说话是最具有说服力的,B 格也是很高的。于是就找到了管理员,问后台有没有知识库的数据。结果只有一些活跃度之类的数据,而且只有最近 2 个月的。笔者真正需要的是整个知识库每篇文章的访问数、字数、评论数等数据。就像下图当中,更多->文档信息
当中展示的:
最直接的办法当然是点开每篇文章查看,然后把数据输入到 Excel 里进行统计。开玩笑!作为一个堂堂的码农!怎么可能会这么 low 的干?!我为脑子里产生这种解决方案而感到羞耻!就算人工 1 个小时就能搞定,也必须花上 3 个小时写个爬虫(再花 3 个小时写一篇文章)。废话不多说,开搞!
不熟悉 Puppeteer(以下简称 pptr)的同学也不用担心,基本都能看得懂,甚至可以作为 pptr 的教学 Demo 来看。
分析
因为飞书文档需要登录,所以还是用 pptr 模拟人工操作方便一些。获取文档数据有两种思路,一种是从接口读,一种是从页面元素读。笔者首先尝试了接口,发现 /space/api/obj_stats/get/
这个接口应该是我们想要的。但是仔细研究一下后,发现了 2 个问题。第一个问题是接口的返回数据里,只有 UV
、PV
这些数据,没有字数。
另外,参数当中有一个 token。如果是不在 wiki 里的单独的文档,这个值可以在 URL 中拿到。但是如果是在 wiki 里,情况就有点复杂了。有的文档是快捷方式挂进来的,有的是后搬进来的。总之很多文档的这个 token 与 URL 中的参数对不上。
笔者知道,以上两个问题如果花时间,还是有很大可能性找到解决办法的,但是问题是没时间啊。闹呢?!本来计划 3 小时的,再研究研究变成 5 小时了,我傻啊?果断放弃,换思路。
从页面元素读,不就是模拟人点点点嘛,能有什么难的呢?好吧,一般有这种想法的都会被打脸,下面请各位读者来听啪啪声吧。
正文
准备
因为要登录,所以采用「有头」模式比较方便,即 headless: false
。另外,因为 pptr 启动的浏览器是无痕模式,每次你都要扫码登录,很麻烦。所以笔者在做这种项目时采用了如下方法:
- 开一个命令行,用 pptr 启动一个浏览器,一直开着,然后记录它的
wsEndpoint
值; - 再开一个命令行,运行真正的爬虫逻辑。但是不再新启浏览器,而是用
wsEndpoint
连接刚才启动的浏览器;
这样做的好处是 1 启动的浏览器一直在,就不需要每次再登录了,也可以很方便的用手动操作控制初始状态。而 2 运行的逻辑,可以随时退出,完全不会影响浏览器。示例代码如下:
const puppeteer = require("puppeteer-core");
const executablePath = "/usr/bin/google-chrome";
(async () => {
await puppeteer.launch({
executablePath,
headless: false,
args: ["--start-maximized"],
});
console.log("Browser wsEndpoint is: ", browser.wsEndpoint());
})();
/** Useage
const browser = await puppeteer.connect({ browserWSEndpoint: 'xxxxxx' });
const page = (await browser.pages())[0];
*/
(2022.08.31 按,最新版已经将 wsEndpoint 存入了本地临时文件中,其它脚本从此读取即可,不需要手动注入了)
另外再封装 2 个常用的方法,「点击元素」和「毫秒延时」:
const waitClickSelector = async (page, selector) => {
await page.waitForSelector(selector);
await page.click(selector);
};
const delay = async (ms) => new Promise((rev) => setTimeout(() => rev(), ms));
读取单篇数据
我们先试着读取单篇的数据,大概分为以下几步:
- 各种点击操作,唤出带有数据的 Modal;
- 获取到需要的元素,读取里面的内容;
- 按
ESC
关闭 Modal;
await waitClickSelector(page, "button.more-btn");
await delay(100);
await waitClickSelector(page, ".document_detail");
await page.waitForSelector(".gpf-doc-detail-v2__detail-modal-v2");
let [words, chars, uv, pv, like, comments] = [];
const [owner, created] = await page.$$eval(
".gpf-doc-detail-v2__detail-label",
(eles) => eles.map((el) => el.innerText)
);
const items = await page.$$eval(".gpf-doc-detail-v2__value", (eles) =>
eles.map((el) => el.innerText)
);
[words, chars, uv, pv, like, comments] = items;
const record = { title, owner, created, words, chars, uv, pv, like, comments };
console.log(i, record);
await page.keyboard.press("Escape");
这里说几点注意事项:
- 第 2 行的
delay
是因为菜单展开有动画,懒得找相应的 wait 方法了,直接一个 delay 完事; - 声明变量时用了「解构赋值」的语法,不太熟悉的同学可以查一下;
- 核心方法就是
$$eval
,不了解的同学可以查看 文档;
OK,到目前为止还是很顺利的,继续!
读取多篇数据
要做的事情非常简单:读取左侧文档列表,然后遍历迭代执行「单篇逻辑」就行了。
我:就这??!!
现实:啪!你没发现列表的长度是会变的吗?
我:你怎么打人……
先来看下遇到的第一个坑。左侧列表是有折叠的,点开折叠之后,列表的长度就变了。
这意味着,要么你上来就把所有折叠打开。要么你每次点击进入新文档之后,都要重新读取一遍列表的长度。考虑到适用性和便利性,笔者选择了后者。这也意味着我们最好使用 while
循环,而不是 for
循环。
另外,多篇文档,就需要用标题来区分了,笔者最后选取了从列表元素中提取标题内容;
let i = 1;
let navs;
let len = 99;
while (i < len) {
navs = await page.$$(".list-item-wrapper");
len = navs.length;
const title = await navs[i].$eval(".tree-node-wrapper", (ele) => ele.innerText);
await navs[i].click();
await page.waitForNavigation();
// CODE:上文的单篇逻辑
i++;
}
还好还好,这个坑顶多算是路不平,不算什么。接下来就是把数据生成 csv 文件了。
生成 csv
这块主要是调研花了一些时间,先是搜到了 node-csv
,看说明里面有 csv-generate
、csv-parse
、csv-strigify
啥的,看了一会看不懂。主要之前用 python 操作 csv 的时候没这么麻烦啊,一定是这个库的错。最后,经过几次反复和犹豫之后,选择了 csv-writer,用法还是比较直观和简单的。Demo 代码如下:
const createCsvWriter = require("csv-writer").createObjectCsvWriter;
const csvWriter = createCsvWriter({
path: "path/to/file.csv",
header: [
{ id: "name", title: "NAME" },
{ id: "lang", title: "LANGUAGE" },
],
});
const records = [
{ name: "Bob", lang: "French, English" },
{ name: "Mary", lang: "English" },
];
csvWriter
.writeRecords(records) // returns a promise
.then(() => {
console.log("...Done");
});
大功告成了……吗
我:哈哈,把上面的代码组合起来,完美!就这??!!
现实:啪!啪!没看到有的记录没有数据吗?
我:……
跑了几条数据后发现,有的文档 pv、uv、words 这些数据是空。仔细一看才发现,原来「表格」和「文档」的数据是不一样的,表格并没有字数、点赞数等,信息如下:
这怎么区分呢?实际上笔者上面单篇逻辑的代码中,最开始选择的元素不是 .gpf-doc-detail-v2__value
,只是遇到了这个问题之后,找到了它们的共性,也就是数字元素的 class 都相同,这才选择的这个 class。既然有共性就好办了,我们用数量来判断就行了,于是代码变成了:
const items = await page.$$eval(".gpf-doc-detail-v2__value", (eles) =>
eles.map((el) => el.innerText)
);
if (items.length === 3) {
// sheet
[uv, pv, comments] = items;
} else {
// doc
[words, chars, uv, pv, like, comments] = items;
}
搞定,另外看了其他类型的文档,逻辑也能覆盖到,完美!
黎明就在眼前
我:这不就得了吗?!也不算什么大坑嘛。就这??!!
现实:啪!啪!啪!你正式运行下试试!
我:我就知道……
实际运行起来发现,执行到第 20 条时,它就会停下来。研究了一下发现,因为第 20 条正好是「列表可视区域的最后一条」,用 pptr 触发点击的话(真人点击反而不会),会触发列表滚动,让它滚到中间。然后它就不动了,因为没有触发跳转,所以就一直卡在 waitForNavigation
那步走不了了。
这可咋整,算了,先赶紧统计出来数据再说吧,好在文档数量不多。于是笔者将代码进行了一下改造,加了一个 START_INDEX
变量,让代码可以从第 n 条开始读。再配合每次手动滚动滚动条,断个 3 次就能把数据跑完了。然后再手动把它们拼到 excel 里就可以了……
我:说好的尊严呢?!手动这么 low……
现实:啪!啪!啪!还不去干活!
我:去 TMD 尊严,下回我再写爬虫我就是狗!(汪汪)
用临时方案搞定了数据之后,笔者开始正式填坑了。试了多种方案,什么记录当前访问到第几条,然后设置个该滚动的阈值啊;监听当前元素是否在可见区域内啊;点一个滚一下啊;双击啊……结果都不怎么好使。
前前后后折腾了 1 个多小时,经过无数次试验偶然发现:只要双击的间隔大于 200ms
就可以了。
我:……@¥%#&%¥&\……
然后优化了 10 几分钟思考和尝试到底怎么「只在临界元素触发双击」,最后发现所有元素都用双击也不会有问题,顺便还可以把 waitForNavigation
去掉。
我:……@¥%#&%¥&\……
好吧,现在问题可以说比较完美的解决了,完整代码 拿去看吧。毁灭吧~真的~累了~
const puppeteer = require("puppeteer-core");
const createCsvWriter = require("csv-writer").createObjectCsvWriter;
const path = require("path");
const browserWSEndpoint = "xxxxxxxx";
const defaultViewport = {
width: 1600,
height: 900,
};
const START_INDEX = 1;
const waitClickSelector = async (page, selector) => {
await page.waitForSelector(selector);
await page.click(selector);
};
const delay = async (ms) => new Promise((rev) => setTimeout(() => rev(), ms));
(async () => {
const browser = await puppeteer.connect({
browserWSEndpoint,
defaultViewport,
});
const page = (await browser.pages())[0];
let i = START_INDEX;
let navs;
let len = 99;
const csvWriter = createCsvWriter({
path: path.resolve(__dirname, `wiki${i}.csv`),
header: [
{ id: "title", title: "title" },
{ id: "owner", title: "owner" },
{ id: "created", title: "created" },
{ id: "words", title: "words" },
{ id: "chars", title: "chars" },
{ id: "uv", title: "uv" },
{ id: "pv", title: "pv" },
{ id: "like", title: "like" },
{ id: "comments", title: "comments" },
],
});
while (i < len) {
navs = await page.$$(".list-item-wrapper");
len = navs.length;
const title = await navs[i].$eval(".tree-node-wrapper", (ele) => ele.innerText);
await navs[i].click();
await delay(200);
await navs[i].click();
await waitClickSelector(page, "button.more-btn");
await delay(100);
await waitClickSelector(page, ".document_detail");
await page.waitForSelector(".gpf-doc-detail-v2__detail-modal-v2");
let [words, chars, uv, pv, like, comments] = [];
const [owner, created] = await page.$$eval(
".gpf-doc-detail-v2__detail-label",
(eles) => eles.map((el) => el.innerText)
);
const items = await page.$$eval(".gpf-doc-detail-v2__value", (eles) =>
eles.map((el) => el.innerText)
);
if (items.length === 3) {
// sheet
[uv, pv, comments] = items;
} else {
// doc
[words, chars, uv, pv, like, comments] = items;
}
const record = {
title,
owner,
created,
words,
chars,
uv,
pv,
like,
comments,
};
console.log(i, record);
await csvWriter.writeRecords([record]);
await page.keyboard.press("Escape");
i++;
}
})();
总结
本文尝试了一下幽默的风格,不知道会不会有点冷,最后的结尾还是正经一下吧。这里着重提几个点:
- 准备 部分说的用
wsEndpoint
连接浏览器调试的方法,是真的香。大家也看到了,最后的好多解决方案都是「瞎试」出来的。如果还是用那种运行一次脚本就要开一次浏览器,并且登陆一次,还要用代码还原不同的初始状态,想想这个成本,怎么都得翻 10 倍吧。所以强烈建议大家掌握这种方式,对于平时的调试是很有帮助的。 - nodejs 生成 csv 的能力一定要掌握。Excel 在统计数据方面还是非常强大的,不要想着啥都用写代码实现,有那时间再多写几个爬虫它不香吗?
- 碰到卡点就多试,指不定哪个方向就能有突破,杂交水稻都是「瞎碰」出来的。但是方法一定要高效,要灵活。自己写代码的时候时刻都要有这种思维,要让代码足够解耦,这样就能够从任意进度开始试验了。
- 不要太完美主义。如果目前不完美的方案已经能够提升不少效率了,为什么不先用这个方案解决一下问题呢?回头再来优化不久好了嘛,不要让手段成为了目标。
好了,就这些吧,感觉幽默风格不太适合我,下回还是换回来吧。
最后以刚刚逝世的「稻盛和夫」先生的金句结个尾:
大多数人对吃苦的含义可能理解得太肤浅。穷,并不是吃苦。穷就是穷,吃苦不是忍受贫穷的能力。
吃苦的本质,是长时间为了某个目标而聚焦的能力,在这个过程中,放弃娱乐生活,放弃无效社交,放弃无意义的消费以及在过程中不被理解的孤独。它本质是一种自控力,自制力,坚持和深度思考的能力。
从很大程度上来说靠自己成功的富人,往往能比穷人更能吃苦耐劳,否则他不可能白手起家。你会看到他富有之后还是比普通人勤奋,比普通人能忍受孤独,还更有理想。这才是“吃苦”。
——《稻盛和夫给年轻人的忠告》