速查总览:这一页怎么查
本页是爬虫的速查手册,按问题翻而不是从头读:每章第一张卡把该层的协议 / API / 参数一次列全,后面的卡再展开语义与对抗细节。这一章给三张索引——章节地图、定位与选型、工具选型矩阵。
爬虫是一条从 TLS 握手到数据落库的流水线,本页按这条流水线分层。不必顺读:先在下表按关键词定位。
章节地图
| 章 | 查什么 | 关键词 |
|---|---|---|
| 01 | 装环境、最小可跑骨架、四类失败反查 | httpx · parsel · 403 · 空结果 |
| 02 | HTTP 方法与状态码、请求头全表、URL 编码、压缩、HTTP/2 与 TLS | User-Agent · Cookie · JA3 |
| 03 | 客户端库对照、连接复用、超时重试退避、并发、代理写法 | httpx.Client · tenacity · proxy |
| 04 | 解析器对照、CSS 选择器与 XPath 全表、内嵌 JSON 提取 | selectolax · XPath · __NEXT_DATA__ |
| 05 | 侦察清单、接口逆向流程、Playwright API 与等待策略 | DevTools · 签名 · 拦截 |
| 06 | 检测向量总览、指纹维度、限速拟人化、验证码 | TLS 指纹 · Cloudflare · 代理池 |
| 07 | Scrapy 组件与命令、去重、调度、分布式、增量抓取 | Spider · Bloom · ETag |
| 08 | 清洗工具、Schema 校验、存储格式对照、管道 | ftfy · pydantic · Parquet |
| 09 | 容错与续爬、监控指标、缓存、测试、部署 | checkpoint · 指标 · VCR |
| 10 | robots 指令、法规对照、判例与灰度地带 | robots.txt · GDPR · PIPL |
| 11 | 规范、工具与法规资料索引 | RFC 9110 · RFC 9309 |
三条最常见的查法
| 你手上有的 | 去哪 |
|---|---|
| 一个状态码或一段空结果 | 01 章的四类失败反查表;状态码全表在 02 章 |
| 「这站的数据在 JS 里」 | 05 章:先侦察找接口,实在不行再开浏览器 |
| 「被封了 / 被挑战了」 | 06 章检测向量表,先定位是哪一层露的馅 |
robots.txt 与服务条款是底线,个人数据与版权是高压区。先看有没有官方 API——很多站点提供的开放接口,比你逆向出来的那套稳定得多,也不用担责。爬虫的立身之本是一门工程学:把散落在网页里的非结构化信息,稳定、合规、规模化地变成可用的结构化数据——难点从来不在「发个请求」,而在对抗、规模与合规。
技术栈的四个层次
- 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(),
})爬虫生态的工具很多,但按「页面是静态还是动态、量级多大」两个维度就能定下来。
按场景选
| 场景 | 工具组合 |
|---|---|
| 静态页、一次性、几百条 | httpx + parsel / selectolax,一个脚本搞定 |
| 静态页、几十万条、要调度与重试 | Scrapy(07 章) |
| 数据在 XHR / JSON 里 | 直接打接口(05 章)——比渲染快一个数量级 |
| 必须执行 JS 才出数据 | Playwright;能只渲染一次拿到接口就别常驻浏览器 |
| 有 TLS / HTTP2 指纹检测 | curl_cffi(模仿浏览器指纹)或真浏览器(06 章) |
| 要高并发 IO | asyncio + 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 章) |
curl 复刻一次,再写代码:DevTools 里对着目标请求「Copy as cURL」,命令行里跑通了,再翻译成 Python。这样能把「请求姿势不对」与「代码写错了」两件事彻底分开——省下的时间比这一步花的多得多。起步 · 骨架 · 失败反查
三张表 + 一段可跑的骨架:环境与依赖、三段式骨架逐行含义、四类失败反查——爬虫最难的不是报错,是不报错但拿不到数据。
一条完整链路只有三段:请求 → 选择 → 落盘。先把这三段跑通,后面十章都是在给某一段加厚。
装什么
| 依赖 | 作用 |
|---|---|
uv add httpx | HTTP 客户端:同步异步一套 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」把浏览器的请求原样复制出来,命令行里跑通了再翻译成代码——能把「请求姿势不对」和「代码写错了」彻底分开。
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() # [][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 章) |
| 404 | URL 拼错、分页越界、内容已删 | 核对 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 是爬虫里最贵的一行代码:它把超时、封禁、解析失败一起变成「没有数据」,于是你的爬虫看起来一直在跑,实际上什么都没抓到。要么记日志再继续,要么统计失败率并在超阈值时停下——让失败可见,是规模化之前必须先做的事。resp.status_code 与 resp.text[:200],两秒定位。协议与基石
四张表:HTTP 方法与状态码、请求头全表(哪些会暴露你是机器人)、URL 编码与结构、编码 / 压缩 / 协议版本与 TLS 指纹。这一层是所有请求的物理定律。
爬虫遇到的九成「玄学问题」,都能在这几张表里找到解释:状态码说明对方怎么看你,请求头决定它怎么认你。
方法与状态码
| 类别 | 要点 |
|---|---|
GET / POST | 取数据 / 提交;爬虫九成只用 GET,接口翻页有时用 POST |
HEAD | 只要响应头:探测存在性与 Content-Length 最省流量 |
OPTIONS | 浏览器的 CORS 预检;你自己抓不需要发 |
| 2xx | 200 成功、204 无内容、206 分段(断点续传) |
| 3xx | 301/302 跳转、304 未修改(增量抓取的关键,07 章) |
| 4xx | 400 参数错、401 要登录、403 被拒、404 没有、429 太频繁 |
| 5xx | 500 对方崩了、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-After | 429/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 3 | 2 多路复用、3 走 QUIC;httpx 要装 http2 额外依赖才开 |
| TLS 指纹(JA3/JA4) | 握手参数的顺序与组合能认出你不是浏览器——即使头全对(06 章) |
| HTTP/2 指纹 | 帧顺序与 SETTINGS 值同样可指纹化 |
403 不等于「被封 IP」。它可能是缺 Referer、缺 Sec-Fetch-*、TLS 指纹不对、或者只是路径要登录。换 IP 是最后一步,不是第一步——先用 curl 逐项补头验证,很多时候补上两个头就通了,白白烧掉的代理费和被拉黑的 IP 段都能省下。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/jsonDevTools › 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)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())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 渲染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'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())r.headers["content-type"] 看有没有 charset → 打印 r.encoding 看库定成了什么 → 用 r.content.decode("gb18030") 手动试一次。三步定位不了的,多半根本不是编码问题而是拿到了反爬页。协议版本本身也是一种身份:真浏览器早已走 HTTP/2 甚至 HTTP/3,很多请求库却默认停在 HTTP/1.1——这道落差就是破绽。
三个版本的差别与爬虫含义
| 版本 | 传输 | 关键特性 | 对爬虫意味着 |
|---|---|---|---|
| HTTP/1.1 | TCP,文本 | 一连接一请求、队头阻塞 | 大多数 Python 库的默认;与真浏览器有落差 |
| HTTP/2 | TCP,二进制帧 | 多路复用、头压缩(HPACK) | 帧序 / SETTINGS / 伪头顺序构成指纹(Akamai 常用) |
| HTTP/3 | QUIC(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"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 一致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=True | httpx 默认不跟随重定向(与 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 却毫无察觉。客户端库是整条流水线的地基:选错了,连接复用、异步、HTTP/2 这些后面章章要用的能力可能根本就没有。
Python 侧怎么挑
| 库 | 同步/异步 | HTTP/2 | TLS 指纹 | 什么时候选它 |
|---|---|---|---|---|
| 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 用一整轮抓取,别在函数里随手 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",
}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 rrequests 默认 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;忽略每域名礼貌间隔。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"})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=...)proxy=(proxies= 已移除,传了报 TypeError,确认);SOCKS5 需 pip install httpx[socks];用代理前务必验证匿名度与真实出口 IP(请求一个回显 IP 的接口如 httpbin.org/ip,看到的应是代理 IP 而非你自己)。解析与提取
四张表:解析器对照、CSS 选择器全表、XPath 全表、内嵌 JSON 的四个藏身处。现代站点往往把数据直接塞进页面 JSON——那才是最稳的入口。
定位数据只有三把工具:CSS 选择器(简洁)、XPath(能往上找、能按文本找)、直接解 JSON(最稳)。
解析器对照
| 库 | 选择器 | 特点 |
|---|---|---|
| parsel | CSS + XPath | Scrapy 同款;脚本里也能单独用,首选 |
| selectolax | CSS | 快得多(C 实现),大批量解析选它 |
| lxml | CSS + XPath | 老牌、功能全,API 偏底层 |
| BeautifulSoup | CSS + 自有 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 友好,后端可换——真正干活的是下面几个 |
| lxml | C 实现(libxml2) | 快、容错贴近浏览器,默认就选它 |
| html.parser | Python 内置 | 零依赖,容错最弱,破损 HTML 上会给你另一棵树 |
| html5lib | 纯 Python | 严格按 HTML5 规范修补,最像浏览器但最慢 |
| selectolax | C 实现(Modest/Lexbor) | 极快,大规模抓取时值得换 |
| parsel | lxml 之上的选择器层 | 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()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");.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") # 向上找祖先/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).*? 与 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)__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 章) |
接口逆向:固定流程
| 步骤 | 做什么 |
|---|---|
| 1 | DevTools 里找到目标请求 → 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,于是超时。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')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,找真实数据接口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 += 1DevTools 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 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 = countsleep(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 证书Frida、objection、jadx(反编译 APK)。不是所有接口都是普通 REST:GraphQL 要你构造 query、WebSocket 要你维持长连接,抓法与「GET 一个 JSON」截然不同。
GraphQL:POST 一个 query
- 特征是
POST /graphql,请求体形如{"query": "...", "variables": {...}}——一眼可辨; - 拿公开的
countries.trevorblades.com/graphqlquery($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){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 不碰任何静态资源,也是一种特征 |
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"])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 指纹与行为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 保持一段时间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)验证码是站点在明确表达「这里不欢迎自动化」——遇到它,第一反应该是退一步,而不是硬闯。
先分清你遇到的是哪一种
- 题目型:图形、滑块、点选——需要有人或有模型去解;
- 打分型: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 章)。
规模化与架构
三张表: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 存下来,一样就跳过——对付那些不给缓存头的站 |
| 列表页时间戳 | 按发布时间翻页,翻到已抓过的就停 |
CONCURRENT_REQUESTS_PER_DOMAIN 与 DOWNLOAD_DELAY,而不是全局并发数。更省心的做法是直接开 AUTOTHROTTLE:对方变慢它就退让,既礼貌又能避开大多数限速触发。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'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,内存直接爆炸。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 抢任务礼貌不只是道德,它直接决定你会不会被封、会不会被投诉,是规模化能不能长期稳定跑下去的前提。
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 = Trueprotego 的 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 值得留一份:解析逻辑改了能重跑,不用重抓 |
网页里的文本天生脏,藏着全角半角、不可见空白、组合字符和编码残留,不先规整,后面分析步步踩雷。
四类脏,四种治法
- 全角 / 连字:
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() # 压缩空白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")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, "价格在原文里搜不到,疑似幻觉"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 章)省得最多 |
长跑任务面对的是不稳定的网络和多变的页面,没有分层兜底和断点续爬,一个意外就让跑了一夜的活儿全废。
按层兜底,别用一个大 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"))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]) 突增 → 通知站点改版是爬虫的常态而非意外,选择器会在某天悄悄失效,维护的重点是让它「一坏就立刻被发现」。
把选择器当配置,不当代码
- 选择器、请求头、限速参数集中放一个配置文件,改版时改的是数据不是逻辑;
- 配置进版本控制,每次改动可追溯、可回滚——「上周还好好的」能一眼看出改了什么。
用录制的真实响应做回归
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"脚本能跑通只是起点,让它每天准点跑、跑挂了有人知道、换台机器还能跑,才算交付。
从简到繁的三档
- 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开发阶段反复抓同一个页面,既慢又是在给目标站添堵——本地缓存一层,调试体验和礼貌程度一起改善。
本地缓存有多值
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) # 首次网络,之后命中本地缓存
# 原则:能用请求解决就别开浏览器,算力/代理成本差百倍爬虫最难测的地方在于依赖外部世界——把「网络」和「解析」拆开,这件事就变得和测普通函数一样简单。
把纯函数切出来
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}
# 离线跑:快、稳定、不打真实站点、不会被封法律与伦理
三张表:robots.txt 指令、主要法规对照、红线与自查清单。技术能做 ≠ 应该做 ≠ 合法——这一层决定你会不会惹上麻烦。
先说清楚:本页不是法律意见。真要做规模化商用抓取,找律师看一遍比读十篇博客管用。
robots.txt 指令
| 指令 | 含义 |
|---|---|
User-agent: * | 下面的规则适用于哪些爬虫;先匹配最具体的那组 |
Disallow: /admin/ | 不允许抓的路径前缀 |
Allow: /admin/public/ | 在 Disallow 范围内开的口子 |
Crawl-delay: 10 | 非标准但广泛支持:两次请求至少隔 10 秒 |
Sitemap: https://…/sitemap.xml | 白送的 URL 清单,比自己爬列表页省事 |
| 解析 | urllib.robotparser(标准库)或 Scrapy 的 ROBOTSTXT_OBEY |
| 法律效力 | robots 本身通常不是法律文件,但违反它会成为「你明知不该抓」的证据 |
主要法规与关注点
| 法规 / 概念 | 管什么 | 对爬虫意味着 |
|---|---|---|
| 服务条款(ToS) | 合同关系 | 登录后抓取尤其危险——点了同意就是签了字 |
| GDPR(欧盟) | 个人数据处理 | 姓名、邮箱、IP 都算个人数据;要有合法依据、可删除 |
| 个人信息保护法(PIPL)(中国) | 个人信息处理 | 强调告知同意与最小必要;跨境传输另有要求 |
| CFAA(美国) | 未授权访问 | 近年判例倾向于「公开可见的数据抓取不算未授权」,但绕过技术限制或登录墙风险高 |
| 著作权 | 内容的复制与再发布 | 抓取 ≠ 有权使用;事实与数据不受保护,表达受保护 |
| 数据库权利 | 欧盟等地对数据库的特别保护 | 大规模复制整站结构化数据风险更高 |
| 不正当竞争 | 对同业的实质性替代 | 把别人的数据做成竞品是最容易出事的场景 |
红线与自查清单
| 问自己 | 答案不妙就停 |
|---|---|
| 有没有官方 API 或数据导出? | 有就用它——更稳、更合规 |
| 要不要登录 / 绕过验证码? | 要,风险等级立刻上一档 |
| 抓的是个人信息吗? | 是 → 最小化、脱敏、别留不必要的字段 |
| 会给对方带来多大负载? | 控制到「对方察觉不到」的水平 |
| 数据要拿来做什么? | 内部分析 ≠ 公开再发布 ≠ 做竞品 |
robots.txt 允许吗? | 不允许而你仍要抓,得有站得住的理由 |
| UA 里有没有留联系方式? | 可识别的 UA + 联系邮箱是最省事的善意表达 |
User-Agent(带项目名与联系邮箱)、遵守 robots.txt、低频、避开高峰、只抓需要的字段。对方运维看到一个礼貌且可联系的爬虫,多半会放行;看到一个伪装成 Chrome 的高频匿名流量,第一反应是封。抓公开网页的第一道边界不在代码里,而在两份文本里——机器可读的 robots.txt,和有合同性质的服务条款。
两份文本,性质完全不同
- robots.txt:行业惯例,表达站方意愿。它本身通常不是法律义务,但无视它是最容易被拿来说事的一条,也是被封的头号原因;
- 服务条款(ToS):合同性质。尤其是注册账号时点过「同意」的那种,违反可能构成违约,与账号一并追责;
- 没有 robots.txt(07 章练习站返回 404)不等于放行,只等于站方没在这份文件里表态。
登录前 vs 登录后,差别很大
- 公开可访问的页面:争议主要在使用方式与数据类型上;
- 登录 / 付费墙之后:你已经和对方成立了合同关系,ToS 的约束力强得多,绕过技术措施的争议也随之而来;
- 这条线值得在动手前就画清楚——它决定了后面所有讨论的性质。
一旦抓到能识别到具体某个人的信息,你面对的就不再只是技术问题,而是 GDPR、CCPA、个保法这类有牙齿的合规义务。
三部主要法规,各自的重点
- GDPR(欧盟):需要合法性基础;从第三方间接收集个人数据时,原则上还要通知数据主体——这一条对爬虫尤其棘手,因为你根本没有他们的联系方式;
- CCPA/CPRA(加州):赋予居民知情、删除、拒绝出售的权利;
- PIPL(中国个人信息保护法):以告知同意为基础,敏感个人信息还要单独同意,跨境传输另有要求。
「公开」不等于「可随便用」
- 一个人把手机号发在论坛上,不代表你可以把它收集起来建库、再用于别的用途——多数法域看的是处理目的而不只是来源;
- 把很多条本身无害的公开信息聚合起来,可能构成更敏感的画像,这一点在实务里被反复强调。
工程上能做的四件事
- 最小化:非必要不采 PII——这是成本最低、效果最好的一条;
- 假名化:确需关联时存哈希而非明文(右边代码),并注意假名化不等于匿名化,多数法规仍视其为个人数据;
- 留存期限:给数据设过期时间并真的删;
- 可删除:设计上要能按人删干净,包括备份和衍生表。
import hashlib
# 若必须处理 PII:假名化,不留明文;并设留存期限与删除流程
def pseudonymize(email):
return hashlib.sha256(email.strip().lower().encode()).hexdigest()[:16]
# 数据最小化:能不采就不采,尤其姓名/联系方式/画像能抓到内容和有权使用内容是两码事,文字、图片、视频大多自带版权,抓取这个动作本身并不转移任何授权。
三种不同的权利,别混为一谈
- 内容版权:文章、图片、视频本身受保护,抓取不产生任何许可;
- 数据库权利:欧盟等法域对「有实质性投入的数据库」另有一层保护,即使里面每一条事实都不受版权保护;
- 事实本身:单纯的事实(价格、比分)通常不受版权保护,但它的特定表达和整体汇编可能受保护。
用途决定风险,且差距极大
- 个人研究 / 学术 < 内部分析 < 商用 < 再分发或转售——最后一档风险最高;
- 「合理使用」(fair use / fair dealing)的边界因法域而异,且比大多数人想象的窄,不能当护身符;
- 优先找公开许可的来源:CC 协议、开放数据平台、官方 API 的授权条款——省事且能写进合规文档。
这个领域没有一劳永逸的标准答案,判例在不同法域结论不一、还在持续演变,「灰度」才是常态。
两个常被引用的美国案子
- Van Buren v. United States(美国最高法院,2021):把 CFAA 的「超越授权访问」收窄到「有没有权限进入这个区域」的门闸式判断,而不是「有没有违反使用规则」。这是后面很多讨论的地基;
- hiQ v. LinkedIn(第九巡回):倾向认为抓取公开数据不构成 CFAA 意义上的未授权访问;但这不是 hiQ 的胜利——它后来在「违反服务条款」这条线上落败并以和解与禁令收场。两条线是分开的:CFAA 说得通,不代表合同说得通。
为什么不能拿判例当护身符
- 法域不同:美国的 CFAA 讨论对欧盟的数据库权利、中国的个保法几乎没有参考价值;
- 还在变:这几年关于「公开数据 + 训练数据」的诉讼密集出现,结论远未收敛;
- 事实细节决定成败:登录与否、是否绕过技术措施、抓了什么类型的数据、拿去做什么——换一个变量结论就可能反转;
- 本页只是技术教程,不构成法律意见。真金白银的项目,问法务。
技术上做得到,不代表就该做——礼貌的爬虫先问「有没有官方渠道」,再掂量自己给目标站添了多少负担。
动手前先问三句
- 有没有官方 API 或数据下载?——往往更快、更稳、还合规,找它的十分钟通常最划算;
- 能不能直接要?——尤其对小站和学术数据,一封说明用途的邮件的成功率比大多数人以为的高;
- 我真的需要全量吗?——抽样、增量、只取所需字段,都能把影响降一个数量级。
抓的时候
- 限速、错峰、缓存、增量(06/07/09 章都在讲这件事的不同侧面);
- 声明可识别的 UA 与联系方式:出问题时站方能找你商量,而不是直接封 IP 或投诉给你的服务商;
- 只取公开、非敏感数据;尊重删除与退出请求。
为什么这对你自己也划算
- 被封的头号原因是频率,礼貌的爬虫活得久——这是全页最实在的一条工程建议;
- 把小站服务器压垮,除了缺德,还会招来投诉、封禁乃至追责,而你省下的那点时间根本不值;
- 反过来,一个限速、标明身份、遵守 robots 的爬虫,在绝大多数站点上根本不会遇到本页 06 章那一整章的麻烦。
# 可识别 UA:出问题时站方能联系到你,而不是直接封 + 投诉
UA = "MyResearchBot/1.0 (+https://example.com/bot; contact@example.com)"
headers = {"User-Agent": UA}
# 原则:官方 API 优先 · 限速 · 错峰 · 缓存 · 只取公开非敏感数据规范、工具与资料索引
收尾两张表:规范与官方资料索引(协议、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 章) |
三条都做到,这一页就算毕业了。