前三篇建立了文档导入和评测基线,数据来源是本地 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Frontier {
private final BlockingQueue<CrawlTask> queue =
new LinkedBlockingQueue<>(10_000);
private final Set<String> seen =
ConcurrentHashMap.newKeySet();
private final Set<String> allowedHosts;
private final int maxDepth;

public record CrawlTask(
String url, String host, int depth, int retryCount) {}

public boolean offer(String normalizedUrl, int parentDepth) {
String host = URI.create(normalizedUrl).getHost();
if (!allowedHosts.contains(host)) return false;
if (parentDepth + 1 > maxDepth) return false;
if (!seen.add(normalizedUrl)) return false;
return queue.offer(
new CrawlTask(normalizedUrl, host, parentDepth + 1, 0));
}

public CrawlTask poll(long timeout, TimeUnit unit)
throws InterruptedException {
return queue.poll(timeout, unit);
}
}

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
2
3
4
HttpClient client = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NEVER)
.connectTimeout(Duration.ofSeconds(10))
.build();

followRedirects 设为 NEVER 而不是 NORMAL,因为自动重定向会跳过两项检查:新 URL 是否在白名单内,以及新 URL 对应站点的 robots.txt 是否允许。手动处理重定向的流程:

  1. 发送请求,检查响应状态码
  2. 如果是 3xx,从 Location 头取出新 URL
  3. 验证新 URL 的 host 在白名单内
  4. 验证新 URL 通过 SSRF 检查
  5. 检查新 URL 站点的 robots.txt
  6. 重定向计数加一,超过 5 次放弃

每个请求设 30 秒读取超时。响应体通过 Content-Length 头预检大小,超过 5 MB 直接放弃。没有 Content-Length 头时,在读取过程中计数字节,超限后中断。

站点并发与礼貌延迟

为每个 host 维护一个 Semaphore 控制并发数,再用一个时间戳记录上次请求时间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ConcurrentHashMap<String, Semaphore> concurrency =
new ConcurrentHashMap<>();
ConcurrentHashMap<String, Long> lastRequestTime =
new ConcurrentHashMap<>();

void throttle(String host, long minIntervalMs)
throws InterruptedException {
Semaphore sem = concurrency.computeIfAbsent(
host, h -> new Semaphore(2));
sem.acquire();
long last = lastRequestTime.getOrDefault(host, 0L);
long wait = minIntervalMs - (System.currentTimeMillis() - last);
if (wait > 0) Thread.sleep(wait);
}

minIntervalMs 默认 1000 毫秒。如果 robots.txt 指定了 Crawl-delay,使用 max(crawlDelay * 1000, 1000)

重试预算

不是所有失败都值得重试。429(Too Many Requests)和 5xx 使用指数退避重试,最多 3 次。403 和 404 不重试。连接超时重试 1 次。

1
2
3
long backoff(int retryCount) {
return Math.min(2000L * (1L << retryCount), 60_000L);
}

429 响应的 Retry-After 头如果存在且值合理(≤ 300 秒),用它替代计算值。超过 300 秒的 Retry-After 视为放弃。

重试时将 CrawlTaskretryCount 加一后重新放入 frontier。超过重试预算的 URL 写入失败日志。

阻止 SSRF

爬虫接受外部 URL 输入——种子列表和页面中提取的链接都可能指向内网。在发起 HTTP 请求之前,必须验证目标地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static boolean isSafeTarget(URI uri) {
if (uri.getHost() == null) return false;
String scheme = uri.getScheme();
if (!"http".equals(scheme) && !"https".equals(scheme))
return false;

InetAddress[] addrs;
try {
addrs = InetAddress.getAllByName(uri.getHost());
} catch (UnknownHostException e) {
return false;
}

for (InetAddress addr : addrs) {
if (addr.isLoopbackAddress()
|| addr.isSiteLocalAddress()
|| addr.isLinkLocalAddress()
|| addr.isAnyLocalAddress()
|| addr.isMulticastAddress()) {
return false;
}
}
return true;
}

这段代码阻止对 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、进程崩溃)后不应从头开始。最简单的断点方案:

  1. 每抓完一个 URL,将其状态(URL、状态码、内容哈希)追加写入日志文件
  2. 定期将 frontier 队列和 seen 集合序列化到 checkpoint 文件
  3. 重启时读取 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 篇处理增量抓取

练习

  1. 启动 fixture 服务器,用 3 个种子 URL 运行爬虫,验证 robots.txt 拒绝的路径没有被抓取
  2. 将 fixture 的 /robots.txt 改为返回 500,确认爬虫跳过整个站点
  3. 在种子列表中混入 http://127.0.0.1:8080/secret,确认 SSRF 检查将其拦截
  4. 在抓取过程中按 Ctrl+C,重启后确认不会重复抓取已完成的页面
  5. netstatss 观察抓取期间的连接数,确认同站并发不超过 2

延伸阅读