2.1 最小爬虫:从种子页到第一条结果

场景:先证明能拿到数据

机器开好之后,每个人都很兴奋,但第一个问题不是怎么写调度器,而是更朴素的:我们到底能不能稳定地把目标站的数据拿下来。业务侧给的清单里有几个站之前 Python 脚本就采不顺,不是被 403 就是解析飘。所以我们把第一个里程碑定得特别小:一个单文件 Go 程序,抓一个种子列表页,解析出详情链接,再抓第一条详情,把标题和正文打印到终端。没有数据库、没有队列、没有并发,跑通就是胜利。

这个最小目标定下来之后,两天就出结果了,比预期快。原因是省掉了一切非必要动作:没有配置文件(硬编码),没有日志库(fmt 直接打印),没有错误重试(失败就重来)。写原型最大的风险是想太多,一上来就设计十层抽象,第三天还在写配置解析。

有意思的是,后来启动正式平台开发时,我们刻意保留了这个演示程序。它成了各种回归测试的样本:每次升级 goquery 或者调整解析逻辑,都先拿演示程序跑一遍,确认最简单的路径没有退化。一行一行的原型代码最终变成了新人的教材和系统的冒烟测试,这是写它时完全没想到的回报。

方案:单机最小链路的四个零件

最小爬虫只需要四个零件,顺序也是数据流向:

零件 职责 我们的实现 原型阶段刻意省掉的
下载器 拿到 HTML net/http 加连接池 代理、重试、限速
解析器 抽链接与字段 goquery 加 CSS 选择器 规则配置化
结果 先打印后入库 结构体加 fmt MySQL、CSV
种子 入口 URL 一个常量数组 URL 仓库、增量

省掉的这些在后面会一个个加回来:2.2 节加 URL 仓库,2.3 节加 MySQL,3.2 节加限速,5.2 节加代理。此刻我们只想验证两件事:目标站对我们这套 User-Agent 是否友好,以及页面结构能否用选择器稳定抽取。

页面结构的验证方法值得多说一句。我们不是凭肉眼看 HTML 写选择器,而是先把两三个列表页和详情页的 HTML 另存到本地,用浏览器开发者工具找到稳定特征节点,再写代码。所谓稳定特征,是指这个节点的 class 或 id 在同站的多个页面里一致,且不在广告位附近。选错特征节点是后患无穷的事,我们在这上面吃过的亏在踩坑一节细说。

配置与代码:四十行跑通

下载部分的关键设置都在 HTTP 客户端上。对我们这个场景,超时和连接复用是最重要的两项:

client := &http.Client{
    Timeout: 30 * time.Second,
    Transport: &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 20,
        IdleConnTimeout:     90 * time.Second,
    },
}

MaxIdleConnsPerHost 默认只有 2,单机抓一个站时并发一上去就反复建连接,这是 Go 新手最常踩的坑之一。解析部分借 1.4 节提到的 goquery:

func parseList(doc *goquery.Document) []string {
    urls := []string{}
    doc.Find("a.news-title").Each(func(i int, s *goquery.Selection) {
        href, ok := s.Attr("href")
        if ok {
            urls = append(urls, href)
        }
    })
    return urls
}

跑通的那天晚上我们对着终端里打印出的标题和正文截了图——对一个要长期运行的平台来说,这一步的意义是建立了信心:目标是可以被稳定采集的,剩下的都是工程问题。后来这个演示程序被保留在仓库的 examples 目录里,新人入职的第一课就是跑通它。

前后端分离的事先不用想,原型阶段打印到终端就是最诚实的输出。

在这一步我们还有一个小小的方法论收获:证明可行性时,永远从最难的那个站点开始。如果最难啃的站都能在最小原型里跑通,剩下的站大概率没问题。反过来从最简单的站开始,会建立虚假的信心,到中后期突然崩盘。我们清单里最难的那个站有 JS 渲染加签名接口,单独为它验证了两天才确认可行,这两天是全项目最值得的两天。

最小爬虫阶段还有一个不成文的规矩:任何超过五十行的原型代码都要被质疑。有次有人把原型写到了三百行,理由是把以后的功能先放上,被我们集体打回。原型的意义在于快速验证假设,每一行多余的代码都在降低验证速度。这个习惯在后面做 3.1 节 master 通信设计时依然有效。

打印到终端只是第一步,下一步就是把结果变成结构体,为落库做准备。我们顺便定下了结果结构体的基调,字段与 1.1 节的需求一一对应:

type PageItem struct {
    SiteID  string
    URL     string
    Title   string
    Body    string
    PubAt   string
    CrawAt  time.Time
    RawSize int
}

RawSize 字段记的是原始 HTML 大小,后来在 2.4 节存 COS 时用上了——上传前先按大小决定是立即传还是攒批。

这个结构体还有一处设计值得说:CrawAt 和 PubAt 分开。前者是我们抓到的时间,后者是页面发布的时间,混在一起会导致排序错乱——凌晨抓的稿子可能发布于前一天。两个时间各管各的语义,看板按 PubAt 排序,排查按 CrawAt 过滤。 这个区分贯穿全书,在 4.1 节清洗管道里又一次出现。

踩坑:跑通之前的四次失败

第一次失败是 403。目标站对默认的 Go HTTP User-Agent 直接拒绝,换成一个常见浏览器标识就好了。这件事在 5.2 节会展开成完整的 UA 策略,这里只提醒一句:永远显式设置 UA,别用默认值。

第二次失败是编码。有个站返回的是 GBK 编码,goquery 直接解析出来是乱码。我们的解法是在解析前根据 Content-Type 和 meta charset 判断编码,用 golang.org/x/text/encoding/simplifiedchinese 转成 UTF-8。这个工具函数后来在 4.1 节清洗管道里成为标准一环。

第三次失败是相对链接。列表页抽出来的 href 一半是相对路径,直接拼 baseURL 时拼错了,详情页 404。用 net/url 的 ResolveReference 才算稳妥解决。这种小错误最坑的地方在于:部分能采成功,像随机出 bug,排查全靠仔细。

第四次失败是选择器被广告位污染。详情页正文选择器命中了页面底部的推广区域,正文里混进去好几条广告文案。发现之后我们在规则里加了排除节点,这种正负面选择器的组合成为后来规则 DSL 的雏形(4.1 节)。

第五次失败更隐蔽:程序在本地能跑,放到轻量服务器上就间歇性超时。排查发现是默认 Transport 的 IdleConnTimeout 和目标站的 keep-alive 配合有问题,服务器主动断连接时我们的连接池还没感知,下一个请求就发到一个死连接上。调整连接池参数后消失。这件事教会我们:网络相关的代码必须上真实环境测,本机的 localhost 回环什么都测不出来。

小结

有意思的是,这个最小爬虫后来成了整个平台的核心循环。当天晚上的版本是 for 循环跑一个种子列表,第二天加上了 Redis 队列,第三天接上 MySQL,一周之后回过神来,它已经不是玩具了。任何大系统都是从能跑的最小一件事开始长出来的,这一点我们在这周里体会最深。四个零件、显式 UA、编码处理、相对链接——把这些坑在原型阶段趟过一遍,等正式平台搭起来时心里是有底的。下一步是给这个裸爬虫加上 URL 仓库:没有去重和队列,跑十次就有十份重复数据,这是 2.2 节的内容。如果你更关心结果怎么落地,可以先跳到 2.3 节看 MySQL 表设计。

原型阶段还有一个坑值得单独记一笔:请求超时。最初我们没设超时,默认客户端可以无限等。某个站点某天响应极慢,整个采集程序卡在三个请求上长达四十分钟,而日志里没有任何错误,看起来就像正常采集。排查之后我们给每个请求加了三十秒超时、整体抓取链路加了总时长上限,并把超时计数纳入监控。一个超时配置救回的是整个程序的可用性——网络程序里,没设超时的请求等于把控制权交给了不知道在哪的服务器。这一天我们团队也把一条写进了入门文档:任何网络调用,先写超时,再写逻辑。这条规矩后来被我们推广到了所有外部调用:数据库查询、消息队列消费、下游回调,全部设了超时。超时值我们还做了按站点分档:慢站给六十秒,快站只给十秒,统一从站点画像读取配置而不是写死在代码里。上线第一个月超时日志的分布成了我们调整这一档位表的唯一依据,自此再也没出现过整条链路被一个慢站拖住的事。

延伸阅读