网络爬虫全面速查手册:协议、解析、反爬与合规

速查总览:这一页怎么查

本页是爬虫的速查手册,按问题翻而不是从头读:每章第一张卡把该层的协议 / API / 参数一次列全,后面的卡再展开语义与对抗细节。这一章给三张索引——章节地图、定位与选型、工具选型矩阵。

爬虫是一条从 TLS 握手到数据落库的流水线,本页按这条流水线分层。不必顺读:先在下表按关键词定位。

章节地图

章查什么关键词
01装环境、最小可跑骨架、四类失败反查httpx · parsel · 403 · 空结果
02HTTP 方法与状态码、请求头全表、URL 编码、压缩、HTTP/2 与 TLSUser-Agent · Cookie · JA3
03客户端库对照、连接复用、超时重试退避、并发、代理写法httpx.Client · tenacity · proxy
04解析器对照、CSS 选择器与 XPath 全表、内嵌 JSON 提取selectolax · XPath · __NEXT_DATA__
05侦察清单、接口逆向流程、Playwright API 与等待策略DevTools · 签名 · 拦截
06检测向量总览、指纹维度、限速拟人化、验证码TLS 指纹 · Cloudflare · 代理池
07Scrapy 组件与命令、去重、调度、分布式、增量抓取Spider · Bloom · ETag
08清洗工具、Schema 校验、存储格式对照、管道ftfy · pydantic · Parquet
09容错与续爬、监控指标、缓存、测试、部署checkpoint · 指标 · VCR
10robots 指令、法规对照、判例与灰度地带robots.txt · GDPR · PIPL
11规范、工具与法规资料索引RFC 9110 · RFC 9309

三条最常见的查法

你手上有的去哪
一个状态码或一段空结果01 章的四类失败反查表;状态码全表在 02 章
「这站的数据在 JS 里」05 章:先侦察找接口,实在不行再开浏览器
「被封了 / 被挑战了」06 章检测向量表,先定位是哪一层露的馅
技术上能抓 ≠ 应该抓 ≠ 合法。本页每一章的技术手段都要放在 10 章的边界里用:robots.txt 与服务条款是底线,个人数据与版权是高压区。先看有没有官方 API——很多站点提供的开放接口,比你逆向出来的那套稳定得多,也不用担责。
这一页的顺序就是排查顺序:抓不到数据时,从下往上问——是协议层出错(02)、请求姿势不对(03)、选择器没命中(04)、数据压根不在 HTML 里(05),还是被识别了(06)。跳过前面几层直接上无头浏览器,是最常见的低效解法。

爬虫的立身之本是一门工程学:把散落在网页里的非结构化信息,稳定、合规、规模化地变成可用的结构化数据——难点从来不在「发个请求」,而在对抗、规模与合规。

技术栈的四个层次

  • HTTP 客户端(requests / httpx):发请求、管会话与重试,一切的地基;
  • 解析(XPath / CSS 选择器):把字段从 HTML 里抠出来;
  • 浏览器自动化(Playwright):应付必须执行 JS 才出内容的站;
  • 框架(Scrapy):调度、去重、管道,多站点规模化才用得上。

怎么选:按场景挑工具

  • 简单静态页:requests + 解析器,十行能解决的事不要上框架;
  • 动态渲染站:先在 DevTools 里找背后的 JSON 接口,找不到再上 Playwright;
  • 红线不可越:robots.txt、服务条款、个人信息保护法是前提,不是「以后再补」。
# 爬虫一分钟:请求 → 解析 → 结构化
import httpx
from parsel import Selector

html = httpx.get("https://quotes.toscrape.com").text
sel = Selector(html)
for q in sel.css("div.quote"):
    print({
        "text": q.css("span.text::text").get(),
        "author": q.css("small.author::text").get(),
    })
两种典型错误:一是杀鸡用牛刀——静态页也硬上 Playwright,几十倍资源换十行 requests 就能做完的事;二是把合规当「以后再补」,等数据落库、业务跑起来再回头补,成本高到只能推倒重来。
动手前先花十分钟在 DevTools 的 Network 面板里侦察——很多「难爬」的站点背后就是一个干净的 JSON 接口,找到它比任何解析技巧都省力。

爬虫生态的工具很多,但按「页面是静态还是动态、量级多大」两个维度就能定下来。

按场景选

场景工具组合
静态页、一次性、几百条httpx + parsel / selectolax,一个脚本搞定
静态页、几十万条、要调度与重试Scrapy(07 章)
数据在 XHR / JSON 里直接打接口(05 章)——比渲染快一个数量级
必须执行 JS 才出数据Playwright;能只渲染一次拿到接口就别常驻浏览器
有 TLS / HTTP2 指纹检测curl_cffi(模仿浏览器指纹)或真浏览器(06 章)
要高并发 IOasyncio + httpx.AsyncClient(03 章)
页面结构极不规则trafilatura 抽正文,或 LLM 辅助抽取(08 章)

同类工具对照

类别候选怎么挑
HTTP 客户端requests / httpx / aiohttp / curl_cffi新项目默认 httpx(同步异步一套 API);要指纹伪装用 curl_cffi
解析BeautifulSoup / lxml / selectolax / parsel要快用 selectolax;要 XPath 用 lxml / parsel
浏览器自动化Playwright / Selenium / DrissionPage新项目一律 Playwright:等待策略与拦截 API 最完善
框架Scrapy / 自己写 asyncio要调度、去重、管道、限速就上 Scrapy,别重造
存储JSONL / Parquet / SQLite / PostgreSQL流式追加用 JSONL;要分析用 Parquet(08 章)
无头浏览器是最贵的那条路,却常被当成第一选择:内存几百 MB 起、并发上不去、还容易被检测。真正的高手顺序是——先看接口(05 章的侦察三件事)、再看静态 HTML 里有没有内嵌 JSON、最后才考虑渲染。开浏览器之前,先确认没有更便宜的入口。
先用 curl 复刻一次,再写代码:DevTools 里对着目标请求「Copy as cURL」,命令行里跑通了,再翻译成 Python。这样能把「请求姿势不对」与「代码写错了」两件事彻底分开——省下的时间比这一步花的多得多。

起步 · 骨架 · 失败反查

三张表 + 一段可跑的骨架:环境与依赖、三段式骨架逐行含义、四类失败反查——爬虫最难的不是报错,是不报错但拿不到数据。

一条完整链路只有三段:请求 → 选择 → 落盘。先把这三段跑通,后面十章都是在给某一段加厚。

装什么

依赖作用
uv add httpxHTTP 客户端:同步异步一套 API,自带连接池
uv add parsel解析:CSS 选择器与 XPath 都支持(Scrapy 同款)
uv add selectolax可选:只用 CSS 选择器时快得多
uv add playwright + playwright install chromium可选:真要渲染时才装(05 章)
标准库就够的csv / json / pathlib / urllib.parse

三段式骨架:每段管什么

段要点
① 请求用 Client 而不是裸函数(复用连接);一定要设 timeout 与 User-Agent;raise_for_status() 把 4xx/5xx 变成异常
② 选择先在 DevTools 里试选择器;取不到就是 None / 空列表,不会报错——所以要显式检查
③ 落盘增量写 JSONL(一行一条,断了能续);要给人看再导 CSV,newline="" + encoding="utf-8"
贯穿请求之间 time.sleep 一下;日志记下 URL 与状态码,出事能回溯

动手前的三条命令

命令看什么
curl -sI https://站点/页面状态码、Content-Type、有没有重定向
curl -s https://站点/页面 | head -c 2000服务端返回的原始 HTML 里到底有没有你要的数据
curl -s https://站点/robots.txt人家允不允许抓(10 章)

DevTools 里「Copy as cURL」把浏览器的请求原样复制出来,命令行里跑通了再翻译成代码——能把「请求姿势不对」和「代码写错了」彻底分开。

浏览器里看到的 HTML 和 httpx 拿到的常常不是一份东西:DevTools 的 Elements 面板显示的是 JS 执行后的 DOM,而你的请求拿到的是服务端返回的原始 HTML。所以「选择器在浏览器控制台里能选中、代码里却是空」是这一页最常见的第一个坑——判据就是上面那条 curl | head:原始 HTML 里没有,就说明数据是 JS 塞进去的(05 章)。
先跑通一页,再谈规模:把单页流程写成一个函数,确认字段都对,再套循环与并发。反过来做——先写调度与并发、最后调选择器——会让你在一堆并发日志里找一个选择器错误。

本章前面的二十行只做了三件事:把 HTML 拿回来、把字段挑出来、把结果写下去。往后的全部复杂度都是这三段各自长出来的。

① 请求段 ② 选择段

  • httpx.get() 返回的是 Response 对象而不是字符串:.text 是按编码解码后的 str,.content 是原始 bytes——存图片存 PDF 用 .content,喂解析器用 .text;
  • Selector(html) 把文本解析成一棵树,在单个块上再调 .css() 才能保证字段归属正确。

③ 落盘段:为什么先写 CSV

  • csv.DictWriter + newline="" + encoding="utf-8",三个参数少一个就会在某个平台上出空行或乱码;
  • 第一版永远先写 CSV——肉眼、Excel 都能立刻验收字段对不对,等字段稳定了再谈 SQLite / JSONL。
# 把本章前面拆开,逐段看返回值(注释是输出)
r = httpx.get("https://quotes.toscrape.com/", timeout=10)
r.status_code                              # 200
r.text[:15]                              # '<!DOCTYPE html>'
type(r.content)                            # <class 'bytes'>  ← 原始字节

sel = Selector(r.text)
len(sel.css("div.quote"))                  # 10   ← 先分块
q = sel.css("div.quote")[0]
q.css("span.text::text").get()[:20]       # '“The world as we hav'
q.css("div.tags a.tag::text").getall()   # ['change','deep-thoughts',...]

sel.css("span.nope::text").get()          # None ← 选不中不报错
sel.css("span.nope::text").getall()       # []
别全局取字段再按下标配对:这一页 10 条名言的标签数是 [4,2,5,4,2,3,2,4,1,3],全局取出来是一个 30 元素的平铺列表,按下标配对必然错位。正确做法是先选出每条记录,再在记录内部取字段。
选择器落空是返回值不是异常:.get() 给 None、.getall() 给 []。想让字段缺失当场暴露,就传 default= 或自己断言。

爬虫的失败分两种:会报错的(好查)与不报错的(要命)。后者才是这一页存在的理由。

① 报错:网络与协议

现象病因下一步
ConnectTimeout / ReadTimeout对方慢、被限速、或你没设超时导致挂死设超时 + 重试退避(03 章)
ConnectError / DNS 失败域名错、网络不通、代理配置不对先用 curl 验一次(03 章)
SSLError / 证书校验失败中间人代理、系统证书旧别急着关校验,先装证书(02 章)
TooManyRedirects登录态丢失后被反复跳登录页带上 Cookie / Session(02 章)

② 报错:状态码

状态码多半是下一步
403被识别成机器人(UA / 指纹 / IP)06 章检测向量表
429频率太高;看 Retry-After 头降速 + 退避(03 · 06 章)
404URL 拼错、分页越界、内容已删核对 URL 构造(02 章)
401要登录 / token 过期会话与鉴权(03 章)
503 或挑战页WAF / 人机验证06 章;先问「有没有官方 API」

③ 不报错:拿到了,但不是你要的

现象病因下一步
选择器返回空,页面看着明明有数据由 JS 渲染,原始 HTML 里没有05 章:找 XHR 接口
抓到的是登录页 / 同意 Cookie 页缺 Cookie 或地区合规拦截02 · 03 章
中文变成乱码编码猜错(charset 缺失或谎报)02 章编码与压缩
只抓到第一页 / 前 20 条分页参数、滚动加载、游标05 章接口分页
字段偶尔为空页面有多套模板04 章健壮性写法

④ 最坏的一种:静默失效

现象病因
跑了一个月,某天起每条记录的某字段都是空站点改版,选择器还在但选不中了
数据量突然少一半,没有任何报错被限速后大量请求超时,而异常被 except: pass 吞了
抓下来全是同一页分页参数没生效(被忽略或需要 cursor)

防线只有一条:把「抽到的字段为空」当成错误报出来,并对每次运行的条数做对比告警(09 章)。爬虫默默失效比报错可怕得多。

try: … except: pass 是爬虫里最贵的一行代码:它把超时、封禁、解析失败一起变成「没有数据」,于是你的爬虫看起来一直在跑,实际上什么都没抓到。要么记日志再继续,要么统计失败率并在超阈值时停下——让失败可见,是规模化之前必须先做的事。
排查顺序固定:先看状态码,再看返回体前 200 字符,最后才怀疑选择器。很多人一上来就改选择器,实际上拿到的根本是一张挑战页或登录页——打印一下 resp.status_code 与 resp.text[:200],两秒定位。

协议与基石

四张表:HTTP 方法与状态码、请求头全表(哪些会暴露你是机器人)、URL 编码与结构、编码 / 压缩 / 协议版本与 TLS 指纹。这一层是所有请求的物理定律。

爬虫遇到的九成「玄学问题」,都能在这几张表里找到解释:状态码说明对方怎么看你,请求头决定它怎么认你。

方法与状态码

类别要点
GET / POST取数据 / 提交;爬虫九成只用 GET,接口翻页有时用 POST
HEAD只要响应头:探测存在性与 Content-Length 最省流量
OPTIONS浏览器的 CORS 预检;你自己抓不需要发
2xx200 成功、204 无内容、206 分段(断点续传)
3xx301/302 跳转、304 未修改(增量抓取的关键,07 章)
4xx400 参数错、401 要登录、403 被拒、404 没有、429 太频繁
5xx500 对方崩了、502/503/504 网关或过载——这几个值得重试

请求头全表:哪些必须给对

头作用 / 风险
User-Agent身份标识;默认的 python-httpx/x.y 是最明显的机器人特征
Accept / Accept-Language内容协商;真实浏览器一定会带,缺了就可疑
Accept-Encoding压缩支持;客户端库会自动解压
Referer来源页;很多接口不带就 403
Cookie会话态;用 Client / Session 自动维护
Origin / Host跨域来源 / 目标主机
Sec-Fetch-* / Sec-CH-UA浏览器自动加的一组头,脚本不带就露馅(06 章)
X-Requested-With老式 AJAX 标记,部分接口要
Authorization / 自定义 X-*令牌与签名——接口逆向的主要战场(05 章)
响应头 Retry-After429/503 时对方告诉你等多久,照做
响应头 ETag / Last-Modified增量抓取的依据(07 章)
响应头 Set-Cookie发下来的会话;换客户端会丢

URL 结构与编码

写法说明
scheme://host:port/path?query#fragment#fragment 不会发给服务器
urllib.parse.urlencode({...})拼查询串;别手动字符串拼接
quote / unquote百分号编码;中文与空格必须编
urljoin(base, href)相对链接转绝对——翻页与详情页链接必用
urlparse / parse_qs拆 URL / 拆查询参数
规范化去掉多余参数、排序参数、统一大小写——去重的前提(07 章)

编码、压缩与协议版本

问题要点
乱码优先信 Content-Type 的 charset;缺失或谎报时用 charset-normalizer 猜
resp.text vs resp.content前者按编码解码后的字符串,后者是原始字节;拿不准先看字节
压缩gzip / br / zstd,客户端自动解;手写 socket 时才要自己处理
HTTP/1.1 vs 2 vs 32 多路复用、3 走 QUIC;httpx 要装 http2 额外依赖才开
TLS 指纹(JA3/JA4)握手参数的顺序与组合能认出你不是浏览器——即使头全对(06 章)
HTTP/2 指纹帧顺序与 SETTINGS 值同样可指纹化
403 不等于「被封 IP」。它可能是缺 Referer、缺 Sec-Fetch-*、TLS 指纹不对、或者只是路径要登录。换 IP 是最后一步,不是第一步——先用 curl 逐项补头验证,很多时候补上两个头就通了,白白烧掉的代理费和被拉黑的 IP 段都能省下。
把「浏览器发的那一整套头」抄下来当基线:DevTools 里复制真实请求的全部请求头,删掉 Cookie 之外你确定不需要的,其余原样带上。比起逐个试,这是最快的方法——也解释了为什么只改 User-Agent 常常不够。

把浏览器地址栏里发生的事拆开看:每一次抓取,本质都是一来一回的 HTTP 事务——读不懂请求行、状态码和头,后面所有反爬现象都会像玄学。

一次事务的四段

  • 起始行:请求写「方法 + 路径 + 版本」,响应写「版本 + 状态码 + 原因短语」;
  • 头:一堆 Name: value,身份、内容协商、凭证全在这里(本章后面逐个讲);
  • 空行:头和体的分界,只有一个空行;
  • 体:GET 通常没有,POST 放表单或 JSON,响应体才是你要的 HTML/JSON。

状态码:按「谁的问题」分四类

  • 2xx 成功——但 200 不代表有数据,反爬也常拿 200 返回一张空壳页或验证码页;
  • 3xx 重定向:跟不跟随是库的默认值,requests 默认跟随(拿到 200、r.history 有 2 条),httpx 默认不跟(直接给你 302 和 Location 头),要显式 follow_redirects=True——换库时最容易被这条阴到;
  • 4xx 你的问题:403 被拒(多半是反爬,06 章)、404 URL 拼错(本章 URL 卡的 urljoin)、429 频率超限(读 Retry-After,03 章);
  • 5xx 对方的问题:值得退避重试,但 503 在爬虫场景里常常也是反爬拦截而非真宕机。

无状态:服务端默认不记得你

  • HTTP 每个请求都是独立的,登录态、购物车、反爬放行票据全靠 Cookie 或 Token 一次次带回去(见本章「Cookie 与会话 / Session」)。

最小复现:看一次原始事务

  • curl -i https://httpbin.org/get 看完整的状态行 + 响应头 + 体;再 curl -i https://httpbin.org/status/403 与 /status/429,对比这三种响应在结构上其实一模一样——差别只在那三位数字;
  • 用 Python 各跑一次 httpx.get("https://httpbin.org/redirect/2") 和 requests.get(...),打印 status_code 与 len(r.history),即可确认两个库的重定向默认值不同。
# 命令行看原始事务:-i 显示状态行 + 响应头
curl -i https://httpbin.org/get

import requests
r = requests.get("https://httpbin.org/get")
print(r.status_code)               # 200
print(r.request.method)            # GET
print(r.headers["Content-Type"])  # application/json
把 403 / 429 当成「代码 bug」去 debug,其实是被反爬识别了;忽略 3xx 重定向会抓到登录页而非目标页。
侦察工具:浏览器 DevTools › Network、curl -i、httpie、Postman。看响应先看三样:状态码、Content-Type、体的前 200 字节——这三样能立刻分辨「拿到目标页」「拿到重定向」「拿到反爬页」三种情况。

反爬的第一道门就是查请求头:先弄清每个头本来是干什么用的,才能判断目标站到底在校验哪一个。

按职责给头分组

头本来的职责爬虫里的意义
User-Agent声明客户端身份默认值直接写着「我是脚本」,第一道筛子
Referer说明从哪个页面跳来缺了或对不上跳转链路会被拦
Accept / Accept-Language / Accept-Encoding内容协商:我想收什么浏览器的值很有特征,凑不齐即穿帮
Cookie / Authorization携带凭证登录态与反爬放行票据
Sec-Fetch-* / Sec-CH-UA浏览器自动加的上下文与客户端提示脚本不会自己带,是「像不像浏览器」的关键
Content-Type我发出去的体是什么格式与 Accept 方向相反,别搞混

Python 默认发了什么

  • 拿 httpbin.org/headers 回显:httpx 一共只发了 4 个头——Accept: */*、Accept-Encoding、Host、User-Agent: python-httpx/0.28.1;requests 同理,UA 是 python-requests/2.32.5;
  • 同一个请求,真实浏览器要发十几个头,还带 Sec-Fetch-Site、Sec-Ch-Ua、Accept-Language。默认 UA 直接自报家门是最便宜的暴露点,也是最容易补的一环。
import requests

headers = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
                  "AppleWebKit/537.36 (KHTML, like Gecko) "
                  "Chrome/126.0.0.0 Safari/537.36",
    "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
    "Referer": "https://example.com/",
    "Accept": "text/html,application/xhtml+xml",
}
r = requests.get(url, headers=headers)
分不清 Content-Type(我发的是什么)与 Accept(我想收什么),把 JSON 接口当表单提交;漏带 Referer / Cookie 还以为是接口坏了。
这里只管「每个头是什么意思」;要从 DevTools 整套复刻浏览器头,见 03 章「构造真实请求头集合」。先补 UA 与 Accept-Language 两个头,能过掉相当一部分只做粗筛的站点——但别指望它能过指纹层(06 章)。

HTTP 本身不记得你是谁,登录态、购物车、反爬放行的票据全靠 Cookie 一次次带回去——用对会话对象,这些就自动就位。

一张 Cookie 的一生

  • 服务端用 Set-Cookie 下发 → 客户端存进 CookieJar → 后续同域请求自动带上 Cookie 头;
  • 属性决定它什么时候被带上:Domain / Path 限范围,Secure 只走 HTTPS,SameSite 限跨站,HttpOnly 让 JS 读不到(但不影响你的爬虫读——它在 HTTP 层)。

三种凭证形态

  • 会话 Cookie:没有过期时间,浏览器关掉即失效,爬虫里表现为跑一阵就掉登录;
  • 持久 Cookie:带 Expires/Max-Age,可以存下来复用;
  • JWT / Bearer Token:不在 Cookie 里,走 Authorization: Bearer ... 头,常见于前后端分离站的接口(05 章接口逆向天天遇到)。

预热与挑战:很多站不会一上来就给你数据

  • 先访问首页拿一个「预热」Cookie,后续接口才放行——直接打接口就是 403;
  • 反爬服务会下发挑战 Cookie(如 Cloudflare 的 cf_clearance),它与 IP、UA 绑定,换任意一个就作废(06 章);
  • 所以一律用 Session / Client 对象,让 jar 自动收发,别手拼 Cookie 字符串——手拼的第一天就会漏掉这些你没注意到的票据。
import requests

s = requests.Session()                # 自动管理 Cookie + 连接池
s.post(login_url, data={"user": u, "pwd": p})  # Set-Cookie 自动入 jar
r = s.get(profile_url)                # 后续请求自动带上登录态
print(s.cookies.get_dict())
每次新建连接不复用 Session,导致登录态丢失;忽略反爬下发的挑战 Cookie(如 cf_clearance)。
等价物:httpx.Client()(Py)、axios + tough-cookie(JS)。

选择器写在哪棵树上,决定了你抓不抓得到数据:先分清「网页源代码」和「渲染后的 DOM」这两棵往往长得不一样的树。

文档是一棵树

  • 三种节点:元素(div、a)、属性(class、href、data-*)、文本;选择器就是在这棵树上描述一条路径;
  • 定位锚点的优先级:id > 语义化 class / data-* > 结构位置(div > div:nth-child(3))——越靠后越容易被改版打断(04 章)。

两棵树的差别就是「动不动态」

  • 网页源代码(Ctrl+U / curl):服务端发来的原始字节,你的 requests 拿到的就是它;
  • DevTools › Elements:JS 跑完之后的 DOM,一定「看起来有数据」;
  • 两者不一致 = 动态渲染。判定命令见 01 章侦察卡:同一个练习站,静态版 class="text" 在原始 HTML 里命中 10 次,/js/ 版命中 0 次。
import requests

html = requests.get(url).text
# 判断数据在不在初始 HTML 里
print("__NEXT_DATA__" in html)   # True → 数据藏在源码 JSON 中
print("class=\"price\"" in html)  # False → 很可能是 JS 渲染
对着 Elements 面板(渲染后)写选择器,但 requests 爬到的是未渲染的原始源码,选择器全落空。
亲眼验证页面是不是动态渲染:curl -s URL | grep 关键字,或 Python 里 关键字 in requests.get(url).text——目标数据能在原始源码里搜到就是静态、直接抓;搜不到多半是 JS 渲染,得去找背后接口或上浏览器。

URL 不只是地址,它是一个可拼装的查询接口:读懂各段结构,翻页、搜索、去重都能靠改参数完成。

六段结构

  • scheme://host:port/path?query#fragment——urlparse 一行拆开;
  • fragment(#后面)不会发给服务端,是纯浏览器行为,所以去重时该丢掉;
  • query 才是可玩的部分:分页常见 page= / offset= / cursor= 三种范式,前两种能直接算出全部 URL,游标式只能顺着翻。

百分号编码:三个函数别用混(输出)

  • quote("北京 市/a?b") → %E5%8C%97%E4%BA%AC%20%E5%B8%82/a%3Fb:空格变 %20,/ 默认保留(当路径段用的);
  • quote_plus("北京 市") → %E5%8C%97%E4%BA%AC+%E5%B8%82:空格变 +,查询参数才用它;
  • urlencode({"q": "北京 市", "p": 2}) → q=%E5%8C%97%E4%BA%AC+%E5%B8%82&p=2:拼整串 query 用这个,别自己拿 & 拼。

相对链接一律交给 urljoin

  • urljoin("https://a.com/list/page/2", "../item?id=3") → https://a.com/list/item?id=3;
  • urljoin("https://a.com/list/page/2", "/item") → https://a.com/item(斜杠开头 = 从根开始);
  • urljoin("https://a.com/list/", "item") → https://a.com/list/item——注意 base 末尾有没有斜杠,结果完全不同。

最小实现:一把去重钥匙

  • 写个 key(url):丢掉 fragment、按名字排序 query 参数、小写 host,再返回。拿 ?b=2&a=1#f 和 ?a=1&b=2 各跑一次——两者应当得到同一个键;
  • 再对比 w3lib.url.canonicalize_url 的输出(07 章的调度与去重两卡会用它),对照就能看出还漏了哪几条规则。
from urllib.parse import urlparse, urlencode, urljoin

urlparse("https://a.com/list?page=2#top")
# scheme='https' netloc='a.com' path='/list' query='page=2' fragment='top'

urlencode({"q": "你好", "page": 2})
# 'q=%E4%BD%A0%E5%A5%BD&page=2'  ← 自动百分号编码

urljoin("https://a.com/list/", "../item?id=3")
# 'https://a.com/item?id=3'
手动字符串拼 URL 忘了编码,参数错乱;没识别出分页参数规律,只能一页页点。
拼相对链接一律走 urljoin(base, href),别手拼字符串——urljoin("https://a.com/list/page/2", "../item?id=3") 得到 https://a.com/list/item?id=3;含中文或空格的 URL 用 w3lib.url.safe_url_string 做百分号编码,去重键用 canonicalize_url(会把查询参数排序归一)。

同一串字节,用错编码就是满屏乱码:抓取里最常见的「数据抓到了却全是问号」,根子几乎都在这一层。

解码顺序:先信声明,再谈猜

  • ① 响应头 Content-Type: text/html; charset=utf-8——有它就按它来(练习站就是这一行);
  • ② 没有再看 HTML 里的 <meta charset>;③ 都没有才用 charset-normalizer 猜;
  • 中文站还会遇到 GBK / GB2312 / Big5,统一按 gb18030 解更安全(它是 GBK 的超集)。

猜编码有多不靠谱

  • 同一句中文用 GB18030 编码:只有「价格 99 元」这几个字时被猜成了 UTF-16-BE(猜错),把同样的句子重复二十遍后才正确识别为 gb18030;
  • 结论不是「别用检测库」,而是短文本别信它:抓字段前先按整页正文定编码,别一个字段一个字段地猜。

压缩:解压是自动的,但你发出去的那行不是

  • gzip / deflate / br(brotli) / zstd——库都会自动解,但前提是本地装了对应的解压包;
  • 一个容易被忽略的细节:httpx 默认发的 Accept-Encoding 随本地已装的库而变——没装 brotli 时是 gzip, deflate,装了之后变成 gzip, deflate, br;
  • 而 2026 年的 Chrome 会带上 zstd。这一行少一截,本身就是一处指纹差异(06 章)——它还会随你的依赖装没装而漂移,最难查的那类问题。
import requests
r = requests.get(url)

# requests 自动解 gzip/deflate;br 需 pip install brotli
r.encoding = r.apparent_encoding   # 用 chardet 猜真实编码
text = r.text

# 更准的现代方案:charset-normalizer
from charset_normalizer import from_bytes
text = str(from_bytes(r.content).best())
中文乱码 = 用错编码解码;设了 Accept-Encoding 却没解压,拿到一坨乱码字节流。
先信任响应头里声明的 charset,没有再猜;猜要用整段正文而不是几个字。乱码排查三步:打印 r.headers["content-type"] 看有没有 charset → 打印 r.encoding 看库定成了什么 → 用 r.content.decode("gb18030") 手动试一次。三步定位不了的,多半根本不是编码问题而是拿到了反爬页。

协议版本本身也是一种身份:真浏览器早已走 HTTP/2 甚至 HTTP/3,很多请求库却默认停在 HTTP/1.1——这道落差就是破绽。

三个版本的差别与爬虫含义

版本传输关键特性对爬虫意味着
HTTP/1.1TCP,文本一连接一请求、队头阻塞大多数 Python 库的默认;与真浏览器有落差
HTTP/2TCP,二进制帧多路复用、头压缩(HPACK)帧序 / SETTINGS / 伪头顺序构成指纹(Akamai 常用)
HTTP/3QUIC(UDP)连接迁移、无队头阻塞Python 侧支持仍稀薄,暂时不是必选项

你现在走的是哪版

  • httpx.get(...).http_version 默认给 HTTP/1.1;开 httpx.Client(http2=True) 后同一个站给 HTTP/2——注意这需要装 httpx[http2](多一个 h2 依赖);
  • requests 无论如何都是 1.1:r.raw.version 恒为 11,这是 urllib3 的硬限制,换参数没用;
  • 命令行一眼看穿:curl -sI https://目标/ | head -1,练习站回的是 HTTP/2 200。
import httpx

# requests 只支持 HTTP/1.1;httpx 可发 HTTP/2
with httpx.Client(http2=True) as c:
    r = c.get("https://example.com")
    print(r.http_version)   # "HTTP/2"
用只支持 HTTP/1.1 的库去伪装 Chrome,协议指纹对不上被拦。反过来也别迷信「开了 HTTP/2 就像浏览器」——httpx 的帧序与 SETTINGS 和 Chrome 并不相同,真要对齐得用 curl_cffi 这类库(06 章)。
需要完整浏览器指纹时用 curl_cffi / tls-client,它们同时对齐 HTTP/2 与 TLS。

HTTPS 不只是「加密」:握手时那串密码套件与扩展的排列顺序,本身就是一枚会出卖你「不是浏览器」的指纹。

握手里发生了什么

  • 客户端发 ClientHello:支持的 TLS 版本、密码套件列表、扩展列表、椭圆曲线、以及 SNI(要访问哪个域名,明文);
  • 服务端回 ServerHello + 证书 → 双方协商出会话密钥 → 之后的 HTTP 报文全在加密通道里跑;
  • 所以中间设备看不到你的头和体,但看得到 ClientHello 长什么样——指纹就出在这里。

JA3 / JA4:把 ClientHello 哈希成一个指纹

  • 把版本、密码套件、扩展、曲线按顺序拼起来做哈希,就得到 JA3(JA4 是更细的新版);
  • 不同库、不同版本的 OpenSSL 会给出不同且稳定的哈希——这意味着「换 UA」对它毫无作用;
  • 换句话说:头伪装解决的是应用层,TLS 指纹是传输层,两层各判各的(06 章会把这条线接完)。
# curl_cffi 直接冒充真实浏览器的 TLS 指纹
from curl_cffi import requests

r = requests.get("https://tls.peet.ws/api/all",
                 impersonate="chrome124")
print(r.json()["tls"]["ja3_hash"])  # 与真实 Chrome 一致
以为 HTTPS 只是「加密」,忽略它同时是一枚会暴露你身份的指纹。
指纹层面的伪装(对齐 JA3 / JA4 与 HTTP/2 帧序)要靠 curl_cffi(impersonate="chrome124" 这类参数,可选值随库版本更新,装完先看一眼它支持到哪个版本)或 tls-client;纯 requests / httpx 换头改不了 TLS 指纹。想自查当前指纹,请求 tls.peet.ws/api/all 与真实浏览器访问同一地址的结果对比。

请求与会话

三张表:客户端库对照与 httpx API、超时 / 重试 / 退避的参数、代理与并发写法。把一次性脚本变成能长期跑的爬虫,靠的就是这几件事。

请求层的三件事:复用连接、失败能重试、并发有上限。缺一件,跑到第几千条就会翻车。

客户端库对照

库特点
httpx同步 + 异步一套 API、支持 HTTP/2;新项目默认选它
requests最老牌、文档最多;没有异步
aiohttp纯异步、性能好;API 与 requests 差别大
curl_cffi基于 curl-impersonate,能模仿浏览器的 TLS / HTTP2 指纹(06 章)
urllib.request标准库;能用但 API 笨重,仅在不能装包时用

httpx 常用 API

写法说明
with httpx.Client(headers=…, timeout=10) as c:复用连接与 Cookie;别在循环里裸调 httpx.get
c.get(url, params={...})查询参数交给库编码
c.post(url, json={...}) / data={...}JSON 体 / 表单体
resp.status_code / .text / .content / .json()状态 / 文本 / 字节 / 解析 JSON
resp.raise_for_status()把 4xx/5xx 变成异常,别让错误页悄悄流进解析
follow_redirects=Truehttpx 默认不跟随重定向(与 requests 相反)
c.cookies / c.headers会话态;登录后自动带上
httpx.AsyncClient() + await c.get(...)异步版;配 asyncio.Semaphore 限并发
limits=httpx.Limits(max_connections=20)连接池上限
http2=True开 HTTP/2(需装 httpx[http2])

超时、重试与退避

项怎么设
超时必设;httpx.Timeout(connect=5, read=20) 分别控制连接与读取
该重试的超时、连接错误、429、5xx
不该重试的400 / 401 / 403 / 404——重试只会更快被封
退避指数退避 + 抖动:1s、2s、4s… 再加随机量,避免整群同时重来
tenacity@retry(stop=stop_after_attempt(3), wait=wait_exponential_jitter())
Retry-After对方给了就按它等,别自作聪明
幂等性重试 POST 前先想清楚会不会重复提交

代理与并发

写法说明
httpx.Client(proxy="http://user:pass@host:port")单个代理
mounts={"https://": httpx.HTTPTransport(proxy=…)}按协议 / 域名分流
代理类型数据中心(便宜易封)、住宅(贵、难封)、移动(最贵)(06 章)
并发:线程ThreadPoolExecutor(max_workers=8)——同步库最省事的并发
并发:异步asyncio.gather + Semaphore 限并发;配 asyncio.sleep 控速
限速每域名单独限;并发数 × 每秒请求数才是对方感受到的压力
没设超时的请求会挂死整个爬虫:默认情况下一个不响应的连接可以卡住几分钟甚至更久,线程池被占满后整个任务停滞,日志里什么都看不到。timeout 是爬虫代码里最不该省的参数;同理,httpx 默认不跟随重定向,从 requests 迁过来时经常因此拿到一个 301 的空 body 却毫无察觉。
速率限制要按域名算,不是按进程算:十个线程各自「每秒一次」,对方看到的是每秒十次。实践中给每个域名配一个信号量或令牌桶,把「总速率」显式写出来——这既是礼貌(10 章),也是最有效的防封手段(06 章)。

客户端库是整条流水线的地基:选错了,连接复用、异步、HTTP/2 这些后面章章要用的能力可能根本就没有。

Python 侧怎么挑

库同步/异步HTTP/2TLS 指纹什么时候选它
requests同步否(恒 1.1)不可改十行脚本、教程复现;生态最全
httpx两者都有装 httpx[http2] 后可开不可改默认首选:API 近 requests,异步与 HTTP/2 都在里面
aiohttp纯异步否不可改几百上千并发、要榨性能
curl_cffi两者都有是可冒充浏览器被 TLS / HTTP2 指纹拦住时(06 章)
Scrapy 内建下载器事件驱动可选不可改整个项目已经在 Scrapy 里(07 章)

JS 侧与选型底线

  • fetch(内置)、axios(拦截器生态好)、got、undici(性能最高,Node 内置实现);
  • 底线只有一条:选支持连接复用与异步的现代库。缺了这两样,后面的限速、并发、代理池全都要绕着它写。
# 同步首选 httpx(API 近似 requests,但支持 HTTP/2 与异步)
import httpx
r = httpx.get(url, timeout=10)

# 异步:高并发 IO 的正解
import asyncio, httpx
async def main():
    async with httpx.AsyncClient() as c:
        r = await c.get(url)
asyncio.run(main())
高并发场景还在用同步 requests 逐个请求,吞吐低到无法接受。另一个反向错误是一上来就上 curl_cffi:它解决的是指纹问题,不是速度问题,没被拦之前用它只是徒增依赖。
同步脚本首选 httpx(API 近 requests,还能一行开 HTTP/2 与异步);纯高并发用 aiohttp;要伪装 TLS 指纹只有 curl_cffi 这类库能做。requests 只走 HTTP/1.1(r.raw.version==11),httpx 要显式 http2=True 才升到 HTTP/2。

循环里每次新建客户端,等于每条请求都重握一次 TCP + TLS 手——把连接池用起来,是慢爬虫变快的第一步。

Client 对象一次帮你做了三件事

  • 连接复用:HTTP keep-alive,握手一次、多请求共用(HTTPS 下省掉的是整个 TLS 握手,最贵的那一段);
  • Cookie 自动收发:登录态、预热 Cookie 全在 jar 里(02 章);
  • 默认头统一:headers= 传一次,整轮抓取都带着,不会漏。

到底快多少

  • 同一台机器连抓练习站 8 个分页:裸 httpx.get() 每次重建 → 换成 httpx.Client() 复用 → 再换异步并发,每一步都快两三倍;
  • 绝对数值和你的网络、目标站距离强相关,别抄我的秒数——但方向是恒定的,自己跑一遍最有说服力(异步那步见本章并发卡)。

池子怎么调

  • httpx.Limits(max_connections=100, max_keepalive_connections=20):前者是总并发上限,后者是闲置保活数;
  • 调大池子不等于抓得快——目标站的限速才是天花板,池子开太大只会更快撞上 429(06 章)。
import httpx

limits = httpx.Limits(max_connections=100,
                     max_keepalive_connections=20)
with httpx.Client(limits=limits, headers=base_headers) as c:
    for u in urls:
        c.get(u)   # 复用 TCP/TLS 连接 + Cookie,快且更像浏览器
循环里反复新建 client,握手开销爆炸且丢失状态。
一个 Client 用一整轮抓取,别在函数里随手 httpx.get()。用 with httpx.Client() as c: 保证退出时连接被正常关闭;异步侧对应 async with httpx.AsyncClient() as c:——忘了关会在日志里堆一片未关闭连接的警告。

反爬看的不是单个头的值,而是整套头是否自洽:下面讲的是把浏览器的请求「整套」搬过来,而不是挑几个抄。

整套搬运的正确姿势

  • DevTools › Network 里右键目标请求 › Copy as cURL,拿到的是浏览器真实发出的全部头;
  • 转成代码(curlconverter.com 或 uncurl),然后逐条删——删到哪一条开始被拦,就知道站方在校验什么。这比凭空猜快得多;
  • 删剩下的那套存成常量,后面所有请求共用(配合本章前面的 Client(headers=...))。

自洽比齐全更重要

  • Referer 要符合真实跳转链路:从列表页进详情页,Referer 就该是列表页 URL,凭空写一个反而更可疑;
  • UA 说自己是 Chrome 126,就得带 Chrome 126 会带的 Sec-Ch-Ua、Sec-Fetch-*,而且 TLS 指纹也得对得上(02 章)——三层说的必须是同一件事;
  • 头的顺序与大小写也会被指纹算法看在眼里,而请求库常会替你重排或改写——这也是纯 Python 库伪装的天花板所在(06 章)。
headers = {
    "User-Agent": UA_CHROME,
    "Accept": "text/html,application/xhtml+xml,*/*;q=0.8",
    "Accept-Language": "zh-CN,zh;q=0.9",
    "Sec-Fetch-Site": "same-origin",
    "Sec-Fetch-Mode": "navigate",
    "Sec-Ch-Ua": '"Chromium";v="126", "Not.A/Brand";v="24"',
    "Referer": "https://site.com/list",
}
只挑几个头抄而不是整套照搬,UA 说自己是 Chrome 却缺 Sec-Ch-Ua 等特有头;或内容抄齐了,顺序与大小写却被请求库改写,照样被指纹识别。
fake-useragent 这类随机 UA 库看着省事,但它会破坏「UA 与其余头、与 TLS 指纹自洽」这条底线——轮换 UA 的正确做法是准备几套完整的头组合整套轮换,而不是只随机那一行字符串。各个头是什么意思,回看 02 章「请求头与 User-Agent」。

不设超时的请求迟早会挂在一个卡死的连接上;不带退避的重试则会把自己变成一次小型 DDoS——稳态爬虫全靠这一层兜底。

超时不是一个数(默认值)

  • requests 默认 timeout=None——不设就是无限等,一个卡死的连接能把整轮抓取吊死;
  • httpx 默认 Timeout(timeout=5.0),四个阶段(connect / read / write / pool)共用这 5 秒,可以分别设:httpx.Timeout(5, read=10);
  • 读超时要比连接超时宽松:慢接口是常态,连不上才是异常。

什么该重试,什么不该

  • 该重:网络错误、超时、429、5xx——都是「等一下可能就好了」;
  • 不该重:4xx 逻辑错误(400/404 重一百次还是错)、403(重试只会加速被封,该换的是策略不是次数);
  • 429 要先读 Retry-After:服务端已经明说了等多久,照做比自己猜聪明。

退避要带抖动

  • 指数退避 1s → 2s → 4s → 8s,再叠一个随机抖动——否则一批并发任务会同时醒来再次撞墙(惊群);
  • tenacity 的 wait_exponential + wait_random 一行搞定,别自己写 sleep 循环。
from tenacity import (retry, stop_after_attempt,
                      wait_exponential, retry_if_exception_type)
import httpx

@retry(stop=stop_after_attempt(5),
       wait=wait_exponential(multiplier=1, max=30),
       retry=retry_if_exception_type(httpx.TransportError))
def fetch(c, url):
    r = c.get(url, timeout=httpx.Timeout(5, read=10))
    if r.status_code in (429, 503):
        raise httpx.TransportError("retryable")
    return r
无限即时重试 = 变相 DDoS,加速被封;对 403 疯狂重试却不换策略。
一定要显式设超时:requests 默认 timeout=None(不设就无限等),而 httpx 默认 5 秒;重试只针对 429 / 503 与网络错误,读 Retry-After 头尊重服务端给的等待时间,退避用指数加抖动(tenacity 的 wait_exponential)。

并发是爬虫的双刃剑:开对了几十倍提速,开脱缰就是把目标站打挂、把自己 IP 打进黑名单。

三种并发模型怎么选

  • asyncio + httpx/aiohttp:IO 密集型的正解,单线程几百并发;代价是整条链路都得是 async;
  • 线程池(ThreadPoolExecutor):想复用 requests 这类同步库时最省事,几十并发够用;
  • 多进程:只有解析成了 CPU 瓶颈(比如大量正则或 JSON 解析)才需要——抓取本身极少是 CPU 密集。

并发数不是越大越好

  • 同一台机器抓 8 个分页,异步并发比顺序复用连接又快了两三倍(方向恒定,绝对值看网络,自己跑);
  • 但目标站的限速才是真天花板:并发一上去,429 和封 IP 就来了(06 章)。先测出对方的容忍线,再定并发数;
  • asyncio.Semaphore(10) 卡住并发上限,再配一点随机延时,就是「快而不失礼」的最小实现。

最小复现:量一次三级加速

  • 抓练习站 quotes.toscrape.com/page/1..8/,写三个版本各计时一次:① 循环里裸 httpx.get();② 复用一个 httpx.Client();③ AsyncClient + asyncio.gather;
  • 会出现两级明显的台阶(每级两三倍)。然后把 ③ 的并发从 8 提到 100 再跑一次——观察耗时是不是继续线性下降,以及有没有开始出现非 200 的响应。这两次对比就是「限速」那一节的实证。
import asyncio, httpx

sem = asyncio.Semaphore(10)          # 并发上限
async def fetch(c, url):
    async with sem:
        r = await c.get(url)
        await asyncio.sleep(0.3)     # 礼貌间隔
        return r.text
async def main(urls):
    async with httpx.AsyncClient() as c:
        return await asyncio.gather(*(fetch(c, u) for u in urls))
Promise.all / gather 无上限并发几千请求,秒被封 IP;忽略每域名礼貌间隔。
JS 侧限流用 p-limit / Bottleneck,Python 侧平滑速率用 aiometer(能直接限「每秒最多几个」而不只是「同时最多几个」)——这两者不是一回事:信号量限的是并发,速率限的是频率,被封 IP 时通常要调的是后者。

写爬虫前先当一次「侦察兵」:在 DevTools 里把真实请求看清楚,往往比对着页面猜半天省下大把时间。

DevTools › Network 的四个动作

  • 过滤 Fetch/XHR:直接看数据从哪个接口来(05 章接口逆向的第一步);
  • Copy as cURL:把整套真实请求原样搬走,是上一张「构造请求头」卡的输入;
  • 看 Timing:区分「对方慢」和「你自己排队」;
  • 存 HAR:把整段会话导出来事后复盘,尤其适合登录、跳转这种多步流程。

命令行侧的三件套

  • curl -i 看原始状态行与头,curl -sI 只要头(顺带看出 HTTP 版本);
  • httpie 输出带高亮、自动格式化 JSON,试接口比 curl 顺手;
  • 请求已经很复杂时,把 curl 命令贴进 curlconverter.com 直接得到 Python 代码,省掉手抄十几个头的机械劳动。
# 1) DevTools → 右键请求 → Copy as cURL:
curl 'https://api.site.com/list?page=1' \
  -H 'user-agent: Mozilla/5.0...' \
  -H 'cookie: sid=abc123' --compressed

# 2) 粘到 curlconverter.com(或 uncurl 库)自动转成:
import requests
requests.get("https://api.site.com/list",
    params={"page": 1},
    headers={"user-agent": "Mozilla/5.0..."},
    cookies={"sid": "abc123"})
跳过侦察直接写爬虫,反复试错浪费大量时间。
省事路线:DevTools 里右键请求 › Copy as cURL,粘到 curlconverter.com 自动转成 requests / httpx 代码;命令行快速试探用 curl -i 或 httpie;整段会话存成 HAR 文件事后复盘。

代理是把「一个 IP 高频访问」摊成「很多 IP 低频访问」的手段——接入方式简单,难在代理本身的质量与匿名度。

接入:一个参数的事

  • HTTP / HTTPS / SOCKS5 三种协议,写法都是 协议://[用户:密码@]主机:端口;
  • 认证两种形态:URL 里带用户名密码,或供应商侧配 IP 白名单(服务器上跑时更常用,省得密码进代码);
  • 要按域名/协议分流时才需要 mounts= 挂多个 transport,绝大多数场景一个 proxy= 参数就够。

用之前先验三件事

  • 通不通:请求一个回显 IP 的接口(如 httpbin.org/ip),看到的应该是代理 IP 而不是你自己的;
  • 匿不匿名:有些代理会加 X-Forwarded-For / Via 头把你的真实 IP 一起转出去,用 httpbin.org/headers 回显查;
  • 快不快:代理会显著拉长延迟,配合超时设置一起调(本章超时卡)。轮换策略、住宅 vs 数据中心 IP 的取舍在 06 章。
import httpx

proxy = "http://user:pass@host:8080"
r = httpx.get(url, proxy=proxy, timeout=15)  # httpx 0.28 起用 proxy=,proxies= 已移除

# SOCKS5 需: pip install httpx[socks]
httpx.get(url, proxy="socks5://host:1080")

# 需按协议/域名分流时,才用 mounts= 挂多个 HTTPTransport(proxy=...)
免费代理速度慢、泄露真实 IP、还可能是蜜罐。
httpx 0.28 起代理参数是 proxy=(proxies= 已移除,传了报 TypeError,确认);SOCKS5 需 pip install httpx[socks];用代理前务必验证匿名度与真实出口 IP(请求一个回显 IP 的接口如 httpbin.org/ip,看到的应是代理 IP 而非你自己)。

解析与提取

四张表:解析器对照、CSS 选择器全表、XPath 全表、内嵌 JSON 的四个藏身处。现代站点往往把数据直接塞进页面 JSON——那才是最稳的入口。

定位数据只有三把工具:CSS 选择器(简洁)、XPath(能往上找、能按文本找)、直接解 JSON(最稳)。

解析器对照

库选择器特点
parselCSS + XPathScrapy 同款;脚本里也能单独用,首选
selectolaxCSS快得多(C 实现),大批量解析选它
lxmlCSS + XPath老牌、功能全,API 偏底层
BeautifulSoupCSS + 自有 API最好读、最慢;容错最强(烂 HTML 用它)
标准库 html.parser—不装包时的兜底

CSS 选择器全表

写法选中什么
div / .cls / #id标签 / 类 / id
a[href] / a[href^="/p/"]有该属性 / 属性前缀匹配($= 后缀、*= 包含)
div p / div > p后代 / 直接子元素
h2 + p / h2 ~ p紧邻兄弟 / 后续兄弟
li:nth-child(2) / :first-child / :last-child按位置
:not(.ad)排除
::text / ::attr(href)parsel 扩展:取文本 / 取属性
取不到的父节点、按文本内容找——这两件事 CSS 做不到,要用 XPath

XPath 全表

写法含义
//div / /html/body/div任意层级 / 从根逐层
//div[@class="item"]属性等于;class 多值时要用 contains
//div[contains(@class, "item")]属性包含
//a[text()="下一页"] / [contains(text(), "下一页")]按文本找——XPath 独有的本事
//li[1] / //li[last()]下标从 1 开始
/text() / //text() / /@href直接文本 / 所有后代文本 / 属性值
.. / ancestor::div往上找父 / 祖先——「先定位标题再取它所在的卡片」靠它
following-sibling::p[1]后面的第一个兄弟(「字段名 + 值」成对的结构常用)
normalize-space(.)取文本并压掉多余空白
//*[@id="x"]//a任意标签 + 后代

内嵌 JSON:数据的四个藏身处

位置怎么取
<script type="application/ld+json">结构化数据(商品、文章、面包屑),最规整
<script id="__NEXT_DATA__">Next.js 站点的完整页面数据
window.__INITIAL_STATE__ = {...}正则抠出花括号那段再 json.loads
XHR 接口最优解:直接打那个返回 JSON 的接口(05 章)
取值层级深时用 jmespath / jsonpath-ng,比一串 ["a"][0]["b"] 稳
抽不到就给 None,这是爬虫静默失效的头号来源:选择器写错、站点改版、某些条目缺字段——三种情况的表现完全一样,都是「跑得好好的,数据是空的」。解法是把「必需字段为空」当成错误:抽取后立刻校验(08 章的 pydantic 模型),空了就记日志、计数、超阈值告警(09 章),而不是写进库里等下游发现。
选择器要「锚定语义、避开样式」:div.css-1x2y3z 这种哈希类名下次构建就变了,而 [data-testid]、id、语义标签(article / h1)稳定得多。实在只有样式类可用时,选最短的那一段(.price 而不是 .list > div:nth-child(3) > span.price)。

解析器决定了你面对的是一棵怎样的树:同一段破损 HTML,不同解析器会修补出不同结构,选择器的成败往往从这里就定了。

同一段破 HTML,两个解析器给出两棵树

  • 喂 <div id=a><p>one<p>two<p>three</div>(三个 <p> 都没闭合,真实网页里遍地都是);
  • 选 #a > p:html.parser 只给 1 个节点,文本粘成 'onetwothree'(它把后两个 p 当成了嵌套子节点);lxml 给 3 个,文本是 ['one','two','three'];
  • 换句话说,同一份选择器同一份 HTML,换个后端结果能差三倍——这不是玄学,是容错策略不同。

选哪个

方案本质特点
BeautifulSoup外壳,不是解析器API 友好,后端可换——真正干活的是下面几个
lxmlC 实现(libxml2)快、容错贴近浏览器,默认就选它
html.parserPython 内置零依赖,容错最弱,破损 HTML 上会给你另一棵树
html5lib纯 Python严格按 HTML5 规范修补,最像浏览器但最慢
selectolaxC 实现(Modest/Lexbor)极快,大规模抓取时值得换
parsellxml 之上的选择器层Scrapy 同款,CSS/XPath 混用还带 ::text

JS 侧

  • cheerio(类 jQuery,最常用)、node-html-parser(轻快)、parse5(规范级)、jsdom(完整 DOM,慢但能跑页面脚本)。
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "lxml")      # 指定 lxml 后端更快
title = soup.select_one("h1.title").get_text(strip=True)

# 极快替代(C 实现,适合大规模)
from selectolax.parser import HTMLParser
tree = HTMLParser(html)
title = tree.css_first("h1.title").text()
拿正则去解析嵌套 HTML 结构——脆弱且必然出错。
BeautifulSoup(html) 不指定 parser 会挑「当前环境最好的可用解析器」——装了 lxml 就用 lxml、没装才退回 html.parser,并抛 GuessedAtParserWarning。默认值随环境漂移,同一份代码换台机器就可能解出另一棵树(就是上面那 1 vs 3),所以抓取代码一律显式写 BeautifulSoup(html, "lxml")。html5lib 没装时指定它会直接抛 FeatureNotFound,不会静默回退。

CSS 选择器是抓取里最顺手的定位语言:够用、可读、和前端同一套话术——先把它吃透,XPath 只在它够不着时才上。

够用的那一小半语法

  • 基础:tag、.class、#id、[attr]、[attr="v"];
  • 组合:后代(空格)、直接子代 >、相邻 +、后续兄弟 ~——子代和后代分不清是错得最多的一处;
  • 伪类::nth-child(2)、:first-child、:not(.ad);
  • 属性匹配:[href^="/p/"] 前缀、[class*="item"] 包含、[href$=".pdf"] 后缀——绑不稳定 class 时的救命稻草。

parsel 的伪元素:一步取值

  • sel.css("span.price::text").get() 直接给文本,sel.css("a::attr(href)").getall() 直接给 ['/x']——不用先 select() 出节点再手工抠属性;
  • 注意 ::text 只取直接文本节点:<a>去<b>看看</b></a> 上 a::text 只有「去」,要连子节点文本得用 XPath 的 //text()(见本章后面)。

锚点选得稳,比选择器写得巧更重要

  • 优先级:id / data-* / 语义化 class > 属性前缀匹配 > 结构位置;
  • .css-1a2b3c 这种构建期哈希类名每次发版都会变,绑上去等于给自己排了个失效倒计时。
# BeautifulSoup / parsel 通用 CSS 语法
soup.select("div.item > a[href^='/p/']")   # 子代 + 属性前缀
soup.select("ul li:nth-child(2)")
[a["href"] for a in soup.select("a.next")]

// cheerio(JS,同款 jQuery 风格)
$("div.item > a").attr("href");
选择器绑死在 hash 化类名(如 .css-1a2b3c)上,站点一更新就全崩。
取文本或属性别只 select() 出节点再手工抠:parsel 支持伪元素 ::text / ::attr(href) 一步到位(a::attr(href) 直接返回 href 列表);选择器优先绑 data-* 或语义化 class,别绑 .css-1a2b3c 这类构建期哈希类名。

XPath 是选择器里的「重型工具」:能按文本选、能往祖先与兄弟方向走——CSS 做不到的横向和向上导航,全靠它。

CSS 给不了的三件事

  • 按文本定位://a[text()='下一页']、//a[contains(text(),'下一')]——翻页链接没有稳定 class 时唯一的活路;
  • 轴(axis):ancestor::、parent::、following-sibling::——从「价格」标签往上找到整个商品卡片,或从 <dt> 横着取 <dd>;
  • 函数:normalize-space()、starts-with()、count()、last()。

text() 的两种写法,结果不同

  • 对 <a>去<b>看看</b></a>://a/text() 得 ['去'](只要直接子文本),//a//text() 得 ['去','看看'](含后代全部文本);
  • 取带空白的 <span> 99.9 </span>://span/text() 原样给 ' 99.9 ',normalize-space(//span) 直接给 '99.9'——首尾和内部连续空白一起收拾了。

从 DevTools 复制的 XPath 为什么在 requests 里全落空

  • Chrome 的「Copy XPath」给的是渲染后 DOM 的路径,最常见的坑是里面多了一层 /tbody/;
  • 两头都验证过:原始 HTML <table><tr><td>1</td></table> 里没有 tbody,lxml 与 html.parser 都不会替你补;而同一段 HTML 丢进 Chromium,table tbody 数出来是 1——浏览器按规范自动补了一层;
  • 所以 //table/tbody/tr 在浏览器控制台里好好的,到 lxml 里就是 0 条。把 /tbody/ 删掉,或干脆写 //table//tr。
from lxml import html
tree = html.fromstring(page)

tree.xpath("//div[@class='price']/text()")
tree.xpath("//a[text()='下一页']/@href")          # 按文本定位
tree.xpath("//span[@class='tag']/ancestor::article")  # 向上找祖先
写绝对 XPath(/html/body/div[3]/…)极其脆弱,页面一动就废——而 DevTools 的「Copy XPath / Copy full XPath」给的恰恰就是这种,还可能多一层浏览器补出来的 tbody。拿它当起点可以,一定要手工改成靠属性或文本定位的相对路径。
取文本用 normalize-space() 一步去掉首尾与内部多余空白(//span/text() 得到 ' 99.9 ',normalize-space(//span) 得到 '99.9');只要直接子文本用 /text()、含后代的全部文本用 //text(),两者结果不同别混用。

正则在爬虫里是把小刀,不是解析器:从文本、属性、内联脚本里抠价格、ID、日期这类碎片刚刚好,越界去解析标签树必出错。

该用正则的三个场合

  • 从内联脚本里揪出整包 JSON(var data = [...]、window.__INITIAL_STATE__ = {...})——本章后面的主力手段;
  • 从纯文本里抠结构化片段:价格里的数字、URL 里的 ID、「3 天前」这类相对时间;
  • 从属性值里抠子串(/product/12345 里的 12345)。

正确的姿势是「先缩小,再正则」

  • 用解析器把范围缩到最小节点,再对那一小段 .text 上正则——而不是对整页 HTML 硬套;
  • 抠 JSON 时配非贪婪 .*? 加 re.S(DOTALL,让 . 匹配换行),否则第一个 ] 就断了;
  • 写好后一定要处理「没匹配上」:re.search 返回 None,直接 .group(1) 就是 AttributeError,这是爬虫里最常见的崩溃点之一。

别拿正则解嵌套标签

  • HTML 是可任意嵌套的,正则是正则语言——原理上就匹配不了任意深度的嵌套,不是「写得不够巧」;
  • 症状很典型:十个页面里九个对,剩下那个多了一层 <div>,抓下来的字段就跨到别的商品去了,而且不报错。
import re

# 从内联 JS 里抠 JSON 字段
re.search(r'"price":\s*([\d.]+)', script).group(1)  # '12.99'

# 从 URL 批量抠 ID
re.findall(r'/product/(\d+)', html)               # ['12','34',...]

re.search(r'total:\s*(\d+)', text, re.DOTALL)
著名反模式:用正则匹配 HTML 标签对,遇嵌套即失效。
正则用来抠值、不用来解树:从内联 JS / JSON 抠字段时配非贪婪 .*? 与 re.DOTALL;但只要目标嵌在标签结构里,先用解析器缩到最小节点、再对它的 .text 上正则,别对整页 HTML 硬套。

现代站点常把整包数据直接塞进页面的一个 script 里——找到它,比对着渲染后的 DOM 一个个抠字段稳得多、也快得多。

先搜这四个锚点

  • __NEXT_DATA__(Next.js,整包 props 就在这个 <script id> 里);
  • window.__INITIAL_STATE__ / __NUXT__(Vue / Nuxt 系);
  • <script type="application/ld+json">(Schema.org 结构化数据,商品、文章、面包屑常有);
  • 什么框架都不是的裸变量,比如练习站 /js/ 版里的 var data = [...]。

为什么内嵌 JSON 优于解析 DOM

  • 练习站 JS 版:正则揪出 var data = [...] 再 json.loads,10 条全拿到,而且字段比 HTML 里更全——tags 直接就是数组 ['change','deep-thoughts',...],作者是嵌套对象;
  • DOM 那条路要先拼标签、再切逗号;JSON 这条路拿到的就是干净结构;
  • 更关键的是稳:前端改 class 名不影响它,改的是数据结构才会断——后者频率低得多。

取出来之后

  • 整包 JSON 往往嵌得很深,用 jmespath / jsonpath-ng 写路径比一层层 ["a"]["b"][0] 可读,也更容易加兜底;
  • <script> 里不一定是纯 JSON(可能是 var x = {...}; 这种赋值语句),先用正则切出大括号那段再 json.loads。
import json
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "lxml")

# Next.js 站点:整包数据就在这个 script 里
raw = soup.select_one("#__NEXT_DATA__").string
data = json.loads(raw)
props = data["props"]["pageProps"]

# Schema.org 结构化数据
for tag in soup.select('script[type="application/ld+json"]'):
    json.loads(tag.string)
费劲解析渲染后 DOM,其实源码里就有整包 JSON 数据,直接取更稳。
先在源码里搜 __NEXT_DATA__、__INITIAL_STATE__、application/ld+json 这几个锚点:命中就 json.loads 整包取,字段齐全且不受前端 class 改名影响。<script> 里的文本用 tag.string / get_text() 拿出来,再交给 json.loads。

站点会改版、字段会缺失、脏数据总会来——健壮的抽取层不追求某一条完美,而是保证单条出问题不拖垮整轮抓取。

三道防线

  • 字段级兜底:.get(default="")(选不中时 .get() 给 None、.getall() 给 []、带 default 给你指定的值);
  • 选择器级 fallback:主选择器 or 备用选择器 or 默认值,串成一行;改版时往往只挂掉其中一条;
  • 记录级隔离:单条解析包在 try 里,坏的记下日志跳过,别让一条脏数据带走整轮。

但兜底不等于装作没事

  • 全字段兜底会把「站点改版」变成「悄悄写入一堆空值」——这正是 01 章那个静默失败;
  • 正确做法是兜底 + 计数:统计每个字段的空值率,超过阈值就告警(09 章)。关键字段(价格、ID)宁可让它抛异常,也别默默写 0。

改造清单:把 hello.py 变成抗改版的版本

  • 回到 01 章那段 hello.py,给三个字段各加一条 fallback 选择器与默认值,再加上「本轮空值率」的统计输出;
  • 然后故意把主选择器改错(span.text 改成 span.txt)重跑:脚本应当照样跑完,但把空值率打成 100% 并告警——而不是像原版那样安静地写出一堆空行;
  • 进阶:把改错前后的 CSV 存两份,写个 diff 脚本比字段空值率——这就是 09 章「维护:应对站点变更」里回归测试的雏形。
from parsel import Selector
sel = Selector(text=html)

# 主选择器失效 → 自动兜底 → 最后给默认值
price = (sel.css("span.price::text").get()
         or sel.css("[data-price]::attr(data-price)").get()
         or "0").strip()

title = sel.xpath("//h1/text()").get(default="").strip()
假设字段永远存在,遇到缺失直接抛异常中断整轮抓取。
给每个字段设兜底:parsel 里 .get(default="") 在选择器落空时返回默认值而不报错(选不中时 .get() 返回 None、.getall() 返回 []);多写几个 fallback 选择器用 or 串起来,最后再 strip() 清洗。

动态渲染与逆向

三张表:侦察清单(动手前先看三件事)、接口逆向流程、Playwright API 与等待策略。高手的第一反应不是渲染页面,是找到真正返回数据的那个请求。

顺序永远是:侦察 → 打接口 → 实在不行才开浏览器。开浏览器贵在内存、并发与被检测,能省则省。

侦察清单:动手前先看三件事

看什么怎么看说明什么
原始 HTML 里有没有数据curl -s URL | grep 关键词有 → 静态抓取就够,别开浏览器
有没有 XHR 接口DevTools → Network → Fetch/XHR,看返回 JSON 的那条有 → 直接打它,最稳最快
有没有官方 API / 数据导出翻文档、看 /api 路径、搜「开放平台」有 → 优先用,合规又稳定(10 章)
顺带看robots.txt 与 sitemap.xml允不允许抓、有没有现成的 URL 清单(07 章)

接口逆向:固定流程

步骤做什么
1DevTools 里找到目标请求 → Copy as cURL → 命令行跑通
2逐项删头,找出哪些是必需的(多半是 Referer、token、Sec-* 中的一两个)
3看参数里有没有 签名 / 时间戳 / 随机串(sign、_t、nonce、token)
4有签名 → 在 Sources 面板搜参数名,或下 XHR 断点,顺调用栈找到生成它的函数
5读懂算法后用 Python 复刻(常见是 md5 / hmac + 固定盐 + 参数排序)
6实在读不动 → 用 Playwright 执行页面里的那个函数,或整段渲染
分页接口的翻页参数通常是 page / offset / cursor(要用上一页返回的值)

Playwright API

写法说明
sync_playwright() / async_playwright()两套 API,脚本用同步版更省事
browser = p.chromium.launch(headless=True)启动;调试时设 headless=False 眼见为实
ctx = browser.new_context(user_agent=…, locale=…, storage_state=…)上下文 = 一份独立的 Cookie 与指纹;storage_state 可保存复用登录态
page.goto(url, wait_until="domcontentloaded")networkidle 在长连接页面上永远等不到,慎用
page.locator("css=…") / get_by_role / get_by_text定位器;自带自动等待
locator.wait_for(state="visible") / expect(...)显式等待
page.wait_for_selector / wait_for_function等元素 / 等任意 JS 条件成立
page.wait_for_response(lambda r: "/api/" in r.url)等那个接口回来——比等元素靠谱
page.route("**/*.{png,jpg,css}", lambda r: r.abort())拦掉图片与样式,速度立刻上去
page.on("response", handler)顺手把接口响应直接存下来,省得再解析 DOM
page.evaluate("() => window.__DATA__")在页面里执行 JS 取值——逆向签名时特别有用
page.mouse / keyboard / locator.scroll_into_view_if_needed()交互与滚动加载
time.sleep(3) 是最脆的等待方式:网快时白等,网慢时照样抓空。Playwright 的定位器自带自动等待,要显式等就等具体条件——某个元素可见、某个接口返回、某个 JS 变量出现。另一个高频坑是 wait_until="networkidle":只要页面有心跳、轮询或长连接,网络就永远不会 idle,于是超时。
用 Playwright 的正确姿势往往是「只渲染一次」:打开页面拿到 token 或 Cookie,之后全部换成 httpx 直接打接口。浏览器只负责过掉那道 JS 门槛,抓取主体仍走轻量路线——这样既省资源,又让并发重新变得可控。

这一步的差距一多半发生在写第一行代码之前——五分钟侦察,省掉几小时白干。

① 站方怎么说 ② 数据在不在原始 HTML 里

  • 先看 /robots.txt。练习站的这个文件返回 404——没有 robots.txt 不等于随便抓,只是站方没在这份文件里表态,服务条款和礼貌限流照样成立(10 章);
  • 判定动态渲染只要一条命令:curl -s URL | grep -c 你要的字段。同一个站的两个版本,静态版 class="text" 命中 10 次,/js/ 版命中 0 次——后者的数据全在 <script> 里;
  • 别拿 DevTools 的 Elements 面板下判断:那是 JS 跑完后的 DOM,一定「有数据」。要看 view-source: 或 curl 拿到的原始字节(05 章)。

③ 有没有更好走的门

DevTools › Network 过滤 Fetch/XHR,翻页或滚动一下,看数据是不是从某个 JSON 接口来的——直接打那个接口比解析 HTML 又快又稳(05 章的接口逆向)。没有接口也常有内嵌 JSON:正则揪出 var data = [...] 再 json.loads,字段往往比 HTML 里更全。

# 侦察三连:有没有内容 / 是不是静态渲染 / 状态码与类型
curl -s https://quotes.toscrape.com/ | wc -c        # 11064
curl -s https://quotes.toscrape.com/ | grep -c 'class="text"'      # 10 ← 静态,直接抓
curl -s https://quotes.toscrape.com/js/ | grep -c 'class="text"'   # 0  ← 数据在 JS 里
curl -sI https://quotes.toscrape.com/ | head -3     # HTTP/2 200 · text/html; charset=utf-8

# 内嵌 JSON:不用开浏览器也能拿到 /js/ 版的数据
import re, json, httpx
html = httpx.get("https://quotes.toscrape.com/js/", timeout=15).text
data = json.loads(re.search(r"var data = (\[.*?\]);", html, re.S).group(1))
len(data), data[0]["author"]["name"]   # (10, 'Albert Einstein')
一上来就开浏览器是最常见的浪费——九成站点根本不需要,而浏览器方案慢一到两个数量级。先看一眼「右键查看源代码」里有没有数据。
侦察结论直接决定选型,也直接决定你要读本页哪一章:原始 HTML 里就有数据 → 03 章直接请求;数据来自 XHR → 找那个 JSON 接口;两者都不是 → 才轮到 Playwright。

requests 只拿服务器吐出的那一份原始 HTML,它不执行任何 JavaScript;页面若靠 JS 二次填充,你抓到的就是一具空壳。

四种「抓不到」的形态

  • 客户端渲染(SPA):初始 HTML 是空壳,数据由 JS 拉取后填充;
  • 无限滚动 / 懒加载:内容随滚动才请求;
  • 交互触发:点「展开」「加载更多」才发 XHR;
  • 登录墙 / 验证墙:拿到的是登录页或挑战页,状态码还可能是 200。

同一个站的两个版本(对照)

  • 静态版 quotes.toscrape.com/:httpx 抓下来解析 div.quote 得 10 条;
  • JS 版 quotes.toscrape.com/js/:同一段代码得 0 条——但数据并没消失,它在页面的 var data = [...] 里;
  • 把同一个 /js/ 页丢给无头浏览器渲染后再数:又变回 10 条。三个数字并排,「JS 渲染」这件事就具体了。

判定之后走哪条路

  • 数据在原始 HTML → 直接抓(03/04 章);
  • 数据在内联脚本 → 正则 + json.loads(04 章),仍然不用开浏览器;
  • 数据在 XHR 接口 → 本章后面的接口逆向;
  • 以上都不行 → 才请出无头浏览器。
import requests
html = requests.get(url).text

# 数据不在初始 HTML → 是客户端渲染
assert "data-price" not in html
# 下一步:打开 DevTools → Network → Fetch/XHR,找真实数据接口
对空壳 HTML 反复调选择器,其实数据根本不在初始响应里——症状是「选择器在浏览器控制台里好好的,代码里就是 0 条」。但反过来也别一看到 0 条就断定是 JS 渲染:没带 UA 被 403、跳转到了登录页、编码错了都会长成同样子,先按 01 章的四步分诊看状态码。
亲眼验证:用 httpx 抓 quotes.toscrape.com/js/,解析 div.quote 得 0 条,而同一份数据就躺在页面的 var data=[...] 脚本里;换成静态版 quotes.toscrape.com/ 则能解析到 10 条。先对比 view-source 与 Elements,再决定开不开浏览器。

开浏览器是重武器;页面数据几乎都来自某个 XHR/API,直接复刻那个请求拿 JSON,往往比渲染整页快一到两个数量级。

一次完整的逆向(全程)

  • ① 发现:无头浏览器打开练习站的无限滚动版 /scroll,挂一个 page.on("response") 监听——立刻抓到 https://quotes.toscrape.com/api/quotes?page=1(200)。DevTools 里过滤 Fetch/XHR 是同一件事,只是手动版;
  • ② 直连:把这个 URL 丢给 httpx 直接请求,拿到的 JSON 顶层字段是 has_next / page / quotes / tag / top_ten_tags,quotes 里 10 条,作者是嵌套对象;
  • ③ 翻页:?page=5 照样给 10 条,has_next 明确告诉你还有没有下一页——连「翻到哪一页停」都不用猜;
  • 结果:整章的无头浏览器、滚动、等待策略全都不需要了,一个 while has_next 循环搞定。

怎么找到那个接口

  • DevTools › Network › 过滤 Fetch/XHR → 触发一次翻页/滚动 → 看哪个请求的响应里有你要的字段;
  • 找到后 Copy as cURL,先把这条 curl 原样跑通,再逐个删头看哪些是必需的(03 章);
  • 接口自带的分页字段(has_next / total / cursor)比你解析 HTML 里的「下一页」按钮可靠得多。

难的部分:参数怎么来的

  • 时间戳、分页 cursor 好办;签名(sign / token / X-Sign)要读混淆 JS 定位生成函数(md5 / hmac / 自定义);
  • 先判断值不值得:签名逆向是这一页最贵的活儿,很多时候「开浏览器让它自己算」反而划算——用本章后面的做法,把浏览器当抓包器。

最小复现:走一遍这条链路

  • 用 httpx 直接抓 quotes.toscrape.com/api/quotes?page=1,打印顶层 keys 和 has_next;
  • 写个 while 循环顺着 has_next 翻到底,统计总条数——再和「用无头浏览器滚动到底再数」的耗时比一比,差距就是那一到两个数量级;
  • 再进一步:给这个循环加上 03 章的超时、重试与 0.3 秒礼貌间隔,它就已经是一个能用的小爬虫了。
# 一次真实的接口逆向:先监听发现,再直连复刻
# ① 发现(浏览器只用这一次,之后就不需要了)
page.on("response", lambda r: print(r.url, r.status)
        if "/api/" in r.url else None)
page.goto("https://quotes.toscrape.com/scroll")
# → https://quotes.toscrape.com/api/quotes?page=1 200

# ② 直连 + 顺着 has_next 翻页,全程无浏览器
import httpx
page_no, all_rows = 1, []
with httpx.Client(timeout=10) as c:
    while True:
        d = c.get("https://quotes.toscrape.com/api/quotes",
                  params={"page": page_no}).json()
        all_rows += d["quotes"]          # 每页 10 条,字段比 HTML 里更全
        if not d["has_next"]: break    # 接口自己告诉你翻完没
        page_no += 1
一上来就开 Selenium,慢又重,其实一个 API 就够;忽略 Token 有效期/一次性导致复刻失败。
判断一个站「值不值得开浏览器」只要三分钟:DevTools 过滤 XHR 翻一页,看有没有一个响应里直接躺着你要的字段。有——那这一章后面五张卡你今天都用不上;没有,再往下读。工具:DevTools XHR、mitmproxy(App 侧)、JS 反混淆(de4js / AST 还原)。

接口实在逆不动、或必须触发一连串真实交互时,才请出无头浏览器这门重炮。

选型

  • Playwright(首选):一套 API 通吃 Chromium/Firefox/WebKit,自带自动等待,同步异步双接口;
  • Puppeteer(Node,只 Chrome 系)、Selenium(最老牌,生态最全但 API 啰嗦、自动等待要自己写);
  • 装完除了 pip install playwright 还要 playwright install chromium 单独下浏览器(约 114 MB)——这一步漏了会报「Executable doesn't exist」。

能做什么

  • 导航、点击、输入、滚动、执行页面内 JS(evaluate);
  • 截图、生成 PDF、拦截与改写网络请求(本章后面单独一张卡);
  • headless=True 无界面跑(服务器上的常态),False 时能亲眼看它点——调试期非常值得开。

裸环境启动失败,八成不是你的代码

  • 在没装桌面依赖的 Linux / 容器里直接跑,报的是 error while loading shared libraries: libnspr4.so: cannot open shared object file,进程退出码 127,Playwright 再包一层 TargetClosedError: Target page, context or browser has been closed;
  • 看到 TargetClosedError 别怀疑自己的选择器——往下翻 Browser logs,真正的原因在最后几行;
  • 解法是补系统依赖:playwright install-deps(需要 root);没有 root 就手动装齐 libnss3 / libnspr4 / libgbm1 / libasound2 这一串。
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()
    page.goto("https://example.com", wait_until="networkidle")
    page.click("text=加载更多")
    html = page.content()
    browser.close()
浏览器方案资源重、慢、贵——能逆向就别开浏览器;忘了 browser.close() 会留下一堆 chrome 进程把内存吃光(长跑任务务必用 with 语句)。还有一条的「开箱即穿帮」:headless Chromium 的默认 UA 里明写着 HeadlessChrome/149.0.7827.55 和 X11; Linux x86_64,同时 navigator.webdriver 是 True——什么都不改就上真站点,等于举着牌子进门(06 章)。
选型:Playwright 一套 API 通吃 Chromium/Firefox/WebKit、自带自动等待,通常比 Selenium 省心;容器里跑 headless 需先补齐系统依赖(playwright install-deps),否则启动即报缺库。

动态页面的内容是「陆续」到位的,抓早了只拿到半成品——等待策略决定你收到的是完整数据还是残缺 DOM。

四个档位,越靠后越具体

  • wait_until="domcontentloaded":HTML 解析完就返回,XHR 还没回来;
  • wait_until="load":图片等子资源也加载完;
  • wait_until="networkidle":网络安静一段时间——但页面有长轮询/心跳时永远不会触发,会一直等到超时;
  • wait_for_selector("要的元素"):最推荐,等的是你真正关心的东西,与页面怎么加载无关。

差别有多大

  • 练习站的无限滚动版 /scroll:domcontentloaded 返回时 .quote 数是 0,wait_for_selector(".quote") 之后是 10——这就是「抓早了」的样子;
  • 但同一站的 /js/ 版,domcontentloaded 时就已经有 10 条了(它的脚本是同步内联的)。没有万能档位:等什么取决于这一页怎么加载数据,所以才推荐直接等元素。

无限滚动的收敛写法

  • 循环「滚到底 → 等一会儿 → 数一次」,数量不再增长就停。练习站上滚三次,.quote 数是 20 → 30 → 40,每次加载 10 条;
  • 一定要有两个终止条件:数量不变,以及最大轮数上限——只靠前者,遇到接口偶发失败就会提前收工;只靠后者,会在长页面上白跑。
page.wait_for_selector("div.item", state="visible")

# 无限滚动:滚到数量不再增长为止
prev = 0
while True:
    page.mouse.wheel(0, 3000)
    page.wait_for_timeout(800)
    count = page.locator("div.item").count()
    if count == prev:
        break
    prev = count
用死等 sleep(5) 将就,既慢又不可靠。
口诀:能等条件就别等时间。page.click() 这类操作 Playwright 已经自带自动等待(等元素可见可点),不用自己加 sleep;真正需要显式等的是「数据到位」这件事,而那正是 wait_for_selector 干的活。wait_for_timeout 只该出现在两个地方:滚动之间的缓冲,和给对方的礼貌间隔。

既然浏览器已替你把带签名的 XHR 发了出去,不如直接在它嘴边把 JSON 接走,省掉一层 DOM 解析。

监听 response:把浏览器当抓包器

  • page.on("response", ...) 挂一个回调,过滤 "/api/" in r.url,打开 /scroll 就收到了 api/quotes?page=1(200)——上一张接口逆向卡的「发现」步骤就是这么做的;
  • 这条路的价值在于签名难逆的站:让浏览器自己算签名、自己发请求,你只负责在返回处接住 JSON,比硬逆混淆 JS 省几个数量级的力气;
  • 注意 r.json() 在非 JSON 响应上会抛异常,回调里要先看 r.headers.get("content-type")。

拦截 request:提速 + 缩小指纹面

  • page.route("**/*.{png,jpg,woff2,css}", lambda route: route.abort()) 把图片字体样式全掐掉——页面越重省得越多;
  • 同一个 route 还能改写请求:加头、换 URL、甚至 route.fulfill() 直接返回一份假响应(做本地回放测试很好用,09 章);
  • 代价是:拦得太狠会让页面 JS 报错、或让站方发现你「不加载任何图片」——这本身也是一种行为特征。
data = []
# 直接抓浏览器发出的 XHR JSON,省去解析 DOM
page.on("response", lambda r: data.append(r.json())
        if "/api/list" in r.url else None)

# 屏蔽图片/字体加速、降低指纹面
page.route("**/*.{png,jpg,woff2}", lambda route: route.abort())
page.goto(url)
加载全部资源(图片/视频/追踪脚本),又慢又更易被指纹识别。
把无头浏览器当「带浏览器指纹的抓包器」:监听 response 事件直接收下它发出的 XHR JSON,省掉解析 DOM;再用 route 拦掉图片/字体/广告,既提速又缩小指纹面。

网页逆向不动时,App 往往走一套更松的私有接口——中间人代理能把手机的加密流量摊开给你看。

基本链路

  • 手机 WiFi 代理指向电脑上的 mitmproxy / Charles / Fiddler;
  • 在手机上装并信任该工具的 CA 证书,HTTPS 才解得开(Android 7 起用户证书默认不被 App 信任,还要额外处理);
  • 然后就能看到 App 的私有 API、参数与签名——它们常常比同一家的 Web 接口宽松得多。

会卡住的两个地方

  • 证书固定(SSL Pinning):App 只认自己内置的证书,中间人直接连不上——需要 Frida / objection 在运行时挂钩绕过;
  • 签名在 so 里:Java 层反编译(jadx)看不到,得逆 native 库,这已经是另一个专业方向了。

先想清楚再动手

  • 绕过证书固定、逆向 App 内部逻辑,法律与合规风险显著高于抓公开网页——很多地区把绕过技术措施单列为一类问题(10 章);
  • 这一整节按公开资料与通用行为写成,本机没有可供的 App 环境,读到具体报错文案请以你自己的工具版本为准。
# mitmproxy 脚本 dump.py:截获 App 私有接口
def response(flow):
    if "api.app.com" in flow.request.pretty_url:
        print(flow.request.url, flow.response.json())

# 运行: mitmdump -s dump.py
# 手机 WiFi 代理指向本机:8080,并安装 mitm CA 证书
遇到 SSL Pinning 直接放弃,或忽视其法律与合规风险。
逆向工具:Frida、objection、jadx(反编译 APK)。

不是所有接口都是普通 REST:GraphQL 要你构造 query、WebSocket 要你维持长连接,抓法与「GET 一个 JSON」截然不同。

GraphQL:POST 一个 query

  • 特征是 POST /graphql,请求体形如 {"query": "...", "variables": {...}}——一眼可辨;
  • 拿公开的 countries.trevorblades.com/graphql query($c:ID!){ country(code:$c){ name capital currency } } 加 {"c":"CN"},直接得到 {"data":{"country":{"capital":"Beijing","currency":"CNY","name":"China"}}}——只要你写的字段,一个不多;
  • 探索未知 schema 靠内省:{ __type(name:"Country"){ fields { name } } } 直接列出全部可选字段(capital、continent、currencies、languages…)。生产环境常关内省,但公开站点关的比你想的少。

GraphQL 的坑:错误不走状态码

  • 把字段名写错成 nope,HTTP 状态码仍然是 200,错误在响应体的 errors 数组里:Cannot query field "nope" on type "Country". Did you mean "code" or "name"?;
  • 意味着 raise_for_status() 在这里完全失效——必须显式检查 "errors" in resp.json(),否则又是一次 01 章说的静默失败;
  • 好消息是这个报错会给出拼写建议,摸索陌生 schema 时它比文档还好用。

WebSocket:连上去持续收帧

  • DevTools 里出现 WS 面板就是它,行情、聊天、直播弹幕的常客;
  • 连上后通常要先发一条订阅消息({"type":"subscribe", ...})才有数据推过来,具体格式只能从 DevTools 的帧里照抄;
  • 和 HTTP 不同,它是长连接:断线重连、心跳、消息去重都得自己处理,别指望重试装饰器。
import requests
query = "query($id:ID!){ product(id:$id){ name price } }"
r = requests.post("https://site.com/graphql",
    json={"query": query, "variables": {"id": "42"}})

# WebSocket 持续接收
import asyncio, json, websockets
async def tap():
    async with websockets.connect("wss://site.com/ws") as ws:
        await ws.send(json.dumps({"type": "subscribe", "ch": "ticker"}))
        async for msg in ws:
            print(msg)
把 GraphQL 当普通网页解析,而不是直接构造 query 请求。
判别:请求体是 {query, variables} 打到 /graphql,就按 GraphQL 只请求所需字段;DevTools 里出现 WS 面板则是 WebSocket,得连上去按协议持续收帧。落代码前先在接口/GraphiQL 里把 query 试通。

反爬对抗

三张表:检测向量总览(对方从哪些层面认出你)、指纹维度、应对手段与代价。理解检测才谈得上规避;这里没有银弹,只有持续维护。

被拦时先定位是哪一层露的馅——头、指纹、行为、IP,四层的对策完全不同,盲目换代理往往白花钱。

检测向量总览

层对方看什么典型表现
请求头缺 UA / Referer / Sec-Fetch-*,或组合不自然直接 403
TLS 指纹JA3 / JA4:密码套件与扩展的顺序头全对但还是 403
HTTP/2 指纹帧顺序、SETTINGS 值、伪头顺序同上,且换 UA 无效
浏览器指纹navigator.webdriver、Canvas / WebGL、字体、插件、时区与语言是否自洽无头浏览器被挑战
行为请求间隔过于规律、不加载静态资源、鼠标从不移动、深夜匀速跑跑一阵后突然被封
IP 与 ASN数据中心 IP 段、单 IP 高频、同段大量请求429 / 挑战页
账号与 Cookie新账号、无历史、Cookie 缺关键项登录后仍被限

应对手段与代价

手段解决哪一层代价
抄一整套真实请求头请求头层几乎为零,永远先做这个
curl_cffi(impersonate)TLS + HTTP/2 指纹低;性价比最高的一步
Playwright + 反检测补丁浏览器指纹内存与并发代价大
降速 + 抖动 + 并发上限行为层吞吐下降,但最持久
住宅 / 移动代理池IP 层贵;要做健康检查与轮换
打码 / 验证码服务人机验证贵、慢、且常踩服务条款(10 章)
登录态与账号池账号层风险最高:违反 ToS、账号会被封

限速与拟人化参数

项建议
请求间隔基础间隔 + 随机抖动(如 1–3 秒),别用固定值
并发单域名个位数起步;看对方的响应时间调整,变慢就退让
时段避开对方业务高峰;7×24 匀速是最明显的机器特征之一
失败反应连续 429 / 403 就指数退避并降速,而不是立刻换 IP 重试
资源加载只抓 HTML 不碰任何静态资源,也是一种特征
这是一场持续的军备竞赛,没有一劳永逸的配置。今天能用的 UA 组合、指纹库版本、代理池,几个月后都可能失效——所以把这些做成可配置、可监控的东西:成功率、状态码分布、挑战页比例接进监控(09 章),掉下去就知道该更新了。反过来,如果你发现自己需要越来越复杂的对抗手段,那通常是对方在明确表达「不希望你抓」——该回到 10 章重新评估这件事该不该做。
排查顺序:先补头 → 再换指纹库(curl_cffi)→ 再降速 → 最后才动 IP。每一步都用 curl 或最小脚本验证,一次只改一个变量。同时改三样,成功了你也不知道是哪样起了作用,下次站点一升级又得从头猜。

反爬不是单点判断,而是行为、身份、指纹、陷阱四层叠加打分——只堵一层,另外三层照样把你捞出来。

四层各自在看什么

层看的东西你能做的难度
行为频率、时序是否过于规律、有无鼠标与滚动轨迹限速、随机化间隔、控制总量低(也最该先做)
身份IP 信誉、UA、缺头或头顺序错、Cookie 缺失整套复刻浏览器头、维护会话低
指纹TLS(JA3/JA4)、HTTP/2 帧序、canvas/WebGL/字体/webdriver换库(curl_cffi)或用真浏览器高,换头无效
陷阱蜜罐链接、隐藏表单字段、投毒的假数据跳过不可见元素、抽样核对数据真假中,最容易忽略

四层是「叠加打分」不是「逐个通关」

  • 没有哪一层单独判你死刑,是分数累积到阈值才动手——这解释了为什么「昨天还好好的,今天忽然全 403」:你只是慢慢逼近了那条线;
  • 也解释了为什么降频常常比换指纹更管用:行为层的分数最容易被自己压下去,而且不需要任何工具。

蜜罐:跟进一个看不见的链接就等于自报家门

  • 页面里塞一个 display:none 或 aria-hidden 的 <a>,真人永远点不到,只有解析 HTML 的程序会跟进去;
  • 所以跟链接前先过滤:跳过带 display:none / visibility:hidden / aria-hidden="true" 的节点,右边就是最小实现。
# 蜜罐链接:隐藏 a 标签,真人看不到、爬虫却会跟进 → 一跟就暴露
for a in soup.select("a[href]"):
    style = a.get("style", "").replace(" ", "")
    if "display:none" in style or a.get("aria-hidden") == "true":
        continue          # 跳过蜜罐,别跟进
    follow(a["href"])
以为换个 UA 就万事大吉,忽略指纹层与行为层。
亲眼验证「头确实发出去了」:给 httpbingo.org/headers 或 httpbin.org/headers 发一个自定义 UA,回显里能看到它原样送达——但这只证明你过了身份层。指纹层与行为层各自独立判定(本章后面有数据),别指望换个 UA 就通关。

头可以随手改,但 TLS 握手、HTTP/2 帧序、浏览器渲染这些底层特征是你所用工具「天生」的,对不上就穿帮。

换 UA 改不了 TLS 指纹

  • 拿 tls.peet.ws/api/all 回显自己的握手特征:httpx 默认请求的 JA3 哈希,与带上一整条 Chrome UA 的请求的 JA3 哈希,一模一样;
  • 换成 curl_cffi 的 impersonate="chrome",JA3 哈希立刻变成另一串,JA4 也从 t13d3013h1_… 变成 t13d1516h2_…——注意结尾那个 h1 / h2,它直接把你走的 HTTP 版本写进了指纹里;
  • 顺带一提,curl_cffi 连 UA 都替你换成了对应浏览器的真 UA,不用自己拼。

三层指纹分别是什么

  • TLS(JA3 / JA4):ClientHello 里密码套件、扩展、曲线的取值与顺序做哈希——每个库、每个 OpenSSL 版本都有自己稳定的一串;
  • HTTP/2:SETTINGS 帧的值、伪头顺序、优先级树(Akamai 常用);
  • 浏览器:navigator.webdriver、canvas/WebGL 渲染差异、字体列表、屏幕尺寸、时区、语言。

自洽是硬要求

  • UA 说 Chrome、TLS 指纹是 Python、时区是 UTC、语言是 en-US、代理出口在别的国家——每一处不一致本身就是特征;
  • 具体的哈希值会随库版本变(我的那两串就只对当时那个版本成立),别把它们当常量记;该记的是方法:请求一次自检站点,把两种客户端的结果并排看。

最小复现:把自己的指纹调出来看

  • 用 httpx 请求一次 https://tls.peet.ws/api/all,把返回的 JSON 存成 a.json;再带上一整条 Chrome 的 User-Agent 请求一次存成 b.json。diff 一下——除了回显的 UA 字段,ja3_hash 应该一个字节都没变;
  • 装 curl_cffi(pip install curl_cffi),用 requests.get(url, impersonate="chrome") 再存一份 c.json。这次 ja3_hash、ja4、http_version 会一起变,连 UA 都自动换成了真 Chrome 的;
  • 三份文件并排一看就清楚:「换个 UA 就能过」是不成立的。
# 请求层:curl_cffi 一次对齐 TLS + HTTP/2 指纹
from curl_cffi import requests
r = requests.get(url, impersonate="chrome124")

# 浏览器层:抹掉自动化痕迹 navigator.webdriver
page.add_init_script(
    "Object.defineProperty(navigator,'webdriver',{get:()=>undefined})")
自动化痕迹 navigator.webdriver=true 没抹掉,一测即穿。
自检站点:tls.peet.ws/api/all(TLS + HTTP/2 指纹,返回 JSON 好比对)、bot.sannysoft.com(浏览器侧的一整屏检测项)。用同一套代码分别以 httpx 和 curl_cffi 请求前者,把两份 JSON 存下来 diff 一遍,比读十篇文章都清楚。

挡在你和数据之间的往往不是站点自己,而是 Cloudflare、Akamai 这类专业防护——先认出是哪一家,才谈得上评估可行性与合规路径。

怎么认出是哪一家

  • 看响应头:server: cloudflare、cf-ray;Akamai 的 x-akamai-*;
  • 看Cookie 名:cf_clearance(Cloudflare)、datadome、_px*(PerimeterX/HUMAN);
  • 看挑战页长相:Cloudflare 的等待页 / Turnstile 勾选框最好认;
  • 浏览器插件 Wappalyzer 能直接报出站点技术栈与 WAF。

认出来之后的判断

  • 不同厂商侧重点不同:Cloudflare 偏挑战 + Cookie,Akamai 偏 HTTP/2 与行为,DataDome / Kasada 偏浏览器指纹与设备信号;
  • 但更该做的判断是「值不值得」:站点花钱买了专业防护,本身就是一次明确的意愿表达。此时优先考虑官方 API、数据合作、商业抓取服务,把对抗成本外包出去(10 章的合规视角)。
import requests
r = requests.get(url)

server = r.headers.get("server", "").lower()
if "cloudflare" in server or "cf-mitigated" in r.headers:
    ...   # Cloudflare:可能需要 cf_clearance 挑战
elif "akamaighost" in server:
    ...   # Akamai:重点对齐 HTTP/2 指纹与行为
用打 A 家的方法硬套 B 家,南辕北辙——先花十分钟认出对手是谁,比盲试十种绕过手段有用得多。另一个常见误判是把站点自研的简单风控当成大厂防护,于是直接上重武器:先按 01 章的分诊看看是不是仅仅缺了个 UA。
Wappalyzer 可识别站点技术栈与所用 WAF。

同一个 IP 高频扫站是最刺眼的信号;代理的意义不在「换 IP」本身,而在让流量看起来既分散又内部一致。

三类 IP 的取舍

类型成本被封难度适用
数据中心最低最容易被识别(整段 ASN 都是机房)宽松站点、内部系统
住宅高像真人,风控成本高有风控的公开站点
移动最高最像(运营商 NAT 后一堆真人共用)移动端接口

轮换不是越勤越好

  • 每请求换:适合无状态接口;一旦涉及登录态或挑战 Cookie,换 IP 就等于把票据作废(02 章的 cf_clearance 与 IP 绑定);
  • 粘性会话(sticky session):同一会话固定出口 IP,几分钟内不变——有登录态时必须用这个;
  • 按域名维护独立池:把在 A 站烧掉的 IP 拿去抓 B 站,只会让两边一起变差。

自洽比数量重要

  • 出口 IP 在德国、Accept-Language 是 zh-CN、浏览器时区是 UTC+8——三者对不上,这个组合本身就比单一 IP 高频更扎眼;
  • 前提永远是:已获授权、遵守目标 robots 与 ToS、并严格控制频率。代理让你更难被发现,不会让你更合规。
import random, httpx

POOL = ["http://u:p@gw1:7777", "http://u:p@gw2:7777"]
def get(url):
    p = random.choice(POOL)
    return httpx.get(url, proxy=p, timeout=15)

# 住宅粘性会话:多数服务把 session id 附在用户名上
# http://user-session-abc123:pass@gateway:7777  → 同一出口 IP 保持一段时间
用一堆数据中心 IP 硬抗住宅级风控,秒被识别;代理地区与 Accept-Language/时区不一致,露馅。
前提永远是:已获授权、遵守目标 robots 与 ToS、并严格控制频率。技术上,代理出口的地理位置要与 Accept-Language、时区自洽,三者对不上,不一致本身就是最扎眼的破绽。

把目标站压垮既不道德也最招封——控制节奏是对抗风控的第一课,更是最基本的工程礼貌。

先把自己这端管好

  • 随机化间隔:固定 1.0 秒的间隔在日志里是一条完美直线,比快更显眼;抖动到 0.5~2 秒就自然得多;
  • 尊重服务端的明示:Retry-After(429/503 时)、Crawl-delay(robots 里,07 章protego 能直接读出来);
  • 控制总量与时段:错峰抓、分几天抓,比一夜抓完安全得多,对方的带宽账单也好看。

浏览器方案的「拟人」

  • 真实鼠标移动轨迹、滚动的加速与减速、输入时的击键间隔——行为层看的就是这些;
  • 但别本末倒置:大多数被封是因为频率,不是因为没模拟鼠标。先把速率降下来,再谈轨迹。

收到 429 的正确反应

  • 立刻退避(按 Retry-After,没有就指数退避加抖动),并把并发调低——很多人只重试不降速,于是稳定地一直撞墙;
  • 连续 429 说明当前策略已经越线,该改的是配置不是重试次数(03 章)。
import random, time

time.sleep(random.uniform(1.5, 4.0))   # 随机间隔,别整齐划一

r = requests.get(url)
if r.status_code == 429:                 # 尊重服务端限速
    time.sleep(int(r.headers.get("Retry-After", 60)))
为了快把并发和频率拉满——被封的头号原因。
守好自己这一端:随机化间隔、遵守 Retry-After 与 Crawl-delay、限制并发与总量。收到 429 / 503 就按 Retry-After 退避,绝不硬冲——把目标站压垮既违规又必被封。

自动化框架会留下一串「机器人指纹」,反检测工具就是来抹这些痕迹的——但工具会过时,需持续更新。

开箱即穿帮的两处

  • headless Chromium 的默认 UA 里明写着 HeadlessChrome/149.0.7827.55,还带 X11; Linux x86_64——什么都不改就上真站点,等于举着牌子进门;
  • navigator.webdriver 是 True,一行 JS 就能查到;
  • 这两处是最低门槛,抹掉它们只说明你「不是完全裸奔」,离过检测还远。

两条战线

  • 浏览器层:playwright-stealth、puppeteer-extra-stealth、undetected-chromedriver、patchright、camoufox——抹 webdriver、canvas、WebGL、插件列表、语言等泄露点;
  • 请求层:curl_cffi、tls-client 冒充真实浏览器的 TLS 与 HTTP/2 指纹(上面指纹卡有对比);
  • 两条战线互不替代:请求层再像,页面里的 canvas 指纹也不会因此对上;浏览器层再干净,也改不了它自己的 TLS 栈。

没有银弹,而且这话不是套话

  • 这些工具是军备竞赛的产物,检测方每升级一次就有一批失效——你抄到的任何一段「稳定绕过」代码都有保质期;
  • 动手前先确认目标站的 robots 与 ToS 是否允许自动化访问:「技术上能过检测」从不等于「获得了许可」(10 章)。
# 最省事的抗检测浏览器
import undetected_chromedriver as uc
driver = uc.Chrome(headless=False)
driver.get(url)

# Playwright + stealth 补丁
from playwright_stealth import stealth_sync
stealth_sync(page)
以为装个 stealth 插件就无敌,实则不断被新检测穿透。
这些工具都是文档级的军备竞赛产物,会随检测升级而失效,没有一劳永逸;动手前先确认目标站的 robots 与 ToS 是否允许自动化访问——「技术上能过检测」从不等于「获得了许可」。

验证码是站点在明确表达「这里不欢迎自动化」——遇到它,第一反应该是退一步,而不是硬闯。

先分清你遇到的是哪一种

  • 题目型:图形、滑块、点选——需要有人或有模型去解;
  • 打分型:reCAPTCHA v3、Cloudflare Turnstile 大多数时候不出题,它在后台按你的整体行为可信度打分;
  • 后者意味着「解题」这条路根本不存在:分数低的原因在你之前的所有请求里,不在这个框里。

优先级:不触发 > 复用会话 > 打码

  • 不触发:降频、换更干净的 IP、把指纹与行为理顺——大多数验证码是被前面的行为招来的;
  • 复用会话:人工过一次挑战,把有效 Cookie 存下来复用(注意它常与 IP、UA 绑定,02 章);
  • 打码服务:2captcha、anti-captcha、CapSolver——有金钱成本和几秒到几十秒的延迟,规模化时这两项都很可观。

把它当信号读

  • 连续撞验证码,说明当前这条路已经被明确关上了。此时该重新评估:有没有官方 API、有没有数据授权渠道、这份数据是不是非要不可(10 章);
  • 硬闯的成本曲线是指数的,而合规渠道往往只是一封邮件。
from twocaptcha import TwoCaptcha

solver = TwoCaptcha("YOUR_API_KEY")
res = solver.recaptcha(sitekey="6Le-...", url=page_url)
token = res["code"]   # 回填到表单字段 g-recaptcha-response 再提交
一上来就想「怎么破验证码」,而不先想「怎么不触发」。
优先级口诀:先想怎么不触发(合规访问、降低频率、维持正常会话),其次复用已有效的人工会话;把验证码读作「此路对自动化不通、该走官方渠道」的信号,而不是要攻坚的目标。

爬虫不是写完就了事的脚本,而是要随站点改版持续维护的活系统;抱着「一次搞定」的幻想,迟早出错。

把它当运维,不当解谜

  • 站点会改版、反爬会升级、你抄来的绕过手法会失效——这不是意外,是常态;
  • 所以真正的能力不是「今天绕过了」,而是「明天它坏了,我十分钟内知道,一小时内修好」——监控封禁率、成功率、字段空值率(09 章);
  • 把选择器、请求头、代理配置都做成配置而非硬编码,改版时改的是数据不是代码。

算一算投入产出

  • 逆一个签名可能要三天,官方 API 可能只要一封邮件;商业抓取服务按条计费,很多时候比你的人力便宜;
  • 还有一个常被忽略的选项:降低诉求——很多需求其实不需要全量实时数据,每天一次的抽样就够;
  • 「技术上能不能做到」和「该不该做、值不值得做」是三个不同的问题,后两个更重要(10 章)。
把对抗当成唯一解,投入越滚越大却算不清成本收益——很多时候官方 API、数据合作、甚至直接降低诉求,都比无休止的军备竞赛更划算也更合规。
把「一次性绕过」当永久方案,几周后整条链路失效——用监控 + 快速迭代替代幻想中的银弹。

规模化与架构

三张表:Scrapy 组件与命令、去重与调度方案对照、增量抓取的三种依据。从一个脚本到能跑百万级 URL 的系统,别重复造调度、去重、管道这三个轮子。

规模化要解决四件事:URL 从哪来(调度)、去重、限速、落库。Scrapy 把这四件都做好了。

Scrapy 组件与命令

组件 / 命令作用
scrapy startproject / genspider建项目 / 建爬虫
scrapy crawl 名字 -O out.jsonl跑并导出(-O 覆盖、-o 追加)
scrapy shell URL交互式调选择器——写 spider 前先在这试
scrapy view URL / parse URL看 Scrapy 眼中的页面 / 单页跑一遍回调
Spider起始 URL 与解析回调:yield 出 item 或新 Request
Item / ItemLoader字段容器与清洗管线
Pipeline清洗 → 校验 → 落库,一个 item 顺序流过(08 章)
中间件下载器中间件改请求(代理、UA、重试);爬虫中间件改回调流
Feed 导出内置 JSONL / CSV / Parquet 导出

常用设置(settings.py)

设置作用
CONCURRENT_REQUESTS_PER_DOMAIN单域名并发——真正影响对方压力的是它
DOWNLOAD_DELAY + RANDOMIZE_DOWNLOAD_DELAY间隔与抖动
AUTOTHROTTLE_ENABLED按对方响应时间自动调速,最省心的礼貌开关
ROBOTSTXT_OBEY = True遵守 robots;startproject 生成的模板里是 True,Scrapy 自身的默认值是 False——手写 settings 时别忘了加(10 章)
RETRY_TIMES / RETRY_HTTP_CODES重试次数与状态码
DOWNLOAD_TIMEOUT超时
HTTPCACHE_ENABLED开发期强烈建议开:改选择器时不必反复打对方站
JOBDIR=crawls/x断点续爬:把队列与去重指纹落盘
DEPTH_LIMIT / CLOSESPIDER_ITEMCOUNT限深 / 到量自动停

去重与调度

方案适用代价
内存 set十万级以内进程一停就没
Scrapy 自带指纹 + JOBDIR单机、要续爬够用
Redis set(Scrapy-Redis)多机共享队列与去重要维护 Redis;内存随 URL 数线性涨
布隆过滤器亿级 URL有假阳性(会漏抓),换来极省内存
数据库唯一索引以「业务 ID」去重最可靠,落库时兜底
URL 规范化去重前必做排序参数、去掉跟踪参数(utm_*)、统一大小写与结尾斜杠
优先级队列先抓列表页再抓详情页Request(priority=…)

增量抓取:三种依据

依据怎么用
ETag / Last-Modified请求带 If-None-Match / If-Modified-Since,没变返回 304,几乎不耗流量
sitemap.xml 的 <lastmod>先扫 sitemap 找出变过的页,再定向抓
内容哈希对正文算 hash 存下来,一样就跳过——对付那些不给缓存头的站
列表页时间戳按发布时间翻页,翻到已抓过的就停
「并发数」和「对方感受到的压力」不是一回事:16 个并发 × 每秒 1 次 = 每秒 16 次请求,很多中小站点扛不住。Scrapy 里真正该调的是 CONCURRENT_REQUESTS_PER_DOMAIN 与 DOWNLOAD_DELAY,而不是全局并发数。更省心的做法是直接开 AUTOTHROTTLE:对方变慢它就退让,既礼貌又能避开大多数限速触发。
开发期一定要开 HTTP 缓存(Scrapy 的 HTTPCACHE_ENABLED,或脚本用 requests-cache):调选择器要反复跑十几遍,每次都真打对方站,既慢又容易被封。缓存开了之后,改代码的迭代速度是数量级的差别。

当抓取从几十个 URL 长到几十万,调度、去重、重试、并发这些通用机制自己糊会越糊越乱,框架把它们做成了开箱即用的骨架。

Scrapy 替你做了什么

  • 调度器 + 去重指纹 + 重试 + 并发控制 + 中间件 + 管道 + 导出,一整套;
  • 跑一个三十行的 spider(Scrapy 2.17)抓练习站三页得 30 条并直接导出 JSON——分页跟进只要一句 response.follow(next_href, self.parse);
  • 它的默认重试策略也是现成的:RETRY_TIMES=2、RETRY_HTTP_CODES=[500,502,503,504,522,524,408,429]——403 不在里面,与 03 章「403 别重试」的判断一致。

框架默认值和项目模板不是一回事

  • 框架内建默认:ROBOTSTXT_OBEY=False、CONCURRENT_REQUESTS_PER_DOMAIN=8、DOWNLOAD_DELAY=0、AutoThrottle 关闭、UA 是 Scrapy/2.17.0 (+https://scrapy.org);
  • 而 scrapy startproject 生成的 settings.py 里写的是 ROBOTSTXT_OBEY=True、CONCURRENT_REQUESTS_PER_DOMAIN=1、DOWNLOAD_DELAY=1;
  • 所以「Scrapy 默认遵守 robots」只在用项目模板时成立——直接用 CrawlerProcess 起爬虫时它不遵守,也不会提醒你。默认 UA 同样自报家门,两件事都要自己接管。

别为跑一次的活儿背整套框架

  • 一次性小抓:httpx + 解析器足矣;
  • 要长期维护、要去重与限速管线:Scrapy(Python 事实标准);
  • 抓动态站:Crawlee(JS/Py,集成 Playwright);Go 侧高性能选 Colly;不想管服务器就上 Apify / Scrapy Cloud。
import scrapy

class QuotesSpider(scrapy.Spider):
    name = "quotes"
    start_urls = ["https://quotes.toscrape.com/"]

    def parse(self, response):
        for q in response.css("div.quote"):
            yield {"text": q.css("span.text::text").get(),
                   "author": q.css("small.author::text").get()}
        nxt = response.css("li.next a::attr(href)").get()
        if nxt:
            yield response.follow(nxt, self.parse)   # 自动翻页
用一堆散脚本硬扛大规模,调度/去重/重试全靠手糊。
选型口诀:一次性小抓 requests + 解析器足矣;要长期维护、要并发与去重管线,直接上 Scrapy(Python 事实标准,调度/中间件/管道/去重一体);抓动态站优先 Playwright/Crawlee 这类带浏览器的框架。别为跑一次的活儿背整套框架。

前沿就是那个「接下来抓哪个」的待办队列,它决定了爬虫的覆盖面、抓取优先级,也决定了它会不会跑飞抓遍全网。

三件必须显式设定的事

  • 范围:域名白名单 + 路径前缀 + 深度上限。少了任何一条,一个「相关链接」就能把你带出站;
  • 优先级:列表页优先于详情页(先把 URL 铺开)、还是详情页优先(先出数据)——取决于你要的是覆盖还是时效;
  • 入队即规范化:先 canonicalize 再算去重键,否则同一页会以好几种写法混进队列。

广度还是深度

  • BFS(队列)适合「抓遍一个站」:层层铺开,早期就能覆盖大部分列表页;
  • DFS(栈)适合「顺着一条线挖到底」:内存占用小,但容易在某个分支里绕不出来;
  • 实际工程里多是优先级队列:给 URL 打分(页面类型、深度、更新频率)后排序,两者的折中。
# Scrapy:给关键请求更高优先级,先抓
yield scrapy.Request(url, priority=10, callback=self.parse)

# URL 规范化:参数排序 + 去锚点,避免同一页当成多个
from w3lib.url import canonicalize_url
canonicalize_url("https://a.com/p?b=2&a=1#x")  # 'https://a.com/p?a=1&b=2'
无深度/范围限制,爬虫跑飞抓遍全网;URL 未规范化,同一页被当成多个。
入队前先把 URL 规范化再算去重键,否则同一页会被当成好几个:w3lib.url.canonicalize_url 会把 ?b=2&a=1#frag 归一成 ?a=1&b=2(排序参数、丢掉 fragment),但它不会合并 /./、/../ 路径段,也不会删 utm_source 这类跟踪参数——这两样得自己补,否则同一页照样进来两次。

去重是规模化的命门,没有它,链接互相指来指去,爬虫会一遍遍抓同一批页面、永远跑不完。

两种去重,别混为一谈

  • URL 去重:规范化后哈希,防止重复入队——本章前面讲的就是它;
  • 内容去重:不同 URL 指向同一份正文(镜像站、带参数的同一页),要靠正文指纹或 simhash 判近似重复;
  • 只做前者,你仍会把同一篇文章存三遍。

布隆过滤器的账(可自己算,与机器无关)

  • 按 1% 误判率,m = -n·ln p / (ln2)²、k = (m/n)·ln2:10 万条 算得 m≈95.9 万位(约 117 KB)、k≈6.6(取 7),合每条 9.6 bit;
  • 1000 万条同样 1% 误判率只要约 11.4 MB——而同样数量的 URL 塞进 Python 的 set,光字符串本身就是几个 GB;
  • 代价是假阳性:它可能说「见过」而其实没见过,于是漏抓一条;反过来绝不会漏报。要 100% 精确就用 Redis Set 或数据库唯一约束。

指纹怎么算

  • md5 / sha1 的十六进制串分别是 32 / 40 字符;抓取场景不涉及安全,选快的就行;
  • Scrapy 的请求指纹对 query 参数顺序不敏感(?a=1&b=2 与 ?b=2&a=1 同一指纹),但它算的是「请求」不只是 URL——方法、body 不同就是不同请求。
# 内存吃紧时用布隆过滤器(省内存、极小误判)
from pybloom_live import ScalableBloomFilter
seen = ScalableBloomFilter(error_rate=0.001)
if url not in seen:
    seen.add(url); crawl(url)

# 持久 / 分布式去重:Redis SET,sadd 返回 1 即为新 URL
if redis.sadd("seen:urls", url):
    crawl(url)
用内存 set 存千万级 URL,内存直接爆炸。
千万级 URL 别塞进内存 set:布隆过滤器按 1% 误判率算下来约 10 bit/URL(手写版存 10 万条 m≈96 万位、k=7、误判率 0.99%),一千万 URL 也就十几 MB;要持久化就用 Redis 的 Set 或 Bloom 模块。指纹用 md5/sha1(十六进制串各 32/40 字符)。

单机吞吐见顶后靠多 worker 横向扩,难点就从「怎么抓」变成了「多台机器之间怎么共享队列、去重和状态」。

要共享的三样东西

  • 队列:所有 worker 从同一个前沿取任务,否则各抓各的、互相重复;
  • 去重指纹:必须集中存(Redis Set / Bloom 模块),本地 set 在分布式下等于没有;
  • 限速配额:这一条最容易漏——每域名的速率必须是全局的,10 个 worker 各自「限速 1 QPS」,对方看到的是 10 QPS。

怎么搭

  • 最短路径是 Scrapy-Redis:把 frontier 与去重指纹都换成 Redis,多 worker 天然协作,改的只是几行 settings;
  • 规模再大、要解耦生产与消费时才上 Kafka / RabbitMQ;
  • 无论哪种,任务都要幂等:worker 随时可能挂,同一个 URL 被抓两次不能污染数据(靠存储层的唯一约束兜底,08 章)。

先问一句:真的需要吗

  • 单机异步几百并发已经能吃满大多数目标站的容忍度——瓶颈通常在对方允许你多快,不在你的机器;
  • 分布式的正当理由通常是「抓很多个不同的站」或「要多地出口 IP」,而不是「抓一个站抓得更快」。后者只会更快被封。
# Scrapy-Redis:几行配置让多台机器共享队列 + 去重
# settings.py
SCHEDULER = "scrapy_redis.scheduler.Scheduler"
DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"
SCHEDULER_PERSIST = True
REDIS_URL = "redis://queue-host:6379"
# 多台机器跑同一个 spider,自动从 Redis 抢任务
多机并发把单个目标站压垮,分布式反而更快被封。
共享队列 + 共享去重是分布式的地基:Scrapy-Redis 把 frontier 与去重指纹都放进 Redis,多 worker 从同一队列取任务、天然不重复;量大到需要解耦生产与消费再上 Kafka/RabbitMQ。务必把「每域名总速率」做成全局限制,而不是每个 worker 各自限速。

礼貌不只是道德,它直接决定你会不会被封、会不会被投诉,是规模化能不能长期稳定跑下去的前提。

robots.txt 怎么读

  • 用库别手写正则:Protego.parse(txt) 之后 can_fetch(url, ua) 能正确处理按 UA 分组——同一份规则里 User-agent: * 允许的路径,被单独 Disallow: / 的 BadBot 一条都抓不到;
  • crawl_delay(ua) 直接读出 Crawl-delay: 5 的 5.0 秒;对没有匹配规则的 UA 返回 None;
  • 标准库 urllib.robotparser 也能用,但对通配符、Allow 优先级这些细节的处理不如 protego 贴近现代实现(Scrapy 内部用的就是它)。

sitemap.xml 是被低估的入口

  • 站方主动列出的 URL 清单,常带 lastmod(最后修改时间)——比盲爬省一大截,还能直接用于增量(见本章后面);
  • 位置通常在 /sitemap.xml,也可能写在 robots.txt 的 Sitemap: 行里;大站会分片成 sitemap index。

robots 返回 404 怎么办

  • 练习站 /robots.txt 返回 404——按惯例视为「没有限制」,但这只是站方没有表态,不是授权;
  • 服务条款、限流礼貌、以及 10 章那些法律边界,一条都没因此消失。设一个可识别的 UA 和联系方式,出问题时对方能找到你,往往比封禁更好收场。
import urllib.robotparser as rp

r = rp.RobotFileParser()
r.set_url("https://site.com/robots.txt"); r.read()
r.can_fetch("MyBot", "https://site.com/page")  # True/False
r.crawl_delay("MyBot")                        # 站方建议的抓取间隔

# Scrapy:ROBOTSTXT_OBEY = True + AUTOTHROTTLE_ENABLED = True
无视 robots 与限流,既不道德也更快触发封禁。
用库解析 robots 别手写正则:protego 的 Protego.parse(...).can_fetch(url, ua) 能正确处理 Disallow/Allow 与按 UA 分组,crawl_delay(ua) 直接读出 Crawl-delay 值;再配合 sitemap.xml 精准发现 URL,比盲爬省一大截。

全网大部分页面在两次抓取之间根本没变,只取变化的那部分,既省带宽又更不显眼、更不容易触发风控。

让服务器告诉你「没变」

  • 响应里有 ETag 就带 If-None-Match 再请求,有 Last-Modified 就带 If-Modified-Since;
  • 命中时服务端回 304 且响应体是 0 字节(原本 314 字节的响应,条件请求后 304 + 0 字节)——几乎零成本;
  • 但别指望所有站都给:练习站首页既没有 ETag 也没有 Last-Modified,这种情况相当普遍。

服务器不给,就自己比

  • 存正文的哈希,抓回来先比指纹再决定要不要解析入库——注意要对清洗后的正文算,否则页面里的时间戳、随机广告位会让每次都「变了」;
  • 更省的做法是用 sitemap 的 lastmod 先筛一遍,只对声称变了的页面发请求。

重爬频率分层

  • 热门/高频更新的页每天抓,冷门的每周或每月——用一个「上次变更时间」驱动的调度,比一刀切的全量重爬省一个数量级;
  • 这也是最有效的「降低被封概率」的手段之一:请求量本身就少了。
headers = {}
if last_etag:
    headers["If-None-Match"] = last_etag   # 带上上次的 ETag

r = requests.get(url, headers=headers)
if r.status_code == 304:
    pass                              # 未变,跳过省带宽
else:
    save(r.text)
    last_etag = r.headers.get("ETag")
每轮都全量重爬,浪费带宽也更显眼、更易触发风控。
优先让服务器告诉你「没变」:请求带上 If-None-Match(配 ETag)或 If-Modified-Since(配 Last-Modified),命中就回 304 空响应、几乎零成本;服务器不给这些头时,再退到「正文指纹比对」判断内容是否更新。

数据处理与存储

三张表:清洗工具、Schema 校验、存储格式对照。抓下来只是开始——能用的干净数据才是产出。

数据落盘前要过三关:洗干净、校验住、存对格式。任何一关省掉,下游都得替你补。

清洗与规范化

问题工具 / 写法
乱码(已损坏的编码)ftfy —— 修「锟斤拷」这类二次编码错误
全角半角、Unicode 变体unicodedata.normalize("NFKC", s)
多余空白与不换行空格" ".join(s.split())(顺带处理 \xa0)
HTML 实体与残留标签html.unescape;正文抽取用 trafilatura
各式日期字符串dateparser(支持中文相对时间);结果统一存 UTC ISO 8601
金额与数字剥掉千分位与货币符号;钱用 Decimal,别用 float
相对链接urljoin(base, href)(02 章)
字段缺失区分「没有这个字段」与「值为空」——前者存 null,后者存 ""

Schema:把校验前移

写法作用
class Item(BaseModel)(pydantic)类型 + 约束一起声明,不合格当场报错
Field(min_length=1) / @field_validator必填与自定义规则
TypeAdapter(list[Item])批量校验一整批
入库前校验 vs 入库后前置:脏数据进不去;后置只能靠人工发现
版本化Schema 会随站点改版演进,字段加了就记一笔版本号

存储格式对照

格式适合要点
JSONL抓取过程中的落盘一行一条、可追加、断了能续——爬虫的默认选择
CSV给人看 / 交付要 newline="" + utf-8-sig(Excel 才不乱码);嵌套结构存不了
Parquet后续分析列存、压缩率高、类型明确;批量写,别一条一写
SQLite中小规模 + 要查询单文件、零部署、唯一索引天然做去重
PostgreSQL长期、并发写、要关系ON CONFLICT DO UPDATE 做幂等 upsert
对象存储 + 分区目录海量原始页面原始 HTML 值得留一份:解析逻辑改了能重跑,不用重抓
抓取与解析耦合在一起,是这一章最贵的设计错误:一旦解析逻辑有 bug,几十万条数据要重抓。同理,边抓边直接写生产库也很危险——脏数据、重复数据、半截数据会直接污染下游。推荐的流水线是「抓 → 落原始 → 解析 → 校验 → 入库」,每一步可单独重跑。
把「原始响应」与「解析结果」分开存:原始 HTML / JSON 压缩后按日期分区留档,解析结果另存结构化表。这样站点改版或选择器写错时,重跑解析即可,不必重新抓一遍——重抓的代价是时间、是封禁风险,而存储很便宜。

网页里的文本天生脏,藏着全角半角、不可见空白、组合字符和编码残留,不先规整,后面分析步步踩雷。

四类脏,四种治法

  • 全角 / 连字:unicodedata.normalize("NFKC", s) 把 ABC123 折成 ABC123、连字 fi 拆成 fi;
  • 看不见的重复:「é」有组合式(1 个码点)和分解式(2 个码点)两种写法,肉眼一模一样但 == 为 False——先 NFC 归一才相等,不做这步去重会漏;
  • 编码残留(mojibake):ftfy.fix_text("schön") 还原成 schön;
  • 空白:re.sub(r"\s+", " ", s).strip()——网页里的不间断空格 \xa0 与全角空格都算 Python 的空白(\s、strip()、split() 全认),所以这一行就够用;真正的坑是不归一时它与普通空格肉眼无异:"a b" == "a\xa0b" 为 False,而 NFKC 会把它折成普通空格。

把「一坨字符串」变成有类型的值

  • dateparser.parse("3 天前") 返回一个真正的 datetime(按当前时间算),中英文相对时间都吃;
  • price_parser.Price.fromstring("¥1,299.00") 给 1299.0 且能取到货币符号 ¥;连「特价 99 元起」也能抠出 99.0;
  • 这一步越早做越好——把「1,299 元」当字符串存进库,后面每次分析都要重新解析一遍。

清洗要可复现

  • 原始 HTML 单独留档(存储卡里讲),清洗规则写成函数并配单元测试;
  • 否则某天发现价格解析错了,你只有清洗后的结果,没法回头重跑。
import re
from ftfy import fix_text
import dateparser
from price_parser import Price

fix_text("schön")                     # 修复 mojibake → 'schön'
dateparser.parse("3 天前")               # → datetime 对象
Price.fromstring("¥1,299.00").amount_float  # 1299.0
re.sub(r"\s+", " ", raw).strip()       # 压缩空白
不清洗直接入库,等到分析时才发现价格是字符串、日期是「3 天前」、同一个作者名有三种写法——那时候原始页面早就变了,改都没法改。清洗的时机是入库前,不是用的时候。
Unicode 归一用 unicodedata.normalize('NFKC', s):能把全角 ABC123 折成半角、连字 fi 拆成 fi;尤其当心「é」有组合式与分解式两种写法,字节不同肉眼一样,不先做 NFC 归一去重会漏(两者直接 == 为 False,NFC 后才相等)。

抽出来的字段先定成有类型、有必填约束的模型,错误才能在入库前被挡下,而不是等到分析时才炸开。

裸 dict 的三个问题

  • 字段名拼错不报错——d["prcie"] = 12 只是多了一个键;
  • 类型不对不报错——价格是 "暂无" 也照样入库;
  • 少了必填字段不报错——直到下游 KeyError。

Pydantic 把这三样都变成当场报错

  • 类型不对:price="abc" 抛 ValidationError,正文写着 Input should be a valid number, unable to parse string as a number,还附上 input_value='abc';
  • 缺字段:报 Field required 并指名是哪个字段;
  • Scrapy 侧对应的是 Item + itemloaders,轻量场景 dataclass 也够用。

抽取与校验要分开

  • 选择器改了不该动数据模型,数据模型改了不该动选择器——这是站点改版时能快速修复的前提(09 章);
  • 校验失败的记录别丢,进死信留查(见本章后面)。
from pydantic import BaseModel, HttpUrl, field_validator

class Product(BaseModel):
    name: str
    price: float
    url: HttpUrl
    @field_validator("price")
    @classmethod
    def non_negative(cls, v):
        assert v >= 0; return v

Product(**raw)   # 自动校验+类型转换;字段错立刻报错
把一坨 dict 到处传,字段拼写错都发现不了。
别到处传裸 dict:用 Pydantic(或 Scrapy 的 Item + itemloaders、dataclass)给字段定类型与必填约束,拼错字段名、类型不对当场报错;把「抽取逻辑」和「schema 校验」拆开,改选择器时不动数据模型。

存法要顺着「将来怎么用」来选——是逐行追加、列式分析、还是原样留档以便重解析,各有对应的落地格式。

按访问方式挑

形态选它为什么
边抓边追加、逐行读JSONL一行一 JSON,可断点续写、可 grep、坏一行不毁全文件
离线批量分析Parquet列存,只读要的列;压缩比远胜 CSV
要查询 / 要唯一约束Postgres / MySQL去重键做唯一索引,天然幂等(09 章)
结构极不规整MongoDB字段随页面变,但代价是没人替你挡脏数据
全文检索Elasticsearch它是索引不是账本,原始数据仍要另存
原始 HTML 快照对象存储(S3/OSS)日后改了选择器可以重解析而不用重抓

两个必踩的坑

  • 别把百万条塞进一个 JSON 数组:写要一次性攒在内存里,读也要整个 load,中途崩了整个文件废掉——JSONL 没有这些问题;
  • json.dumps 默认会把中文转义:{"t": "中文"} 输出成 {"t": "\u4e2d\u6587"},加 ensure_ascii=False 才是可读的中文。数据没错,但从此没法用肉眼或 grep 检查。
import json

# JSONL:一行一条,可流式追加,百万级也不爆内存
with open("out.jsonl", "a", encoding="utf-8") as f:
    f.write(json.dumps(item, ensure_ascii=False) + "\n")

# 分析场景:列存 Parquet,压缩比与读取都更优
import pandas as pd
pd.DataFrame(items).to_parquet("data.parquet")
把百万条塞进一个大 JSON 数组,内存与读写全崩——改用 JSONL 逐行写。
格式跟着访问方式走:边抓边追加、逐行读用 JSONL(一行一 JSON,可断点续写);离线批量分析用列存 Parquet(按文档,压缩比与列裁剪远胜 CSV);写 JSON 记得 ensure_ascii=False,否则中文会被转义成一串 \uXXXX。原始 HTML 单独存对象存储,便于日后重解析。

把去重、清洗、校验、落库拆成一节节流水线,每段各管一件事,坏了能单独换、脏数据能单独隔离。

四节流水线

  • 去重:按去重键先挡掉重复(07 章的规范化 + 指纹);
  • 清洗:编码、空白、类型转换(见本章「清洗与规范化」);
  • 校验:schema 不过的进死信(见本章前面);
  • 落库:批量提交 + upsert。

两条让吞吐差一个数量级的规矩

  • 批量写而不是逐条写:一条一次 round-trip,网络与事务开销全花在往返上;
  • 幂等落库:用去重键做 upsert(Postgres 的 ON CONFLICT、MySQL 的 ON DUPLICATE KEY),重跑不产生脏数据——这是断点续爬能成立的前提(09 章)。

死信队列:一条脏数据不该掀翻整批

  • 校验失败就 raise,等于让一条异常记录毁掉这一批的全部成果;
  • 正确做法是捕获后把原始数据 + 报错原文 + URL 一起写进死信表,主流程继续;
  • 死信表要有人看——它同时也是站点改版的早期信号(09 章的监控)。
from scrapy.exceptions import DropItem

class CleanPipeline:
    def process_item(self, item, spider):
        item["name"] = item["name"].strip()
        if not item.get("price"):
            raise DropItem("缺价格")   # 丢弃脏数据,不阻塞整批
        return item
# settings: ITEM_PIPELINES = {"proj.CleanPipeline": 300}
一条脏数据抛异常导致整批丢失——应隔离进死信队列。
关键两招:脏数据别 raise 掀翻整批,捕获后丢进死信队列旁路留查;写库用批量提交而非逐条(一条一次 round-trip,吞吐能差一两个数量级)。每阶段做成可插拔,换存储不牵动清洗逻辑。

当页面结构乱到选择器维护不动时,让 LLM 读正文、吐 JSON 是条现代出路,但它慢、贵,还会一本正经地编数据。

什么时候值得上

  • 值得:页面结构千变万化(聚合了几百个来源)、字段是自然语言里的语义(「这条评论在夸还是在骂」)、一次性任务不值得写选择器;
  • 不值得:结构规整、量大、要跑很久——选择器一次写好几乎零成本,LLM 是每条都在花钱;
  • 稳妥做法是混合:选择器抽规整的大结构,LLM 只补它搞不定的那两三个难点字段。

三条省钱与保真的规矩

  • 先裁剪再喂:trafilatura.extract(html) 只留正文,token 能省一个数量级——整页 HTML 里绝大部分是标签、脚本和导航;
  • 用 schema 强制结构:别指望「请返回 JSON」这句话,用结构化输出把格式钉死,再用 Pydantic 复校一遍;
  • 缓存:同一段正文重复处理是纯浪费,按内容哈希做键存起来。

幻觉是这一节唯一真正的风险

  • 选择器落空会给你 None(01 章),LLM 落空会给你一个看起来很合理的编造值——后者进了库就再也分不出来;
  • 所以关键字段(价格、编号、日期)要么别交给它,要么抽完之后回原文做一次字符串核对:抽出来的值必须能在原文里搜到。
# 1) 先裁剪:只保留正文,token 省一个数量级
import trafilatura, anthropic, json
text = trafilatura.extract(html)

# 2) 用结构化输出把返回格式钉死(schema 直接由 Pydantic 模型生成)
client = anthropic.Anthropic()
msg = client.messages.create(
    model="claude-sonnet-5",
    max_tokens=1024,
    output_config={"format": {"type": "json_schema",
                             "schema": Product.model_json_schema()}},
    messages=[{"role": "user", "content": text[:8000]}])

# 3) 再用 Pydantic 复校一遍,并回原文核对关键字段
item = Product.model_validate_json(msg.content[0].text)
assert item.price in text, "价格在原文里搜不到,疑似幻觉"
整页原始 HTML 直接喂 LLM,token 爆炸又慢又贵;不校验输出,幻觉数据混入库。
结构化输出(output_config.format 传 JSON Schema)是现在钉死返回格式的标准做法,比在提示词里写「请返回 JSON」可靠得多;老一些的写法是把 schema 塞进一个工具定义再用 tool_choice 强制调用,同样有效。无论哪种,拿回来都要再用 Pydantic 校一遍——格式对不代表内容对。这段按官方文档写成,未在本机跑通(需要 API key)。

工程与运维

三张表:容错与续爬、该监控的指标、缓存、测试与部署。爬虫默默失效比报错更可怕——这一章就是为了让失效可见。

生产级爬虫与脚本的差别只有一条:坏了你能不能第一时间知道。

容错与续爬

手段说明
checkpoint把「已完成的 URL / 游标」落盘,重启从断点继续(Scrapy 用 JOBDIR)
任务级重试单条失败进「待重试」队列,别让一条失败拖垮整轮
失败清单失败的 URL 单独存一份,跑完统一复查
幂等写入用唯一键 upsert,重跑不会产生重复数据
熔断连续 N 次 403/429 就停下告警,而不是硬着头皮跑到 IP 被封
优雅退出捕获 SIGTERM,把队列与游标存好再退

该监控什么

指标异常意味着
成功率 / 状态码分布403 或 429 占比上升 = 被识别或限速(06 章)
每轮抓取条数突然减半却不报错 = 静默失效,最该配告警的一条
字段填充率某字段空值率飙升 = 站点改版、选择器失效(04 章)
响应时间变慢通常是对方在限速,该退让
队列长度 / 处理速率积压说明下游或并发配置有问题
代理健康度失败率高的代理要及时踢出池子
落库条数 vs 抓取条数两者对不上 = 管道里在丢数据

实现:prometheus_client 暴露指标,或最简单的——每轮跑完把统计写进日志并对比上一轮。

缓存、测试与部署

事项做法
开发缓存requests-cache / Scrapy 的 HTTPCACHE——调选择器时别反复打对方
测试解析存几份真实 HTML 当夹具,对解析函数写单元测试(不联网)
录制回放VCR.py 把真实响应录下来重放,集成测试稳定可复现
改版检测对关键页面的结构做「金丝雀测试」:每天跑一次,选择器失效就告警
部署容器化 + cron / 定时任务;日志与产出都挂卷,容器重建不丢
配置代理、密钥、频率走环境变量,别写进代码
成本控制代理按流量计费时,拦掉图片与静态资源(05 章)省得最多
爬虫最常见的死法不是崩溃,是「一直在跑但什么都没抓到」:站点改版让选择器全部落空、限速让请求全部超时、登录态过期让每页都返回登录页——这三种情况下进程都活得好好的,日志里也可能干干净净。所以监控要盯「产出」而不是「进程状态」:条数、填充率、成功率,这三个指标掉下去就该有人被叫醒。
给每一轮抓取打一份「体检报告」:抓了多少、成功率多少、各字段空值率多少、耗时多久,一并写进日志或存成一行记录。有了历史对比,「今天只抓到 800 条而昨天是 5000 条」这种问题会在几分钟内暴露,而不是等下游用数据时才发现。

长跑任务面对的是不稳定的网络和多变的页面,没有分层兜底和断点续爬,一个意外就让跑了一夜的活儿全废。

按层兜底,别用一个大 try

  • 网络层:超时 + 指数退避重试,区分可重试(超时 / 5xx / 429)与不可重试(4xx)——03 章讲过;
  • 解析层:单条记录包 try,坏的进死信、好的照常入库(08 章);
  • 存储层:用去重键 upsert,重跑不产生重复。
  • 一个 try 兜住整个循环,等于把「一条坏数据」升级成「整轮失败」。

断点续爬的最小实现

  • 每处理 N 条就把「已完成的 URL 集合 / 当前页码 / 游标」落盘(JSON 或 SQLite 都行);
  • 启动时先读进度,从断点接着跑;
  • 配合上面的幂等落库,重跑同一段不会产生脏数据——这两件事必须一起做,只做一件都不成立。

优雅退出

  • 捕获 SIGINT/SIGTERM,把当前批次落盘、连接关掉再退出;
  • 否则 Ctrl-C 一按,内存里没来得及写的那一批就没了,而进度文件还停在更早的位置。
import json, os, logging

ck = json.load(open("ckpt.json")) if os.path.exists("ckpt.json") else {"done": []}
done = set(ck["done"])
for url in urls:
    if url in done:
        continue                       # 已完成 → 断点续爬
    try:
        process(url)
    except Exception:
        logging.exception(url); dead_letter.append(url); continue
    done.add(url)
    json.dump({"done": list(done)}, open("ckpt.json", "w"))
一个未捕获异常让跑了一夜的任务全废。
三件套保命:网络层做指数退避重试(区分可重试的 5xx/超时与不可重试的 4xx)、写入做幂等(用去重键 upsert,重跑不产生脏数据)、长任务定期存进度好断点续爬。别用一个大 try 兜住整个循环——按层各自兜底、失败任务进死信,丢一条不牵连全批。

爬虫会「沉默地坏掉」——站点改版或被封时进程照跑,只是抓回来的全是空,没监控就只能等业务方来问。

四个该盯的指标,按「谁先动」排序

  • 成功率 / 封禁率(2xx 与 403/429 的占比):最早动的一个,被封或改版当天就掉;
  • 字段空值率:选择器失效的直接信号——总量没变、条数没变,只是某个字段全空了(01 章那个静默失败的规模化版本);
  • 抓取量:和昨天同期比,掉一半就该看;
  • 延迟:目标站变慢常常是限速开始生效的前兆。

告警要设在「变化」上而不是「阈值」上

  • 「成功率低于 90% 报警」在一个本来就 85% 的站点上永远在响,在一个本来 99.9% 的站点上又太迟钝;
  • 用同比波动:与前 7 天同一时段比,掉超过 X% 就告警;
  • 再加一条兜底:连续 N 分钟零产出直接告警——它能抓住所有你没想到的失败方式。

日志要能追单条 URL

  • 结构化日志(一行一 JSON),带上 URL、状态码、耗时、抽到几条、用了哪个代理;
  • 出问题时能顺着一条 URL 把它的一生看完,比翻一屏 print 快一个数量级。
from prometheus_client import Counter, start_http_server

OK = Counter("crawl_ok_total", "成功请求数")
BANNED = Counter("crawl_banned_total", "被封请求数")
start_http_server(8000)             # 暴露 /metrics 供 Prometheus 抓

if r.status_code == 200: OK.inc()
elif r.status_code in (403, 429): BANNED.inc()
# Grafana 告警: rate(crawl_banned_total[5m]) 突增 → 通知
没监控,数据断供好几天才被业务方发现。
盯住会「先动」的指标:成功率和封禁率骤降往往比数据缺口更早暴露站点改版或被封,给它们设波动告警;再配结构化日志追单条 URL 的生命周期,和字段缺失率这类数据质量指标一起看。

站点改版是爬虫的常态而非意外,选择器会在某天悄悄失效,维护的重点是让它「一坏就立刻被发现」。

把选择器当配置,不当代码

  • 选择器、请求头、限速参数集中放一个配置文件,改版时改的是数据不是逻辑;
  • 配置进版本控制,每次改动可追溯、可回滚——「上周还好好的」能一眼看出改了什么。

用录制的真实响应做回归

  • VCR.py 首次运行录下真实响应存成 cassette,之后离线回放:测试不再依赖网络,也不会给目标站添麻烦;
  • 关键抽取写成断言(「首条价格必须是 12.00」),改版导致字段丢失能在 CI 里当场抓到;
  • 注意 cassette 是旧的页面——它能防住「代码改坏了」,防不住「站点改版了」。后者靠本章前面的监控。

闭环:坏了 → 告警 → 修复

  • 三件事缺一不可:监控发现、配置里快速改、回归测试确认没改坏别的;
  • 把这套建起来的成本,通常在第二次改版时就回本了。
import vcr, requests

# 首次运行真实请求并录制到 cassette;此后离线回放,稳定可复现
@vcr.use_cassette("fixtures/list.yaml")
def test_parse_list():
    items = parse(requests.get(URL).text)
    assert items[0]["price"] == "12.00"
把爬虫当一次性脚本,几周后选择器悄悄全挂。
把选择器/配置版本化放进代码库,改动可追溯、可回滚;对关键抽取用录制的真实响应做回归测试,改版导致的字段丢失能在 CI 里当场抓到。建好「失效→告警→修复」的闭环,比堆一次性脚本耐用得多。

脚本能跑通只是起点,让它每天准点跑、跑挂了有人知道、换台机器还能跑,才算交付。

从简到繁的三档

  • cron / systemd timer:单机定时,够用得惊人——别一上来就上调度平台;
  • Airflow / Prefect:有依赖关系(抓完再清洗再入库)、要看历史运行状态时才值得;
  • 容器 + 云上定时任务:环境一致、可水平扩,适合多站点多任务。

容器化的几个实用点

  • 把依赖钉死(requirements.txt 带版本号)——爬虫最怕的就是「换台机器解析结果不一样」(04 章那个 1 vs 3 的解析器差异);
  • 无头浏览器镜像要额外装系统依赖,否则启动即报缺库(05 章有报错);
  • 日志打到 stdout 让容器平台收走,别写进容器里的文件。

定时任务的两个经典坑

  • 上一轮还没跑完,下一轮又起来了——加文件锁或用调度器的「不允许并发」选项;
  • cron 的环境变量和你终端里的不一样:PATH、虚拟环境、时区都可能不同,脚本里用绝对路径、显式激活环境。
# Dockerfile:官方 Playwright 镜像已含浏览器与系统依赖
FROM mcr.microsoft.com/playwright/python:v1.44.0-jammy
COPY . /app
RUN pip install -r /app/requirements.txt
CMD ["python", "/app/run.py"]

# 定时: crontab → 0 */6 * * * docker run --rm mycrawler
本地能跑,上服务器缺依赖/字体/浏览器直接崩——容器化根治。
容器化是根治「本地能跑、上线就崩」的关键——把 Python、系统库、浏览器和字体依赖一起封进镜像;简单定时用 cron,有任务依赖/重跑/回填需求上 Airflow/Prefect 编排,海量任务分发用 Celery/RQ。密钥与代理走环境变量或密钥管理,别写进代码。

开发阶段反复抓同一个页面,既慢又是在给目标站添堵——本地缓存一层,调试体验和礼貌程度一起改善。

本地缓存有多值

  • requests_cache.CachedSession("demo", expire_after=300) 一行就把整个 requests 的请求缓存到 SQLite;
  • 同一个页面第一次 from_cache=False、耗时是一次真实网络往返;第二次 from_cache=True、耗时降到毫秒级——两三个数量级的差距(绝对值看你的网络,自己跑一下);
  • 调选择器时这一层最省事:改一次代码重跑一次,不再等网络,也不再往目标站发一个请求。

三层缓存,各管一段

  • 本地 HTTP 缓存(开发期):如上;
  • 条件请求(生产期):ETag / Last-Modified,命中回 304 零字节(07 章有);
  • 原始 HTML 留档:改了选择器可以重解析而不用重抓(08 章)——这是最省的一层,因为它把「重抓」变成了「重跑」。

成本不只是带宽

  • 代理按流量计费时,拦掉图片字体(05 章的 route.abort())能直接省钱;
  • 无头浏览器按分钟烧 CPU 与内存,能用接口就别开浏览器(05 章);
  • LLM 抽取按 token 计费,先裁剪再喂、并缓存(08 章)。
import requests_cache

# 透明缓存:同一 URL 一小时内不重复打真实站点
session = requests_cache.CachedSession("http_cache", expire_after=3600)
session.get(url)   # 首次网络,之后命中本地缓存
# 原则:能用请求解决就别开浏览器,算力/代理成本差百倍
不分页面一律开浏览器 + 住宅代理,成本失控。
省钱第一原则:能用 HTTP 请求就别开浏览器,渲染一个页面的算力是纯请求的上百倍;先用缓存/快照避免重复抓同一页,静态资源本地留存,住宅代理只留给真正需要的请求。抓之前先按 ROI 掂量,有些数据不值这个成本。

爬虫最难测的地方在于依赖外部世界——把「网络」和「解析」拆开,这件事就变得和测普通函数一样简单。

把纯函数切出来

  • parse(html) -> list[dict] 不发请求、不写库,只做纯粹的字符串到结构的转换;
  • 这样它就能用一段固定的 HTML 片段直接测,跑得飞快、不上网、结果确定;
  • 反过来说:把 requests.get 写在解析函数里,就等于把它变成了不可测的代码。

三层测试各测什么

  • 单元:喂几段代表性 HTML 片段(正常的、缺字段的、多一层嵌套的),断言抽出来的结构;
  • 回归:VCR.py 回放录下的真实响应,防止改代码改坏了(上一张维护卡);
  • 冒烟:定时对真实站点抓一页,断言条数不为零、关键字段非空——它测的是站点还没改版,属于监控的一部分(本章监控卡)。

别在单元测试里连真站点

  • 网络一抖测试就红,你会开始忽略红色——那时候测试就失去意义了;
  • 而且这等于每次 CI 都替目标站增加一次无谓的请求,规模化之后相当不礼貌(10 章)。

最小实现:给 hello.py 补一个不上网的测试

  • 把 01 章 hello.py 里的解析部分抽成 parse(html) -> list[dict],不发请求也不写文件;
  • 存一份练习站首页的 HTML 到 fixtures/quotes.html,写 test_parse:断言条数是 10、第一条作者是 Albert Einstein、标签数是 4。pytest 跑一下——零网络、毫秒级;
  • 再故意把选择器改错重跑,确认测试变红。这一步比写测试本身更重要:没见过它红过的测试,等于没有测试。
import pathlib
from myspider import parse_item

def test_parse_item():
    html = pathlib.Path("fixtures/item.html").read_text("utf-8")
    item = parse_item(html)
    assert item == {"name": "手机", "price": 1299.0}
# 离线跑:快、稳定、不打真实站点、不会被封
每次测试都打真实站点,慢、不稳、还可能被封。
解析逻辑用存档的真实响应做离线单测(给定 HTML → 期望字段),既快又不打扰目标站;再加契约测试,接口结构一变 CI 就报警。把这些跑进 CI,站点改版能在上线前就被测出来。

规范、工具与资料索引

收尾两张表:规范与官方资料索引(协议、robots、法规去哪查)与工具清单,外加一条能检验「这一页学没学会」的自测标准。

「这个行为到底该怎么定义」——下面这些才是权威出处,博客只是转述。

规范与文档

资料回答什么
RFC 9110(HTTP 语义)方法、状态码、头字段的权威定义(02 章)
RFC 9309(Robots Exclusion Protocol)robots.txt 终于有了正式标准
RFC 9111(HTTP 缓存)ETag / Last-Modified 的语义(07 章增量)
MDN HTTP 文档比 RFC 好读的版本
httpx / Playwright / Scrapy 官方文档API 的最终依据;三者文档质量都很高
sitemaps.org / schema.org站点地图格式 / 结构化数据字段(04 章的 ld+json)
各站的开放平台文档先找它——有官方 API 就别逆向(05 · 10 章)

工具清单

类别工具
请求httpx、requests、aiohttp、curl_cffi(指纹伪装)
解析parsel、selectolax、lxml、BeautifulSoup、jmespath
渲染Playwright、Selenium、DrissionPage
框架Scrapy(+ Scrapy-Redis 分布式)
抓包与调试DevTools、mitmproxy(含 App 抓包)、Charles、curl
清洗ftfy、dateparser、trafilatura、pydantic
存储JSONL、pyarrow(Parquet)、SQLite、PostgreSQL
工程requests-cache、VCR.py、tenacity、prometheus_client
合规urllib.robotparser、各站 robots 与 ToS 页面

一条自测标准

题目要用到
挑一个真实站点:抓 500 条列表 + 详情,字段校验通过,增量跑第二遍只更新变化的部分02–04(请求与解析)、07(调度与增量)、08(Schema 与落库)
验收一断网重跑能续上,不产生重复数据
验收二故意改坏一个选择器,监控要能报出来(09 章),而不是静默出空值
验收三全程遵守 robots.txt 与合理频率,UA 可识别(10 章)

三条都做到,这一页就算毕业了。

爬虫技术栈的时效性比大多数领域都短:反爬手段、指纹库、代理生态、平台政策都在变,两年前的教程里「一定能用」的写法今天多半失效。所以别背配置,要理解机制——知道对方是从哪一层识别你的(06 章),才能在工具失效时自己找到下一条路;而合规那一层的变化虽然慢,代价却最高,每次开新项目都值得重看一遍 10 章。
做项目的顺序建议是「窄而深」:一个站点从抓取做到增量、监控、合规,胜过十个站点各抓一百条。真实世界的难点——改版、限速、分页游标、脏数据——只有在长期跑一个目标时才会全部出现。