从零构建现代搜索引擎(04):实现有边界的网页爬虫
前三篇建立了文档导入和评测基线,数据来源是本地 fixture 文件。真实搜索引擎的数据来自网页。抓取网页看起来只是发 HTTP 请求再解析 HTML,但如果不加约束,爬虫会在几分钟内失控——压垮目标站点、陷入无限循环、抓回内网敏感数据,或在中断后丢失全部进度。
本篇的目标:写一个有明确边界的爬虫,能从种子 URL 出发,按广度优先发现页面,遵守 robots.txt,控制并发和重试,拒绝危险地址,并在中断后恢复。
边界从哪里来
"有边界"在爬虫语境下指四类约束:
抓取范围:只抓白名单内的域名。种子 URL 页面上提取的链接如果指向其他站点,直接丢弃。URL 深度设上限(例如 5 层),防止动态生成的无限路径耗尽队列。
礼貌约束:同一站点的请求间隔不低于 1 秒,同站并发不超过 2 个连接。robots.txt 声明的 Crawl-delay 优先于默认值。全局并发受线程池限制。
资源约束:单个响应体不超过 5 MB,连接超时 10 秒,读取超时 30 秒,单个 URL 最多重试 3 次。整个爬取任务可以设总页面数上限。
安全约束:在发起 HTTP 请求之前验证目标 IP 不是回环地址、私有网段或链路本地地址。只允许 http 和 https 协议。
Frontier:待抓取队列
Frontier 管理所有待抓取的 URL。最简单的实现是一个阻塞队列加一个已见集合:
1 | |
seen 用规范化后的 URL 做 key(规范化规则在第 05 篇详述)。allowedHosts 在启动时从种子 URL 推导或由配置指定。maxDepth 默认 5。
队列容量设了 10,000 的上限。小规模抓取不需要磁盘持久化队列;如果规模增长到百万级,可以换成 RocksDB 或数据库支撑的队列,Frontier 接口不变。
遵守 robots.txt(RFC 9309)
RFC 9309 在 2022 年把 robots.txt 从行业惯例提升为 IETF 标准。核心规则不复杂,但细节容易实现错误。
获取:对每个站点,在抓取任何页面前先请求 /robots.txt。
响应码决定行为:
| robots.txt 状态码 | 含义 | 爬虫行为 |
|---|---|---|
| 2xx | 成功 | 解析规则并遵守 |
| 3xx | 重定向 | 跟随最多 5 次,在原始 authority 上下文中应用规则 |
| 4xx | 文件不存在 | 该站点无抓取限制 |
| 5xx | 服务端错误 | 假定全站禁止抓取 |
这里最容易犯的错误是把 5xx 当成"没有限制"——RFC 9309 明确要求 MUST assume complete disallow。原因是:服务器出了故障,管理员可能原本设置了抓取限制但现在无法提供,此时继续抓取不尊重站点意愿。
匹配规则:Allow 和 Disallow 按最长匹配决定优先级,不是按出现顺序。两条规则长度相同时 Allow 优先。匹配大小写敏感,从路径的第一个字节开始。* 匹配零或多个任意字符,$ 标记路径结尾。
缓存:robots.txt 缓存不应超过 24 小时。实现上可以在内存中为每个 host 维护规则对象和过期时间。
robots.txt 不是访问授权。RFC 原文明确写道 “These rules are not a form of access authorization”。它是站点对爬虫的请求,不是安全机制。但作为遵守互联网礼仪的爬虫,这些请求应当被尊重。
解析实现可以引入 crawler-commons 1.4(Apache 许可),也可以自写。自写版本只需处理 User-Agent 行分组、Allow/Disallow 行收集和最长匹配逻辑,大约 100 行代码。
JDK HttpClient 抓取
Java 25 内置的 java.net.http.HttpClient 足够搭建教学爬虫,不需要引入 Apache HttpComponents 或 OkHttp。
1 | |
followRedirects 设为 NEVER 而不是 NORMAL,因为自动重定向会跳过两项检查:新 URL 是否在白名单内,以及新 URL 对应站点的 robots.txt 是否允许。手动处理重定向的流程:
- 发送请求,检查响应状态码
- 如果是 3xx,从
Location头取出新 URL - 验证新 URL 的 host 在白名单内
- 验证新 URL 通过 SSRF 检查
- 检查新 URL 站点的 robots.txt
- 重定向计数加一,超过 5 次放弃
每个请求设 30 秒读取超时。响应体通过 Content-Length 头预检大小,超过 5 MB 直接放弃。没有 Content-Length 头时,在读取过程中计数字节,超限后中断。
站点并发与礼貌延迟
为每个 host 维护一个 Semaphore 控制并发数,再用一个时间戳记录上次请求时间:
1 | |
minIntervalMs 默认 1000 毫秒。如果 robots.txt 指定了 Crawl-delay,使用 max(crawlDelay * 1000, 1000)。
重试预算
不是所有失败都值得重试。429(Too Many Requests)和 5xx 使用指数退避重试,最多 3 次。403 和 404 不重试。连接超时重试 1 次。
1 | |
429 响应的 Retry-After 头如果存在且值合理(≤ 300 秒),用它替代计算值。超过 300 秒的 Retry-After 视为放弃。
重试时将 CrawlTask 的 retryCount 加一后重新放入 frontier。超过重试预算的 URL 写入失败日志。
阻止 SSRF
爬虫接受外部 URL 输入——种子列表和页面中提取的链接都可能指向内网。在发起 HTTP 请求之前,必须验证目标地址。
1 | |
这段代码阻止对 127.0.0.0/8、10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、169.254.0.0/16 和 IPv6 对应范围的请求。169.254.169.254 是云环境元数据端点,属于链路本地地址,会被 isLinkLocalAddress() 拦截。
DNS rebinding 攻击可以让同一域名在两次解析时返回不同 IP。完整防御需要在解析后直接用 IP 建立连接(DNS pinning)。对教学项目,域名白名单加 IP 检查已经形成双重防线。
断点与恢复
爬虫中断(Ctrl+C、进程崩溃)后不应从头开始。最简单的断点方案:
- 每抓完一个 URL,将其状态(URL、状态码、内容哈希)追加写入日志文件
- 定期将 frontier 队列和 seen 集合序列化到 checkpoint 文件
- 重启时读取 checkpoint 恢复 frontier,读取日志跳过已完成的 URL
checkpoint 间隔可以按页面数(每 100 页)或时间(每 5 分钟)。日志文件用追加写入,不需要原子性——因为文档导入层有 SHA-256 去重,重复抓取同一页面不会导致索引中出现重复文档。
用本地 Fixture 测试
在线站点不可控,测试必须可重复。用 JDK 内置的 com.sun.net.httpserver.HttpServer 在随机端口启动一个本地 HTTP 服务器,注册不同路径模拟不同场景:
/robots.txt返回 Disallow 规则/page1返回正常 HTML,包含指向/page2和外部域名的链接/redirect返回 302 到/page1/loop-a和/loop-b互相重定向/slow延迟 35 秒响应(测试超时)/rate-limited返回 429 + Retry-After 头/large返回超过 5 MB 的响应
每个测试场景独立启动 fixture 服务器,验证爬虫行为符合预期。fixture 不依赖网络,可以在 CI 环境中运行。
当前局限
本篇的爬虫还做不到以下几件事,后续章节逐步解决:
- 正文提取仍然粗糙——下一篇处理导航噪声和正文定位
- URL 规范化只做了基础处理——下一篇处理参数排序和跟踪参数清理
- 没有处理 JavaScript 渲染的页面——动态渲染是选修实验
- 没有条件请求(If-Modified-Since)——第 25 篇处理增量抓取
练习
- 启动 fixture 服务器,用 3 个种子 URL 运行爬虫,验证 robots.txt 拒绝的路径没有被抓取
- 将 fixture 的
/robots.txt改为返回 500,确认爬虫跳过整个站点 - 在种子列表中混入
http://127.0.0.1:8080/secret,确认 SSRF 检查将其拦截 - 在抓取过程中按 Ctrl+C,重启后确认不会重复抓取已完成的页面
- 用
netstat或ss观察抓取期间的连接数,确认同站并发不超过 2
延伸阅读
- RFC 9309 Robots Exclusion Protocol: https://www.rfc-editor.org/rfc/rfc9309
- OWASP SSRF Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html
- Introduction to Information Retrieval, Chapter 20: Web crawling and indexes






