如何用 puppeteer 爬取「飞书」wiki 知识库文档的数据?

2,607 阅读7分钟

背景

笔者最近主导了公司内部的一个跨多团队的技术项目,大家都知道,这种多团队协作的项目,要点之一就是要写好文档。否则你将面临每天不同的人来询问你相同的问题,然后你把答案无数次的重复回答,根本没有时间干别的。更何况一些较复杂的技术方案,很难用语言表述清楚。

总之,这次项目极其重视文档。我们用的飞书,建了专门的知识库,积累了很多文档。最后,在汇报的时候,碰到了一个小难题:怎么来加深大家对于文档重要性的认识呢

用数字说话是最具有说服力的,B 格也是很高的。于是就找到了管理员,问后台有没有知识库的数据。结果只有一些活跃度之类的数据,而且只有最近 2 个月的。笔者真正需要的是整个知识库每篇文章的访问数、字数、评论数等数据。就像下图当中,更多->文档信息当中展示的:

image.png

最直接的办法当然是点开每篇文章查看,然后把数据输入到 Excel 里进行统计。开玩笑!作为一个堂堂的码农!怎么可能会这么 low 的干?!我为脑子里产生这种解决方案而感到羞耻!就算人工 1 个小时就能搞定,也必须花上 3 个小时写个爬虫(再花 3 个小时写一篇文章)。废话不多说,开搞!

不熟悉 Puppeteer(以下简称 pptr)的同学也不用担心,基本都能看得懂,甚至可以作为 pptr 的教学 Demo 来看。

分析

因为飞书文档需要登录,所以还是用 pptr 模拟人工操作方便一些。获取文档数据有两种思路,一种是从接口读,一种是从页面元素读。笔者首先尝试了接口,发现 /space/api/obj_stats/get/ 这个接口应该是我们想要的。但是仔细研究一下后,发现了 2 个问题。第一个问题是接口的返回数据里,只有 UVPV 这些数据,没有字数。

image.png

另外,参数当中有一个 token。如果是不在 wiki 里的单独的文档,这个值可以在 URL 中拿到。但是如果是在 wiki 里,情况就有点复杂了。有的文档是快捷方式挂进来的,有的是后搬进来的。总之很多文档的这个 token 与 URL 中的参数对不上。

image.png

笔者知道,以上两个问题如果花时间,还是有很大可能性找到解决办法的,但是问题是没时间啊。闹呢?!本来计划 3 小时的,再研究研究变成 5 小时了,我傻啊?果断放弃,换思路。

从页面元素读,不就是模拟人点点点嘛,能有什么难的呢?好吧,一般有这种想法的都会被打脸,下面请各位读者来听啪啪声吧。

正文

准备

因为要登录,所以采用「有头」模式比较方便,即 headless: false。另外,因为 pptr 启动的浏览器是无痕模式,每次你都要扫码登录,很麻烦。所以笔者在做这种项目时采用了如下方法:

  1. 开一个命令行,用 pptr 启动一个浏览器,一直开着,然后记录它的 wsEndpoint 值;
  2. 再开一个命令行,运行真正的爬虫逻辑。但是不再新启浏览器,而是用 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));

读取单篇数据

我们先试着读取单篇的数据,大概分为以下几步:

  1. 各种点击操作,唤出带有数据的 Modal;
  2. 获取到需要的元素,读取里面的内容;
  3. 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");

这里说几点注意事项:

  1. 第 2 行的 delay 是因为菜单展开有动画,懒得找相应的 wait 方法了,直接一个 delay 完事;
  2. 声明变量时用了「解构赋值」的语法,不太熟悉的同学可以查一下;
  3. 核心方法就是 $$eval,不了解的同学可以查看 文档

OK,到目前为止还是很顺利的,继续!

读取多篇数据

要做的事情非常简单:读取左侧文档列表,然后遍历迭代执行「单篇逻辑」就行了。

我:就这??!!
现实:啪!你没发现列表的长度是会变的吗?
我:你怎么打人……

先来看下遇到的第一个坑。左侧列表是有折叠的,点开折叠之后,列表的长度就变了

image.png

这意味着,要么你上来就把所有折叠打开。要么你每次点击进入新文档之后,都要重新读取一遍列表的长度。考虑到适用性和便利性,笔者选择了后者。这也意味着我们最好使用 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-generatecsv-parsecsv-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 这些数据是空。仔细一看才发现,原来「表格」和「文档」的数据是不一样的,表格并没有字数、点赞数等,信息如下:

image.png

这怎么区分呢?实际上笔者上面单篇逻辑的代码中,最开始选择的元素不是 .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 在统计数据方面还是非常强大的,不要想着啥都用写代码实现,有那时间再多写几个爬虫它不香吗?
  • 碰到卡点就多试,指不定哪个方向就能有突破,杂交水稻都是「瞎碰」出来的。但是方法一定要高效,要灵活。自己写代码的时候时刻都要有这种思维,要让代码足够解耦,这样就能够从任意进度开始试验了
  • 不要太完美主义。如果目前不完美的方案已经能够提升不少效率了,为什么不先用这个方案解决一下问题呢?回头再来优化不久好了嘛,不要让手段成为了目标。

好了,就这些吧,感觉幽默风格不太适合我,下回还是换回来吧。

最后以刚刚逝世的「稻盛和夫」先生的金句结个尾:

大多数人对吃苦的含义可能理解得太肤浅。穷,并不是吃苦。穷就是穷,吃苦不是忍受贫穷的能力。
吃苦的本质,是长时间为了某个目标而聚焦的能力,在这个过程中,放弃娱乐生活,放弃无效社交,放弃无意义的消费以及在过程中不被理解的孤独。它本质是一种自控力,自制力,坚持和深度思考的能力。
从很大程度上来说靠自己成功的富人,往往能比穷人更能吃苦耐劳,否则他不可能白手起家。你会看到他富有之后还是比普通人勤奋,比普通人能忍受孤独,还更有理想。这才是“吃苦”。
——《稻盛和夫给年轻人的忠告》