现代网络爬虫完整知识体系交互讲解

全景:爬虫工程的版图

钻进请求和选择器之前先回答三个问题:它解决什么问题、技术栈分几层、什么场景选什么工具。后面每一层都是这张地图的放大。急着动手就先跳 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(),
    })
两种典型错误:一是杀鸡用牛刀——静态页也硬上 Playwright,几十倍资源换十行 requests 就能做完的事;二是把合规当「以后再补」,等数据落库、业务跑起来再回头补,成本高到只能推倒重来。
动手前先花十分钟在 DevTools 的 Network 面板里侦察——很多「难爬」的站点背后就是一个干净的 JSON 接口,找到它比任何解析技巧都省力。

上手:跑通第一个爬虫

先把一条完整链路跑通——装环境、抓一页真站点、把字段写进 CSV,再回头逐行拆解、学会读四类失败、学会动手前先侦察。后面十章都是在给这条链路的某一段加厚。

爬虫的第一课不是理论,是让一段代码真的把数据抓回来、落到磁盘上

三步开工

  • 建隔离环境python3 -m venv .venv 再激活(爬虫依赖多又版本敏感,别装进系统 Python);
  • 装两个库pip install httpx parsel——一个发请求(同步、异步、HTTP/2 都在里面),一个做选择器(Scrapy 同款解析层);
  • 跑右边这段:抓 quotes.toscrape.com 首页,把名言、作者、标签写成 CSV。终端应打印 10 条已写入 quotes.csv如果只有表头、条数是 0,几乎不会是「网站没数据」,而是选择器落空或状态码不对(见本章第三张卡)。

练手对象要挑对

quotes.toscrape.combooks.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.csv
新手撞的头两堵墙都在环境不在代码:Debian/Ubuntu 上 python3 -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()       # []
别全局取字段再按下标配对:这一页 10 条名言的标签数是 [4,2,5,4,2,3,2,4,1,3],全局取出来是一个 30 元素的平铺列表,按下标配对必然错位。正确做法是先选出每条记录,再在记录内部取字段。
选择器落空是返回值不是异常.get()None.getall()[]。想让字段缺失当场暴露,就传 default= 或自己断言。

抓取失败分四层,越往下越安静——最危险的那类根本不抛异常,脚本一路绿灯跑完、退出码 0,只是什么都没抓到。

① 连接层 ② HTTP 层

  • 连接层会抛异常:ConnectErrorConnectTimeout——此时连状态码都还没有。报错正文随网络环境变,所以按异常类型分诊,别按文案 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:
    pass
except 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_codelen(r.history),亲手确认两个库的重定向默认值不同。
# 命令行看原始事务:-i 显示状态行 + 响应头
curl -i https://httpbin.org/get

import requests
r = requests.get("https://httpbin.org/get")
print(r.status_code)               # 200
print(r.request.method)            # GET
print(r.headers["Content-Type"])  # application/json
403 / 429 当成「代码 bug」去 debug,其实是被反爬识别了;忽略 3xx 重定向会抓到登录页而非目标页。
侦察工具:浏览器 DevTools › Networkcurl -ihttpiePostman。看响应先看三样:状态码、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-EncodingHostUser-Agent: python-httpx/0.28.1requests 同理,UA 是 python-requests/2.32.5
  • 同一个请求,真实浏览器要发十几个头,还带 Sec-Fetch-SiteSec-Ch-UaAccept-Language默认 UA 直接自报家门是最便宜的暴露点,也是最容易补的一环。
import requests

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

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

一张 Cookie 的一生

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

三种凭证形态

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

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

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

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

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

文档是一棵树

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

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

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

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

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

六段结构

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

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

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

相对链接一律交给 urljoin

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

现在你来:给自己写一把去重钥匙

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

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

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

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

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

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

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

猜编码有多不靠谱

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

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

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

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

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

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

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

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

你现在走的是哪版

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

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

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

握手里发生了什么

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

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

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

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

请求与会话

高效、稳健、可复用地把请求打出去。选对客户端库,做好连接复用、超时重试与并发控制,是把一次性脚本变成能长期跑的爬虫的第一步。

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

Python 侧怎么挑

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

JS 侧与选型底线

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

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

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

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

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

到底快多少

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

池子怎么调

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

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

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

整套搬运的正确姿势

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

自洽比齐全更重要

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

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

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

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

什么该重试,什么不该

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

退避要带抖动

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

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

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

三种并发模型怎么选

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

并发数不是越大越好

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

现在你来:亲手量一次三级加速

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

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

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

DevTools › Network 的四个动作

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

命令行侧的三件套

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

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

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

接入:一个参数的事

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

用之前先验三件事

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

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

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

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

解析与提取

从一堆标签里,精准挖出你要的那块数据。解析器把 HTML 变成可查询的树;CSS 选择器与 XPath 负责定位;正则抠文本片段;而现代站点往往把数据直接塞进页面 JSON——那才是最稳的入口。

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

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

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

选哪个

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

JS 侧

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

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

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

够用的那一小半语法

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

parsel 的伪元素:一步取值

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

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

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

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

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

CSS 给不了的三件事

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

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

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

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

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

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

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

该用正则的三个场合

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

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

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

别拿正则解嵌套标签

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

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

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

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

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

先搜这四个锚点

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

为什么内嵌 JSON 优于解析 DOM

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

取出来之后

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

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

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

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

三道防线

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

但兜底不等于装作没事

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

现在你来:把 hello.py 改成抗改版的版本

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

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

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

动态渲染与逆向

数据在 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')
一上来就开浏览器是最常见的浪费——九成站点根本不需要,而浏览器方案慢一到两个数量级。先看一眼「右键查看源代码」里有没有数据。
侦察结论直接决定选型,也直接决定你要读本页哪一章:原始 HTML 里就有数据 → 03 章直接请求;数据来自 XHR → 找那个 JSON 接口;两者都不是 → 才轮到 Playwright。

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

四种「抓不到」的形态

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

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

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

判定之后走哪条路

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

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

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

一次完整的逆向(全程)

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

怎么找到那个接口

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

难的部分:参数怎么来的

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

现在你来:走一遍这条链路

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

# ② 直连 + 顺着 has_next 翻页,全程无浏览器
import httpx
page_no, all_rows = 1, []
with httpx.Client(timeout=10) as c:
    while True:
        d = c.get("https://quotes.toscrape.com/api/quotes",
                  params={"page": page_no}).json()
        all_rows += d["quotes"]          # 每页 10 条,字段比 HTML 里更全
        if not d["has_next"]: break    # 接口自己告诉你翻完没
        page_no += 1
一上来就开 Selenium,慢又重,其实一个 API 就够;忽略 Token 有效期/一次性导致复刻失败。
判断一个站「值不值得开浏览器」只要三分钟:DevTools 过滤 XHR 翻一页,看有没有一个响应里直接躺着你要的字段。有——那这一章后面五张卡你今天都用不上;没有,再往下读。工具:DevTools XHRmitmproxy(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.55X11; Linux x86_64,同时 navigator.webdriverTrue——什么都不改就上真站点,等于举着牌子进门(06 章)。
选型:Playwright 一套 API 通吃 Chromium/Firefox/WebKit、自带自动等待,通常比 Selenium 省心;容器里跑 headless 需先补齐系统依赖(playwright install-deps),否则启动即报缺库。

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

四个档位,越靠后越具体

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

差别有多大

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

无限滚动的收敛写法

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

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

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

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

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

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

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

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

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

基本链路

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

会卡住的两个地方

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

先想清楚再动手

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

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

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

GraphQL:POST 一个 query

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

GraphQL 的坑:错误不走状态码

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

WebSocket:连上去持续收帧

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

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

反爬对抗

一场永不结束的猫鼠军备竞赛。理解检测,才谈得上规避。头伪装再好,TLS / HTTP2 / 浏览器指纹对不上照样死。这里没有银弹,只有持续维护。

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

四层各自在看什么

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

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

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

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

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

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

换 UA 改不了 TLS 指纹

  • tls.peet.ws/api/all 回显自己的握手特征:httpx 默认请求的 JA3 哈希,与带上一整条 Chrome UA 的请求的 JA3 哈希,一模一样
  • 换成 curl_cffiimpersonate="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.jsondiff 一下——除了回显的 UA 字段,ja3_hash 应该一个字节都没变;
  • curl_cffipip install curl_cffi),用 requests.get(url, impersonate="chrome") 再存一份 c.json。这次 ja3_hashja4http_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(浏览器侧的一整屏检测项)。用同一套代码分别以 httpxcurl_cffi 请求前者,把两份 JSON 存下来 diff 一遍,比读十篇文章都清楚。

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

怎么认出是哪一家

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

认出来之后的判断

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

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

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

三类 IP 的取舍

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

轮换不是越勤越好

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

自洽比数量重要

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

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

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

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

先把自己这端管好

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

浏览器方案的「拟人」

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

收到 429 的正确反应

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

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

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

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

开箱即穿帮的两处

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

两条战线

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

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

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

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

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

先分清你遇到的是哪一种

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

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

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

把它当信号读

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

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

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

把它当运维,不当解谜

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

算一算投入产出

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

规模化与架构

从一个脚本,到能跑百万级 URL 的系统。别重复造轮子:成熟框架帮你搞定调度、去重、管道;分布式与增量抓取则决定你能不能长期、经济地维持这条流水线。

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

Scrapy 替你做了什么

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

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

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

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

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

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

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

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

三件必须显式设定的事

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

广度还是深度

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

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

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

两种去重,别混为一谈

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

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

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

指纹怎么算

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

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

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

要共享的三样东西

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

怎么搭

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

先问一句:真的需要吗

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

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

robots.txt 怎么读

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

sitemap.xml 是被低估的入口

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

robots 返回 404 怎么办

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

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

# Scrapy:ROBOTSTXT_OBEY = True + AUTOTHROTTLE_ENABLED = True
无视 robots 与限流,既不道德也更快触发封禁。
用库解析 robots 别手写正则:protegoProtego.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
  • 看不见的重复:「é」有组合式(1 个码点)和分解式(2 个码点)两种写法,肉眼一模一样但 ==False——先 NFC 归一才相等,不做这步去重会漏;
  • 编码残留(mojibake)ftfy.fix_text("schön") 还原成 schön
  • 空白re.sub(r"\s+", " ", s).strip()——网页里的不间断空格 \xa0 与全角空格都算 Python 的空白(\sstrip()split() 全认),所以这一行就够用;真正的坑是不归一时它与普通空格肉眼无异"a b" == "a\xa0b"False,而 NFKC 会把它折成普通空格。

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

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

清洗要可复现

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

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

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

裸 dict 的三个问题

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

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

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

抽取与校验要分开

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

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

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

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

按访问方式挑

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

两个必踩的坑

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

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

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

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

四节流水线

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

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

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

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

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

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

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

什么时候值得上

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

三条省钱与保真的规矩

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

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

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

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

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

工程与运维

能长期稳定跑、坏了能发现,才叫生产级爬虫。容错、监控、可复现的测试与部署,让爬虫从「跑一次的脚本」变成「活着的系统」——尤其别忘了:爬虫默默失效比报错更可怕。

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

按层兜底,别用一个大 try

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

断点续爬的最小实现

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

优雅退出

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

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

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

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

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

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

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

日志要能追单条 URL

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

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

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

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

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

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

用录制的真实响应做回归

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

闭环:坏了 → 告警 → 修复

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

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

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

从简到繁的三档

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

容器化的几个实用点

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

定时任务的两个经典坑

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

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

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

本地缓存有多值

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

三层缓存,各管一段

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

成本不只是带宽

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

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

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

把纯函数切出来

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

三层测试各测什么

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

别在单元测试里连真站点

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

现在你来:给 hello.py 补一个不上网的测试

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

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

从这里到精通:路线图

地图铺完了,剩下的路要亲手写出来。最后这一章给出收尾路线:难度递进的动手项目、按阶段的资料,以及一条自测标准。

爬虫是一门对抗中成长的手艺:教程里的站点永远是配合的,真实站点才会教你重试、指纹与限速。按下面的顺序走,每一步都有明确产出物,并且全程在合规边界内。

动手项目(难度递进)

  • ① 静态页抓取入库: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 章:给解析函数补一个不上网的测试;
  • 这七个练习加起来不到一个下午,但它们覆盖了本页最容易「读懂了却写不出来」的七处。
学习路线最常见的两个误区:一是把「教程站点全抓下来」当成练成了——配合你的练习站永远不会教你真实的反爬、指纹与限速;二是一头扎进 Playwright/分布式炫技,却跳过 HTTP、编码、解析和合规这些地基,遇到真实站点反而处处漏水。按顺序打地基,比追新工具更快到达精通。
一条自测标准:给你一个从未见过的网站,你能否在半小时内摸清它的渲染方式与反爬强度,给出技术选型(直连接口 / requests / Playwright / Scrapy)与合规评估,并抓下第一批结构化数据——能做到,这一页就毕业了。