去年为了帮助自己发现更多感兴趣的播客节目,我开发了一个名为“播客广场”的工具,它不仅能够帮助我纵向地去收集某个话题下的最新播客节目,还能实现对播客节目运营数据的统计。目前它已正式关闭,如果想要了解开发这个产品的初衷和形态,可以前往少数派网站搜索“播客广场”

播客广场

我一直想把制作这个爬虫的经验分几次分享出来,因为它同时涉及前后端,运维甚至AI。但问题在于在AI辅助编程已经成为主流的当下,其中的有些经验已经变得不再有价值,尤其是代码。我在编写该应用时,AI刚刚崛起还辅助编程还未大规模流行,所有的代码依然是手敲完成。

但还是有些我在制作应用过程中积累的经验是AI无法带给你的,其中的重中之重是关于技术决策和架构设计,例如如何应对约束以及进行妥协——这些内容将会是本文的重点。考虑到代码现在可以随心所欲的生成,所以它们在本文仅仅只会作为一些用于辅佐设计思路的点缀。当然我也会把代码开源给大家,在公众号“技术圆桌”私信我“播客广场”即可。如果需要真的对代码进行精讲还请留言给我,或者你可以尝试Cursor之类的AI IDE优先来协助你理解代码。

还是要说一个免责声明:本文会以小宇宙应用为例来进行讲解,其中会提及到各种用途的API。但因为过去一年我并没有继续关注这些API的缘故,当中的API在这一年内所接受的参数,返回体的结构都可能发生了变化,所以讲解难免会出现讲解错误。不过我依然觉得无伤大雅,在阅读完毕之后,我相信你会意识到本文与“具体”无关。

如何开始

程序设计是约束下的产物。在我心中存在这么一个程序的不可能三角:1)功能强大;2)代码维护成本低;3)投入资源(人力、资金)少。例如你不想写一行代码却想要一个五脏俱全的爬虫服务(功能多且维护成本低),那么买其实是最好的选择(投入高);又例如我希望程序无所不包但又不想花太多时间在上面(功能多且投入少),那代码注定是屎山(维护成本高)。对于个人开发者而言,我们的不可能三角困境其实是固定的:购买服务器的资金以及能够投入的精力都极其有限,因此需要在功能上妥协。我妥协的第一个点便是爬虫的执行效率。

做爬虫需要克服最大的障碍是IP地址,因为长时间频繁访问的关系,单个IP被封不过是时间的问题而已。在项目前期解决问题的方法简单粗暴:降低数据抓取的频率。简单来说,我并没有打算让服务器每分每秒不停的运转,不间隔的抓取网站数据,而是定时定点,有间隔的对数据进行抓取与更新,并且在每次请求前都有意让进线睡眠固定几秒的时间。所以你们看到的爬虫的入口大概长这样,我使用cronb表达式来将每一种类型的数据抓取服务设定为定时任务

// Every day at 1am
cron.schedule('0 1 * * *', async () => {
    await extractInfoFromDiscoveryList();
});

// Every day
cron.schedule('0 0 * * *', async () => {
    await scanProgramNeedToUpdateThumbnail();
    await batchUpdateProgramInfo();
});

在相当长一段时间内这是有效的,但我不确定有效究竟是因为方案本身还是因为网站在反爬虫上并没有做什么。总体上说解决IP问题我试过两个手段:

  • 更换服务器:这个方案在项目的前期有效,在项目前期单个IP被封的周期以月为单位。
  • 使用专业的代理服务:更换服务器虽然并不复杂,但归根到底还是需要人介入其中,因为我的抓取程序使用的是DigitalOcean的Droplet,所以每次重来我需要重新申请机器资源,配置环境变量等等。由于在项目后期IP被封的越来越频繁,于是我直接开始使用Geonode的代理服务。

降低数据的抓取是否会影响到的数据的有效性?这取决于我为什么要抓取数据,想用数据来做什么。播客数据并不算是高频率的更新数据,大部分播客节目一周有三次更新就已经算是很高产了,资讯类播客能做到的也不过是日更而已。如果只是想跟上他们更新的步伐,对于单个播客节目来说一天抓取一次就足够了;另一方面,就我想满足的需求而言(节目分类与数据统计)数据的实时性并不重要,他们可以以天、几天甚至是周为单位。

程序的抓取逻辑是:想要全量抓取到每一个播客的每一个单集,我必须首先知道市面上有哪些“活着”的播客,这里活着的意思并不是表示它存在过,而是至少在过去6个月内发布过单集。而想要获取到全量的播客节目,渠道有两个:1)小宇宙的“发现”页面(接口);2)应用的上每一位“用户”——每个用户是听众的同时,他也可能是一名主播。所以我会通过评论找到他们,然后通过访问他们的主页,来继续抓取他们的播客。

用“非实时性”的观念来看待数据能够缓解关于计算资源的压力,但哪怕以天为单位,我们依然面临另一个问题:没法把计算资源平均的分配给每一种数据类型。例如程序需要同时抓取用户、节目、评论,必然无法保证每种类型的数据抓取代码在一天之内都能够运行6小时。基于此我还是要对数据进行再一次细分并根据实际情况和重要性来分配资源。根据长时间的调试和测试后,成型的数据抓取频率/优先级分布如下:

  • 一日两次从“发现”接口(/v1/discovery-feed/list)获取到随机的播客单集信息,用于丰富我的播客库。在这一步里我只关心单集所属的播客ID,并基由ID继续触发以下两类数据的抓取
    • 播客的具体信息(/v1/podcast/get?pid=${pid}),具体信息包括播客的订阅数和icon图片地址。这样一来就可以实现对播客订阅数增长的监控,以及随后对icon图片进行下载。
    • 播客单集的所有用户评论(/v1/comment/list-primary),通过评论我可以丰富我的用户数据
  • 一日一次更新播客的简介说明以及icon图片

别忘了我们还需要根据抓取到的播客数据,将信息根据关键词分类汇总给用户,例如在过去24小时内又有哪些关于“小米”的播客单集被发布。所有这些数据都是完全由SQL提前计算生成,而非在用户访问网站实时查询得到。这么做的原因有两点:场景是固定的(因为我只有网站作者我拥有关键词的修改权限,不允许用户任意添加关键词),以及数据更新并不频繁。预加载在提升用户体验的同时还可以节省计算资源。所有的SQL计算任务也同样是定时任务:

// Every 2 hours
cron.schedule('0 */2 * * *', async () => {
    try {
        await topEpisdeByPlayCountPubedLast24Hours();
        await topEpisdeByPlayCountPubedLast7Days();
        await topPaidEpisdeByPlayCountPubedLast7Days();
        await topProgramBySubCountSoFar();
        await topProgramByGainMostSubscribersLast3Days();
    } catch (error) {
        console.log(new Date, '------ CRON JOB ERROR -----')
        console.log(error)
    }

技术架构

在确定我们需要什么样的架构前,首先温故一下上面所说的数据抓取的工作方式:数据的抓取依赖于一个底层的播客元信息数据库,数据库记录着播客在小宇宙的访问地址、名称、描述等等,在此之上我才能保证它们的每日更新。反过来说,如果我都不知道某个播客存在于这个世界上,也就不可能抓取到它的数据,因为抓取的“入口”是缺失的。

那么如何丰富我的元数据库呢?小宇宙的“发现”接口是一个好的出发点,它提供包括“为你推荐”、“编辑精选”、“24小时最热榜”在内的3个板块,每个板块会随机分享一些播客单集。从这些单集出发,我可以找到它们背后所属的播客,再扫描这个播客下的所有节目并抓取评论里的用户信息。现在我们继续探索这里所提到的几个任务:

  • 通过“发现”接口抓取单集数据
  • 抓取播客详情
  • 抓取播客的所有节目
  • 抓取播客每一集下的评论信息

虽然这几个任务存在依赖关系,但这种关系仅仅是数据上的而非执行层面的,这些任务的执行频率并不相同。例如“发现”接口的一天调用一次就足够了,因为其提供的“编辑精选”、“24小时最热榜”都可能是以天为单位进行的;而用于确认播客是否发布了最新节目而对播客进行轮询操作,则可以是以小时为单位的;对不同单集的评论抓取操作也可以并发执行。

综上,我们可以考虑使用“异步”而非“同步”的方式来构建我们的爬虫,相比“异步”,“同步”其实是一种不利的强约束,操作之间需要互相等待,调用之间彼此容易造成影响因此容错低,但好处是任务的处理效率高。考虑到数据是允许延迟且操作之间是低耦合的,我们完全可以摆脱这种约束。使用队列(Azure Service Bus)就是我们摆脱同步的最佳方式

播客抓取流程

从上图中可以看到,一旦从“发现”接口中获取到元信息之后,我们并非立即开始抓取播客详情与用户评论,而是将需要执行的操作放入到一个队列中。同时另一端的程序会监听队列,逐个逐步的根据消息中的指令来抓取数据。这样的异步操作可以带来几个好处:

  • 提升容错性:以队列为界限,无论是其上游消息生产者还是下游消息消费者程序出现了异常,都只不会影响到另一侧程序的正常执行
  • 提升可维护性:队列上下游代码可以在确保(消息)契约不变的情况下独自进行修改和部署
  • 增强可拓展性:一旦要抓取到的数据增多,我可以在队列的下游通过添加进程来并行消费队列中的消息,以避免消息的积压,数据抓取的不及时

“异步”与“离线”的思路是贯穿在这整个爬虫应用中的。在网站后期我加入两类与AI有关的功能:1)为受欢迎的单集生成文字总结;2)提供基于RAG的搜索功能。这两类功能是明显的“面向数据编程”:文字总结依赖的是下载至本地的音频文件,而RAG流水线同样仰仗保存在数据库中的播客元信息。以文字总结为例它的流程如下:

总结音频流程

在如上图所示的流程中,用于存储被抓取数据的数据库是一个分水岭,和队列机制类似,其上游是数据的生产者,其下游是数据的消费者,并且不难看出数据的消费过程是多个冗长的I/O操作的组合,它需要反复和外部系统打交道。因此更适合用流水线或者离线任务来实现。

如何应对不确定性

在编写这个爬虫的过程中我最常问我自己的问题是:怎么知道知道抓取到的数据是全面的?怎么确认的策略是对的?后来我才意识到这是不过是一种形式的内耗而已:我也许没法证明我是对的,但谁又能证明我是错的呢?只有官方拥有这个权威。业务上其实并不存在太多的不确定性,一来我们在做的是一张没有标准答案的试卷;二来经过抓包和分析我们可以看得很清楚手上究竟有哪些资源(接口)可以供使用,如果一件事接口做不到那么我们也做不到。

设计可观测性

技术上的不确定性带来的挑战更大:做爬虫其实是非常被动的事情,因为主动权并不在你的手里,你受制于被抓取方的一举一动:它的接口可能发生更改,它会封禁你的IP地址,也许有的字段为空会导致你的程序突然挂掉。综合看相比普通程序而言爬虫的稳定性更差,因此它的可观测性非常重要,便于我们及时发现问题及时修复。

对于如何建立可观测性我的建议是从业务角度出发而不是从技术角度出发。

大部分程序都会对外提供一个名为health的API接口用于开发者判断程序是否处于正常运行中,该接口会包含程序的一些基础信息,以及与第三方服务(如数据库、Redis)的连接状态。据我观察大部分开发者在实现该功能时,只是简单的依据能否建立连接来判断第三方服务是否正常,但事实上“建立连接”不等同于“可以成功向数据库中插入数据”,更不等同于“爬虫在正常工作”。有时候因为程序过于“优秀了”导致错误被成功捕获住而不是被抛出。程序所在服务器的资源利用率当然也是指标之一,暴涨或者暴跌都不是好的事情。但如果条件允许的话,检测程序是否正常的指标应改还是应该直接仰仗业务指标,对于爬虫而言最直观的指标其实是观察在固定时间范围内捕获到的数据有没有更新,如果被抓取到的用户总数长时间保持不变,那意味着我需要介入看看哪里出了问题。

我不怕程序出现问题,而是我怕程序出现问题我一无所知。于是我自己简单写了一个仪表盘页面用于展示我所拥有的所有服务的健康状态,并且将仪表盘页面设置为我Chrome浏览器新标签的启动页(每次打开一个新标签都默认跳转到此页)。你所看到的仪表盘的最后一行便是对爬虫运行情况的监控,包含机器的资源利用率,目前队列中有待处理的消息数,以及最近的数据更新状态。

监控

允许错误发生

关于可观测性我曾经还有一个想法:建立一个报表,将每天的错误错误日志收集起来并进行统计,然后以按照一定的节奏来解决这些问题。同时报表也可以成为我观测程序运行状况的一个参考指标。

但如果你维护过大型系统就会知道,想要完全消除一切bug是不可能的,或者说可能,但是成本会大到成本难以接受。bug有两个很有意思的特性:1)并不是所有错误都会影响到程序的运行;2)重试能够解决大部分问题。基于以上两点,再加上个人开发者的精力有限,以及小概率单条数据的抓取失败并不影响全局的抓取结果,所以我选择不解决其中的部分bug。一旦抓取出错,问题事件便会被放入Azure Service Bus的死信队列(deadletter queue)中,同时会有另一段程序并行再次消费死信队列中的消息。这里所说的再次消费其实不是什么特殊逻辑,说白了就是再走一遍原始的抓取逻辑。这个办法对那些不能稳定复现的bug非常有效,我称之为“用玄学对抗玄学”:我不知道为什么重试可以成功,我也不知道bug为什么会出现,但我知道前一个“不知道”能够对抗后一个“不知道”。

只要程序能够正常运行,它就一定会有bug存在——“正常”与“不正常”就像是硬币的两面是不可分离的。我知道bug令人不爽,我也理解解决bug带来的挑战是颇具吸引力的一件事,但我们终将面对不可能三角,当开发者面对的是一个实实在在面向公众的产品时,还是要现实一点。

放弃完美

还有一类不确定是关于项目本身的:它不一定能存活多久。

要知道所谓的业内通用模式、最佳实践本质上是一种投资,它不会在当下立即生效,而是为确保长远看项目的可维护性处于一个不错的水平之上。但如果你并不确定你的代码会活多久,那这样的投资是否存在性价比就是一个问题了。例如如果你十分确定一段代码你只会使用一次,那么它有没有坏味道其实就不重要了,修或者重构还会造成浪费。这里我们又回到了开头所说的“不可能三角”,这里我再一次选择减少通过降低项目的可维护性来降低我的投资。

项目中的大部分数据,如抓取数据所用的token、下载的音频文件、chroma数据库的文件,其实都是存在本地的文件系统中而不是云上的。甚至项目本身使用的使用的也非是全托管(managed)的云服务,而是我自己租用的VPS。不选择云的原因很简单:它们很贵,而且我也不需要它们所提供的全套服务,更重要是有光是集成它们的时间,就足够我开发完成一个使用本地文件系统的新功能。目前我唯一使用的全托管的云服务是Digital Ocean的MySQL,因为抓取到的播客数据目前对我来说是最为宝贵的资产,chroma数据库则可以基于MySQL数据进行重建,音频文件可以基于抓取到的数据重新下载,所以我选择在它上面做投资。

as long as it works meme

收尾

还有很多很多东西可以分享,出于篇幅的考虑暂且只能写到这里,至少我能够想到的关于设计这个爬虫最重要的部分都涉及到了。最后我想拔高一下文章立意:从整篇文章不难看出,每一个技术决策都是依据实际需求而定的,我始终认为,好的决策离不开对需求的深刻理解,这里的需求既指向业务也包含技术。

大部分人包括我自己在内,在阅读别人的代码时总是不由自主的将自己熟悉的开发模式或者熟悉的架构当作他人的代码的上下文:“你这里为什么不这么做”、“为什么不使用XX”。而事实上如果一开始就把你放在他所处的同样位置,面对相同约束,也许你也会做出同样的选择。