全景:爬虫工程的版图
钻进请求和选择器之前先回答三个问题:它解决什么问题、技术栈分几层、什么场景选什么工具。后面每一层都是这张地图的放大。急着动手就先跳 01 章,十分钟跑通第一个爬虫再回来看全景。
爬虫的立身之本是一门工程学:把散落在网页里的非结构化信息,稳定、合规、规模化地变成可用的结构化数据——难点从来不在「发个请求」,而在对抗、规模与合规。
技术栈的四个层次
- 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(),
})上手:跑通第一个爬虫
先把一条完整链路跑通——装环境、抓一页真站点、把字段写进 CSV,再回头逐行拆解、学会读四类失败、学会动手前先侦察。后面十章都是在给这条链路的某一段加厚。
爬虫的第一课不是理论,是让一段代码真的把数据抓回来、落到磁盘上。
三步开工
- 建隔离环境:
python3 -m venv .venv再激活(爬虫依赖多又版本敏感,别装进系统 Python); - 装两个库:
pip install httpx parsel——一个发请求(同步、异步、HTTP/2 都在里面),一个做选择器(Scrapy 同款解析层); - 跑右边这段:抓
quotes.toscrape.com首页,把名言、作者、标签写成 CSV。终端应打印10 条已写入 quotes.csv;如果只有表头、条数是 0,几乎不会是「网站没数据」,而是选择器落空或状态码不对(见本章第三张卡)。
练手对象要挑对
quotes.toscrape.com 与 books.toscrape.com 是 Scrapy 官方开的练习站,静态版、JS 渲染版、登录版、无限滚动版一应俱全,本来就是给人抓的。别拿真实商业站当第一个练习对象——学习阶段的错误(不限速、翻页死循环)在真站点上就是给人添堵,也最容易一上来就把自己的 IP 打进黑名单。
# hello.py —— 请求 → 解析 → 落盘,一条完整链路
import httpx, csv
from parsel import Selector
r = httpx.get("https://quotes.toscrape.com/", timeout=10)
r.raise_for_status() # 4xx/5xx 立刻炸,别往下带着错误页跑
sel = Selector(r.text)
rows = []
for q in sel.css("div.quote"): # 先分块
rows.append({ # 再在块内取字段
"text": q.css("span.text::text").get(),
"author": q.css("small.author::text").get(),
"tags": ",".join(q.css("div.tags a.tag::text").getall()),
})
with open("quotes.csv", "w", newline="", encoding="utf-8") as f:
w = csv.DictWriter(f, fieldnames=["text", "author", "tags"])
w.writeheader(); w.writerows(rows)
print(len(rows), "条已写入 quotes.csv") # → 10 条已写入 quotes.csvpython3 -m venv 可能报 ensurepip is not available,以及忘了激活虚拟环境就 pip install。timeout=(不带就是无限等)和一个真实的 User-Agent。上一张卡的二十行只做了三件事:把 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= 或自己断言。抓取失败分四层,越往下越安静——最危险的那类根本不抛异常,脚本一路绿灯跑完、退出码 0,只是什么都没抓到。
① 连接层 ② HTTP 层
- 连接层会抛异常:
ConnectError、ConnectTimeout——此时连状态码都还没有。报错正文随网络环境变,所以按异常类型分诊,别按文案 grep; - HTTP 层默认不抛异常:请求一个不存在的路径拿到 404 加一张错误页,程序毫不知情地继续往下走。
③ 解析层:最安静的一类 ④ 编码层
- 选择器落空不报错。把那张 404 错误页喂进解析代码,输出是「抓到 0 条、正常结束、退出码 0」——站点改版导致选择器失效的表现和这一模一样;
- 所以每轮抓完加一句
assert rows是最便宜的保险; - 编码层:响应头没声明 charset 时靠猜,猜错就是整片乱码。
# 四步分诊:异常 → 状态码 → 条数 → 内容
try:
r = httpx.get(url, timeout=10)
except httpx.TimeoutException: # ① 超时:值得重试
...
except httpx.ConnectError: # ① 连不上:网络/DNS/代理问题
...
else:
r.raise_for_status() # ② 非 2xx 转成异常
rows = parse(r.text)
assert rows, "抓到 0 条,选择器可能失效" # ③ 静默失败的唯一防线
# 反面教材:这样写,上面四类失败会全部变成「今天数据有点少」
try:
...
except Exception:
passexcept Exception: pass 是爬虫里最贵的一行——连接层的错被吞掉就变成解析层的 0 条,最终表现为「跑完了,没数据,也不知道为什么」。要吞也得先把异常打出来。status_code 是不是 200 → 取到的行数是不是 0 → 落盘的编码对不对。协议与基石
爬虫不是「学会一个库」,而是一整条从 TLS 握手到数据落库的流水线。本层是一切请求的物理定律——地基不牢,越往上(反爬、规模化)越容易塌。看不懂 HTTP 事务,后面全是玄学。
把浏览器地址栏里发生的事拆开看:每一次抓取,本质都是一来一回的 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 一次次带回去(本章第三张卡)。
现在你来:亲眼看一次原始事务
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 与真实浏览器访问同一地址的结果对比。请求与会话
高效、稳健、可复用地把请求打出去。选对客户端库,做好连接复用、超时重试与并发控制,是把一次性脚本变成能长期跑的爬虫的第一步。
客户端库是整条流水线的地基:选错了,连接复用、异步、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 而非你自己)。解析与提取
从一堆标签里,精准挖出你要的那块数据。解析器把 HTML 变成可查询的树;CSS 选择器与 XPath 负责定位;正则抠文本片段;而现代站点往往把数据直接塞进页面 JSON——那才是最稳的入口。
解析器决定了你面对的是一棵怎样的树:同一段破损 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() 清洗。动态渲染与逆向
数据在 JS 里?要么开浏览器,要么——更聪明地——直接打它背后的 API。高手的第一反应不是渲染页面,而是在 DevTools 里找到真正返回数据的那个请求,直接复刻它。
老手和新手的差距一多半发生在写第一行代码之前——五分钟侦察,省掉几小时白干。
① 站方怎么说 ② 数据在不在原始 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 试通。反爬对抗
一场永不结束的猫鼠军备竞赛。理解检测,才谈得上规避。头伪装再好,TLS / HTTP2 / 浏览器指纹对不上照样死。这里没有银弹,只有持续维护。
反爬不是单点判断,而是行为、身份、指纹、陷阱四层叠加打分——只堵一层,另外三层照样把你捞出来。
四层各自在看什么
| 层 | 看的东西 | 你能做的 | 难度 |
|---|---|---|---|
| 行为 | 频率、时序是否过于规律、有无鼠标与滚动轨迹 | 限速、随机化间隔、控制总量 | 低(也最该先做) |
| 身份 | 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 章)。
规模化与架构
从一个脚本,到能跑百万级 URL 的系统。别重复造轮子:成熟框架帮你搞定调度、去重、管道;分布式与增量抓取则决定你能不能长期、经济地维持这条流水线。
当抓取从几十个 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 空响应、几乎零成本;服务器不给这些头时,再退到「正文指纹比对」判断内容是否更新。数据处理与存储
抓下来只是开始,能用的干净数据才是产出。清洗、结构化校验、选对存储与管道,决定了数据能不能被下游可靠地消费——而 LLM 辅助抽取正在成为处理不规则页面的新范式。
网页里的文本天生脏,藏着全角半角、不可见空白、组合字符和编码残留,不先规整,后面分析步步踩雷。
四类脏,四种治法
- 全角 / 连字:
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)。工程与运维
能长期稳定跑、坏了能发现,才叫生产级爬虫。容错、监控、可复现的测试与部署,让爬虫从「跑一次的脚本」变成「活着的系统」——尤其别忘了:爬虫默默失效比报错更可怕。
长跑任务面对的是不稳定的网络和多变的页面,没有分层兜底和断点续爬,一个意外就让跑了一夜的活儿全废。
按层兜底,别用一个大 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 与 ToS 是边界线,个人数据与版权是高压区,而「做个有礼貌的爬虫」长期看对你自己也最有利。
抓公开网页的第一道边界不在代码里,而在两份文本里——机器可读的 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 优先 · 限速 · 错峰 · 缓存 · 只取公开非敏感数据从这里到精通:路线图
地图铺完了,剩下的路要亲手写出来。最后这一章给出收尾路线:难度递进的动手项目、按阶段的资料,以及一条自测标准。
爬虫是一门对抗中成长的手艺:教程里的站点永远是配合的,真实站点才会教你重试、指纹与限速。按下面的顺序走,每一步都有明确产出物,并且全程在合规边界内。
动手项目(难度递进)
- ① 静态页抓取入库:01 章那段
hello.py就是起点——把它从 CSV 换成 SQLite,加上 04 章的 fallback 选择器与空值率统计,产出一张字段干净、可复跑的数据表; - ② 翻页、登录态与增量:加上翻页遍历、
Client维持的登录态、按内容指纹去重的增量抓取(07 章)——重复运行只新增不重复,产出一个幂等的抓取脚本; - ③ 接口直连 vs 渲染后解析:拿练习站的
/scroll版本做对照(05 章走通过一遍),把「监听 XHR 找接口 → 直连翻页」和「无头浏览器滚动到底」两条路线各写一遍并计时,产出一份带数字的选型笔记; - ④ Scrapy 项目化:迁移为 Scrapy 项目——下载中间件做 UA/代理轮换、配置去重与 AutoThrottle 限速(注意 07 章说的框架默认值与项目模板不一致),接上日志与失败告警(09 章),产出一个能无人值守跑一周的工程。
书与资料(按阶段)
- 入门:httpx / requests 官方文档 +
toscrape.com(Scrapy 官方练习站群,静态版、JS 版、滚动版、登录版齐全,本页的几乎都跑在它上面); - 系统化:《Python 网络爬虫权威指南》(Web Scraping with Python,Ryan Mitchell)——从解析到法律边界的完整视角;
- 进阶:Playwright(Python)与 Scrapy 官方文档——本页第 05、07 章的第一手资料;
- 合规:重读本页第 10 章,收藏所在法域的个人信息保护法原文。
把本页的「现在你来」全做一遍
- 02 章:写一把 URL 去重钥匙、亲眼看一次原始 HTTP 事务;03 章:量一次「裸 get → 复用 Client → 异步并发」的三级加速;
- 04 章:把
hello.py改成抗改版的版本;05 章:顺着has_next把接口翻到底; - 06 章:把自己的 TLS 指纹调出来看;09 章:给解析函数补一个不上网的测试;
- 这七个练习加起来不到一个下午,但它们覆盖了本页最容易「读懂了却写不出来」的七处。