← 返回总目录

Day 191 — 爬虫引擎架构与骨架

综合实战 600 行 5 个可运行脚本

Day 191 — 爬虫引擎架构与骨架

阶段:Phase 12 — 综合实战
项目:AI 赋能反爬数据平台 · 第 1/5 天
子主题:引擎分层 · URL 规范化 · Frontier · 令牌桶限速 · 重试分类 · 幂等落库
代码:1 个实验靶站 + 4 个可独立运行脚本,全部带 --self-test
实测环境:Linux 7.0.0-34-generic / Python 3.12.3 / SQLite 3.45.1 / 2026-10-21 06:00–06:20 CST


今天在项目里的位置

Day 191  引擎骨架      → 队列、限速、重试、落库:先把“能稳定跑”做出来   ← 今天
Day 192  代理池        → IP 轮换、健康检查、按目标分配出口
Day 193  浏览器与验证码 → JS 渲染、指纹、识别
Day 194  AI 增强       → 反爬策略预测、内容归类、数据管道
Day 195  运维与交付    → 调度、加密、鉴权、Docker + CI/CD

反爬平台最容易犯的错,是一上来就研究“怎么绕过验证码”。
但真正跑过一天的人都知道:90% 的失败不是被封,是自己把引擎写坏了——
同一页抓八遍、限速写成了加速、重试把 404 重试了四次、断点续爬从头再来。

所以第一天不碰代理、不碰浏览器,只造一台诚实、可测量、可恢复的抓取机器。


概念速览

完整词典见 concepts.md,图解见 diagrams/README.md。

概念 一句话 为什么重要
Frontier 待抓 URL 的队列 爬虫的心脏,决定“下一个抓谁”
URL 规范化 把同一个页面的多种写法折叠成一个键 不做 = 重复抓取、配额白烧
广度优先 先抓完第 N 层再进第 N+1 层 深度优先会掉进“无限日历”爬不出来
令牌桶 全局共享的速率阀门 每 worker 各自 sleep 会让速率翻 N 倍
桶容量 capacity 允许的瞬时突发量 设成 rate 会让短任务速率翻倍(实测 119.55 vs 60)
robots.txt 站点声明的禁爬区 最长匹配语义,Allow: / 不能盖掉 Disallow: /private
抓取预算 本次最多抓多少页 被 robots 拒绝的 URL 必须归还预算
错误分类 可重试 vs 永久 重试 404 只是把错误放大四倍
full jitter 退避取 random(0, ceiling) 防所有 worker 同一毫秒回撞
Retry-After 服务端指定的等待秒数 尊重它比任何指数退避都礼貌
幂等键 规范 URL 即主键 UPSERT 让重放不产生第二行
断点续爬 从库里反查未抓的 URL 进程崩了不用从第 1 页重来
p50 / p95 延迟分位 平均值会掩盖长尾,限速看 p95 才有意义

概念解释

1. 为什么引擎要分五层

一个能跑过夜的爬虫,职责必须切开,否则任何一处改动都会牵动全身:

  ┌────────────┐   URL    ┌────────────┐  HTML  ┌────────────┐
  │  Frontier  │ ───────► │  Fetcher   │ ─────► │  Parser    │
  │ 队列+去重   │          │ 限速+重试   │        │ 抽链接/字段 │
  └────────────┘ ◄────────└────────────┘        └─────┬──────┘
        ▲                  新链接                      │ 结构化记录
        └──────────────────────────────────────────────┘
                                                       ▼
                              ┌────────────┐   ┌────────────┐
                              │   Sink     │ ◄─ │  Pipeline  │
                              │ 幂等落库    │    │ 清洗/校验   │
                              └────────────┘    └────────────┘

为什么要切:

这就是“骨架”的意义:今天写的每个类都很小,但它们是后面四天所有功能的插槽。

2. URL 规范化:爬虫的第一道正确性关卡

服务端眼里,下面四个是同一个页面:

http://Example.COM:80/a/./b/../c?b=2&a=1#top
http://example.com/a/c?a=1&b=2
http://example.com/a/c?a=1&b=2&utm_source=weixin
http://example.com//a//c/?a=1&b=2

如果按原始字符串去重,你会抓四遍。规则按顺序执行:

步骤 规则 不做的后果
1 相对路径按 base 补全 /page/2 与 page/2 变成两个键
2 scheme / host 小写 Example.COM 与 example.com 重复
3 去掉默认端口 :80 / :443 同一站点两种键
4 折叠重复斜杠、解析 . 与 .. a/../c 与 c 重复(自检抓到的真 bug)
5 query 按键排序 ?a=1&b=2 与 ?b=2&a=1 重复
6 丢掉 utm_/ref/fbclid 等跟踪参数 分享链接每次都是“新页面”
7 丢掉 fragment #top 永远不发给服务端
8 非 http/https 直接拒绝 javascript:void(0) 会毒化队列

⚠️ 顺序有讲究:必须先补全再解析 ..。我第一版把 .. 当普通段过滤掉,
结果 /a/./b/../c 被算成 /a/b/../c,自检直接 FAIL——这就是下面“翻车实验 1”。

为什么用 deque + set,而不是 list + set:

3. 限速:一个全局桶,不是每个线程一个 sleep

最直觉的写法:

python
def worker(url):
    time.sleep(1 / rate)     # ← 看起来每个请求间隔 1/rate 秒
    fetch(url)

8 个 worker 一起跑,全局速率就是 8 × rate。Day 191 实测:目标 20 req/s,
实际 117.77 req/s(5.9 倍),120 个页面 1.02 秒打完。对目标站点来说,
这不是“限速”,这是小型 CC 攻击。

正确姿势是所有 worker 从同一个桶取令牌:

tokens
t=0  ████████████████████  20 个(capacity=rate,错的默认值)
     ↑ 一开跑就能连打 20 个,短任务速率翻倍

t=0  █                     1 个(capacity=1,严格均匀)
     ↑ 每个请求间隔严格 1/rate

令牌桶两个参数:

为什么取令牌要“预扣”:tokens -= 1 在锁内先记账,再出锁 sleep。
若只在锁内判断、锁外 sleep,8 个线程会同时看到 tokens=0.2,同时算出同一个等待时间,
醒来后又同时抢同一个令牌——限速会周期性失效(thundering herd 的迷你版)。

为什么不直接用漏桶:漏桶恒定速率输出、不允许突发;令牌桶允许“平时攒、关键时刻小爆一下”。
抓详情页时突发没有价值,但抓列表页后紧跟着抓 5 个详情页,1 秒的突发额度能省 5 次往返。
默认 1 是安全值,需要时显式调大。

4. robots.txt:最长匹配,不是“先到先得”

User-agent: *
Disallow: /private
Allow: /

如果按“从上往下第一条匹配”判,/private/9 会先命中 Disallow —— 这次是对的;
但把两行顺序换一下,就会被 Allow: / 放行。标准语义是最长匹配优先,
/private(8 字符)比 /(1 字符)更具体,所以永远禁。
Python 的 urllib.robotparser 已实现该语义,不要自己写正则。

还有一个工程细节:被 robots 拒绝的 URL 必须归还抓取预算。
我第一版没还,--limit 40 只抓到 22 页,因为 18 个禁区链接把预算吃掉了(翻车实验 3)。

5. 重试:先分类,再退避

        请求失败
           │
   ┌───────┴────────┐
   │ 4xx 客户端错误   │ 5xx / 429 / 超时 / 连接重置
   │                │
 404 403 410      可重试
   │                │
   └─► 立即放弃      └─► 退避后重试(最多 N 次)
       记 failures        第 N 次仍失败 → 落 failures 表

6. 幂等落库与断点续爬

为什么用 SQLite 唯一约束而不是“应用层判断有没有写过”:

sql
CREATE TABLE pages (
    url        TEXT PRIMARY KEY,   -- 规范 URL 就是幂等键
    title      TEXT,
    body_len   INTEGER,
    status     INTEGER,
    fetched_at TEXT NOT NULL
);

断点续爬就变成一句话:

python
done = {r[0] for r in conn.execute("SELECT url FROM pages")}
todo = [u for u in all_urls if u not in done]

如果不用规范 URL 当主键,续爬会把 /page/5 和 /page/5?utm_source=x 当成两页,
永远抓不完——去重和幂等必须共用同一个键。


图解

数据流与三层状态

flowchart LR A[种子 URL] --> B{normalize} B -->|不可抓| X[丢弃] B -->|规范 URL| C{seen?} C -->|是| X C -->|否| D[Frontier 队列] D --> E{robots 允许?} E -->|否| R[release 归还预算] E -->|是| F[TokenBucket.acquire] F --> G[HTTP 请求] G -->|2xx| H[解析 title/正文] G -->|5xx/429/超时| I{重试次数 < N?} I -->|是| F I -->|否| J[failures 表] G -->|404/403| J H --> K[(pages UPSERT)] H --> L[抽链接] --> B
flowchart LR
    A[种子 URL] --> B{normalize}
    B -->|不可抓| X[丢弃]
    B -->|规范 URL| C{seen?}
    C -->|是| X
    C -->|否| D[Frontier 队列]
    D --> E{robots 允许?}
    E -->|否| R[release 归还预算]
    E -->|是| F[TokenBucket.acquire]
    F --> G[HTTP 请求]
    G -->|2xx| H[解析 title/正文]
    G -->|5xx/429/超时| I{重试次数 < N?}
    I -->|是| F
    I -->|否| J[failures 表]
    G -->|404/403| J
    H --> K[(pages UPSERT)]
    H --> L[抽链接] --> B

令牌桶时间轴(rate=20, capacity=1)

tokens
 1.0 ┤█
     │ ╲   每 50ms 放行 1 个
 0.5 ┤  █╲
     │   ╲ █╲
 0.0 ┤────╲─█╲────────────────────────► 时间
     0   50 100 150 200 ms

capacity=20 的错误版本:
 1.0 ┤████████████████████
     │                   ╲
 0.0 ┤────────────────────█──────────►
     0                    1000 ms   前 20 个请求 0 等待

重试状态机

stateDiagram-v2 [*] --> Request Request --> OK: 2xx Request --> Permanent: 4xx Request --> Retryable: 5xx/429/超时 Retryable --> Backoff: attempt < 4 Backoff --> Request: sleep(random(0, ceiling)) Retryable --> Failed: attempt = 4 Permanent --> Failed OK --> [*] Failed --> [*]
stateDiagram-v2
    [*] --> Request
    Request --> OK: 2xx
    Request --> Permanent: 4xx
    Request --> Retryable: 5xx/429/超时
    Retryable --> Backoff: attempt < 4
    Backoff --> Request: sleep(random(0, ceiling))
    Retryable --> Failed: attempt = 4
    Permanent --> Failed
    OK --> [*]
    Failed --> [*]

定义与使用方法(API 速查)

normalize(url, base=None) -> str | None

参数 类型 说明
url str 原始 URL,可为相对路径
base str | None 当前页 URL,用于补全相对路径
返回 str | None 规范 URL;非 http/https 或非法主机返回 None

Frontier(max_pages=1000)

方法 作用 返回
push(url, base=None) 入队(自动规范化 + 去重 + 预算检查) True 入队成功
pop() 出队一个 URL str 或 None
release() 归还预算(robots 拒绝时调用) None
len(frontier) 队列剩余 int

TokenBucket(rate, capacity=1.0)

成员 说明
rate 每秒放行令牌数
capacity 最大突发量,爬虫建议 1
acquire() 阻塞直到拿到令牌,返回等待秒数
waited 累计等待秒数(运维指标)

RetryPolicy(attempts=4, base=0.1, cap=2.0, jitter="full")

参数 默认 说明
attempts 4 总尝试次数(含首次)
base 0.1 首次退避基数(秒)
cap 2.0 单次退避上限
jitter full none 为确定性退避
delay(attempt, retry_after=None) — 返回本次应睡秒数

fetch_with_retry(url, policy) -> (status, body, attempts)

异常 含义 处理
PermanentError 4xx,重试无意义 调用方记录并跳过
RetryableError 重试用尽 落 failures 表

save_page(conn, url, title, body_len, status) -> bool

返回 True 表示新行,False 表示覆盖更新。幂等键是 pages.url。


实战代码案例

文件 角色 自检 说明
code/00-local-server.py 实验靶站 6/6 500 页 + 慢响应 + 503 抖动 + 302 + robots
code/01-url-frontier.py 基础 11/11 规范化、deque 队列、robots、BFS 爬取
code/02-rate-limited-engine.py 进阶 4/4 令牌桶 + 线程池 + p50/p95 + 实测速率
code/03-retry-and-sink.py 进阶 12/12 错误分类、全抖动、SQLite UPSERT、断点续爬
code/04-pitfall-per-worker-sleep.py 避坑 4/4 复现“限速变加速”与“去重失效”

每个脚本都能离线自检,不依赖靶站:

bash
cd days/day-191-crawler-engine-skeleton/code
for f in 00-local-server.py 01-url-frontier.py 02-rate-limited-engine.py 03-retry-and-sink.py 04-pitfall-per-worker-sleep.py; do
  echo "== $f"; python3 "$f" --self-test || break
done

实战实验手册

以下所有输出均为 2026-10-21 06:00–06:20 CST 在本机真实执行捕获,
非示意。靶站只监听 127.0.0.1,不产生任何外网流量。

实验 0:靶站自检(离线,10 秒)

目的:确认实验环境自带一个行为确定的 HTTP 服务端。
环境:Python 3.12.3,仅标准库。

执行:

bash
cd days/day-191-crawler-engine-skeleton/code
python3 00-local-server.py --self-test

实际结果:

  PASS  page 200 且含标题
  PASS  flaky 首次 503
  PASS  flaky 二次 200
  PASS  robots 禁 /private
  PASS  redirect 跟随到 200
  PASS  status 有统计

self-test: 6/6 passed

结论:--self-test 内部自己起服务、自己关,不需要用户先开终端占端口。


实验 1:限速写成加速(翻车实验 1)

目的:量化“每 worker 各自 sleep”与“全局令牌桶”的速率差异。
环境:靶站 127.0.0.1:8099,目标 20 req/s,8 worker,120 页。

准备 + 执行(一个终端跑完,自动清理):

bash
cd days/day-191-crawler-engine-skeleton/code
python3 00-local-server.py --port 8099 &
SRV=$!; sleep 1
python3 04-pitfall-per-worker-sleep.py --url http://127.0.0.1:8099 \
        --rate 20 --workers 8 --pages 120
kill $SRV

实际结果:

目标速率 20.0/s · workers 8 · pages 120
理论耗时 6.00s
  坑1 {'strategy': 'per-worker sleep', 'wall_s': 1.019, 'achieved_rps': 117.77}  超速 5.9×
  正解 {'strategy': 'global bucket', 'wall_s': 5.952, 'achieved_rps': 20.16, 'sleep_s': 45.99}  偏差 0.8%

坑2 去重:原始 6 条 vs 规范化 2 条
同一批 6 个链接,规范化后省下 4 次请求

结论:错误写法比目标快 5.9 倍;共享桶偏差 0.8%。
“每请求 sleep”在单线程下正确,在多线程下必然失效——限速器必须是共享状态。


实验 2:限速精度(20 / 60 req/s)

目的:验证令牌桶在 closer-to-real 速率下的偏差,以及 p95 延迟。

执行:

bash
python3 00-local-server.py --port 8099 & SRV=$!; sleep 1
python3 02-rate-limited-engine.py --url http://127.0.0.1:8099 --pages 120 --rate 20 --workers 8
python3 02-rate-limited-engine.py --url http://127.0.0.1:8099 --pages 240 --rate 60 --workers 8
kill $SRV

实际结果:

--- rate 20 / 120 pages ---
结果: {'pages': 120, 'wall_s': 5.953, 'req_per_s': 20.16, 'ok': 120, 'http_err': 0,
       'net_err': 0, 'p50_ms': 1.3, 'p95_ms': 4.1, 'bucket_wait_s': 45.93}
目标速率 20.0/s,理论耗时 6.00s,实际 5.953s,偏差 0.8%

--- rate 60 / 240 pages ---
结果: {'pages': 240, 'wall_s': 3.988, 'req_per_s': 60.19, 'ok': 240, 'http_err': 0,
       'net_err': 0, 'p50_ms': 1.2, 'p95_ms': 3.4, 'bucket_wait_s': 30.721}
目标速率 60.0/s,理论耗时 4.00s,实际 3.988s,偏差 0.3%

结论:稳态偏差 0.3%–0.8%,属于调度抖动级别。
bucket_wait_s 是 8 个线程累计等待(45.93s ≈ 115 次 × 0.4s 的排队),
说明限速发生在请求之前、而不是请求之后。


实验 3:BFS + robots + 抓取预算

目的:验证 robots 禁区被跳过,且不吞噬抓取预算。

执行:

bash
python3 00-local-server.py --port 8099 & SRV=$!; sleep 1
python3 01-url-frontier.py --crawl http://127.0.0.1:8099 --limit 40
kill $SRV

实际结果(修复预算泄漏后):

{'fetched': 40, 'queued_left': 0, 'unique_seen': 74, 'dedup_skipped': 45, 'robots_blocked': 34}

对比(修复前,同一命令):

{'fetched': 22, 'queued_left': 0, 'unique_seen': 40, 'dedup_skipped': 25, 'robots_blocked': 18}

结论:修复后 40 页预算真正抓到 40 页;34 个 /private/* 链接被 robots 拦下。
dedup_skipped=45 说明每个页面都有 1–2 个重复链接(n+1/n+2 交叉),
没有规范化与去重,这批会变成 45 次多余请求。


实验 4:重试 + 幂等落库 + 断点续爬

目的:503 抖动被重试吃掉;重跑同一批 URL 不产生重复行;续爬只抓缺的。

执行:

bash
rm -f /tmp/day191.sqlite*
python3 00-local-server.py --port 8099 & SRV=$!; sleep 1
python3 03-retry-and-sink.py --url http://127.0.0.1:8099 --pages 40 --db /tmp/day191.sqlite
python3 03-retry-and-sink.py --url http://127.0.0.1:8099 --pages 40 --db /tmp/day191.sqlite --resume
python3 03-retry-and-sink.py --url http://127.0.0.1:8099 --pages 40 --db /tmp/day191.sqlite --resume
kill $SRV
sqlite3 /tmp/day191.sqlite 'select count(*), min(fetched_at), max(fetched_at) from pages;'

实际结果:

{"todo": 45, "ok": 45, "fail": 0, "new_rows": 45, "rows_total": 45, "wall_s": 0.35}
{"todo": 0, "ok": 0, "fail": 0, "new_rows": 0, "rows_total": 45, "wall_s": 0.0}
{"todo": 0, "ok": 0, "fail": 0, "new_rows": 0, "rows_total": 45, "wall_s": 0.0}

结论:45 条(40 正常页 + 5 个 flaky)全部成功,5 个 flaky 各用了 2 次尝试;
两次续爬都是 0 待办、0 新行——幂等生效。


翻车实验记录

翻车 1:.. 被当成普通路径段丢掉

翻车 2:桶容量 = rate,短任务速率翻倍

翻车 3:robots 拒绝的 URL 白吃抓取预算

翻车 4:pkill -f 把执行它的 shell 一起杀了


实测数字汇总

场景 目标 实测 偏差
每 worker sleep(错) 20 req/s 117.77 req/s +489%
共享令牌桶 capacity=1 20 req/s 20.16 req/s +0.8%
共享令牌桶 capacity=1 60 req/s 60.19 req/s +0.3%
共享令牌桶 capacity=rate(错) 60 req/s 119.55 req/s +99%
BFS 抓取(预算 40) 40 页 40 页 / 34 次 robots 拦截 0
45 URL 落库(含 5 个 503) 45 行 45 行 / 0 失败 0
二次续爬 0 待办 0 待办 / 0 新行 0

测试条件:本机 loopback,单进程靶站,Python 3.12.3,单次运行非均值;
延迟数字受本机调度影响,仅用于对比同一台机器上的策略差异。


思考题

  1. 如果目标站点对同一 IP 的并发连接数限制是 4,你的 workers=32 会表现成什么现象?
    只看 http_err 能发现吗?应该加哪个指标?
  2. capacity=1 让吞吐非常平滑,但每次请求都要等一个令牌。
    在“列表页 1 个 + 详情页 20 个”的场景里,怎样设计两级桶才既礼貌又不慢?
  3. 规范化把 %E4%B8%AD%E6%96%87 还原成中文再拼回去,为什么可能在某些站点上
    反而造成 404?应该保留原始编码还是解码?(提示:看服务端如何解析 path)
  4. 断点续爬用 SELECT url FROM pages 反查。当表里有 500 万行时,这个查询会慢在哪?
    怎样改成“记录水位线”的方式?两种方式各会漏抓什么?
  5. 假设你要抓的站点 30% 的页面返回 200 但内容是“访问过于频繁,请稍后再试”,
    状态码完全正常。你的重试分类器该怎么扩展?这属于哪一层的问题?

今日交付

明天(Day 192)在这套骨架上接代理池:出口 IP 轮换、健康检查、按目标分配。