第 7 讲 · JSON 接口与正则:新手解析 HTML,老手找接口
本讲目标 :认清一个分水岭------优先吃接口 > 抠内嵌 JSON > 解析 HTML 。
学会用
r.json()直接拿结构化数据,看懂{code, msg, data}包装层,并给正则找到它真正该干的活。
配套代码 :
spiders/s07_json_regex.py运行 :
python spiders/s07_json_regex.py本讲产物:无文件落盘,控制台输出接口翻页结果与正则提取结果
---下载地址:git clone https://gitee.com/wuyikui555/python-spider.git
一、直接吃 JSON:比解析 HTML 高一个维度
先建立一个观念:页面上你看到的数据,绝大多数不是 HTML 里「长」出来的。
浏览器先拿到一份 HTML 骨架,然后用 JavaScript 再发一次(或几次)请求,把数据填进去。那几次请求返回的通常就是 JSON。你费半天劲去解析 HTML,其实是在啃别人饭后剩下的骨头------接口里那份 JSON,才是原始数据本身。
看靶场的列表接口,一次请求把第 3 页整个拿到:
python
r = requests.get(base_url + "/api/books",
params={"page": 3, "size": 5}, timeout=5)
print("Content-Type :", r.headers.get("Content-Type"))
payload = r.json() # 等价于 json.loads(r.text)
print("顶层结构 :", {k: type(v).__name__ for k, v in payload.items()})
print("code / msg :", payload["code"], "/", payload["msg"])
print("data 里的键 :", list(payload["data"].keys()))
实测:
Content-Type : application/json; charset=utf-8
顶层结构 : {'code': 'int', 'msg': 'str', 'data': 'dict'}
code / msg : 0 / ok
data 里的键 : ['page', 'total_pages', 'total', 'list']
三个信息点:
① Content-Type 是 application/json。 这是「我该用 r.json() 还是 BeautifulSoup」的判据。拿到响应先看这一行,比什么都省事。
② r.json() 等价于 json.loads(r.text) 。requests 帮你做了两件事:检查能不能解析、按 JSON 规范解码。注意它不会 帮你判断成功失败------解析不出合法 JSON 会直接抛 JSONDecodeError。所以生产代码里用之前,最好先确认 Content-Type 确实是 JSON。
③ 顶层是 {code, msg, data}。 数据被包在 data 里,data 里才有 list。这就是「包装层」,下一节专门讲它的坑。
直接遍历 list:
python
for b in payload["data"]["list"]:
print(f" {b['id']:>3} {b['title']:20s} {b['author']:6s} ¥{b['price']}")
第 3 页内容:
11 重构操作系统 陈思远 ¥116.1
12 从入门到精通量子物理 刘雨桐 ¥73.7
13 漫谈神经网络 孙嘉禾 ¥94.5
14 漫谈唐诗鉴赏 张明远 ¥122.2
15 深入理解微服务架构 冯晓岚 ¥34.5
这份数据和第 5 讲从 HTML 里抠出来的,是同一批书。 差别在获取方式:
| 维度 | 解析 HTML | 直接吃 JSON |
|---|---|---|
| 有没有标签要洗 | 有,get_text 一堆 |
没有,本身就是字典 |
| 价格是什么类型 | 字符串 '¥116.1'(要 float()) |
数字 116.1 |
| 改版的脆弱性 | class 一改就挂 | 字段名稳定得多 |
| 响应体积 | 14KB(第 2 讲量过) | 1.1KB |
| 你要写的代码 | select / xpath 一长串 |
payload["data"]["list"] |
换成真实站点时 :接口地址不会写在页面上。打开 F12 → Network → 过滤
XHR/Fetch→ 刷新页面,找到那个返回 JSON 的请求,右键 Copy as cURL,把 URL 和参数抄进代码即可。看一眼 Network 面板,往往能省掉一半工作量。
二、包装层:HTTP 200 不代表拿到数据
真实接口几乎不会把裸数据直接返回,而是套一层 {code, msg, data}。这层壳是有意的,它让服务器能在「HTTP 层面成功」的前提下,表达「业务层面失败」。
请求一本不存在的书:
python
r = requests.get(base_url + "/api/book/99999", timeout=5)
print("请求不存在的书 → HTTP", r.status_code)
print("返回体 →", r.text.strip())
data = r.json()
if data.get("code") != 0:
print(f"★ 用业务码判断:code={data['code']},这次没拿到数据,直接返回")
实测:
请求不存在的书 → HTTP 404
返回体 → {"code": 404, "msg": "not found"}
★ 用业务码判断:code=404,这次没拿到数据,直接返回
这里要诚实标注一个差异 :本靶场很规矩,查无此书时 HTTP 状态码就是 404 ,code 也是 404,两边一致。
但大量国内接口不是这样的 ------它们会返回 HTTP 200,body 是:
json
{"code": 10001, "msg": "资源不存在", "data": null}
这就是本讲的核弹级教训:
只检查
status_code == 200的爬虫,会把「查无此书」当成「爬到了」。
它不会报错。你的代码一路绿灯,然后把 data = None 塞进数据库,或者更糟------把 {"code": 10001} 当成一条图书记录存下来。等你发现时,脏数据已经混进结果里了。
正确的判断顺序永远是:
python
resp = requests.get(url, timeout=8)
if resp.status_code != 200:
return None # 传输层就失败了
data = resp.json()
if data.get("code") != 0: # 业务层失败(本靶场 0 才是成功)
log.warning("接口返回业务失败:%s", data.get("msg"))
return None
return data["data"]
注意
code的成功值因站而异。 本靶场是0,支付宝开放平台是10000,很多 REST API 干脆不用 code 而用errno。去接一个陌生接口时,第一件事是确认「哪个 code 表示成功」 ,别默认 0。utils/parse.py的parse_api_books()就是先取data再取list,data为None时安全返回([], 1):
python
def parse_api_books(payload: dict) -> tuple[list[dict], int]:
"""解析 /api/books 的返回,返回 (当页数据, 总页数)"""
data = payload.get("data") or {}
return data.get("list") or [], int(data.get("total_pages") or 1)
三、翻页:total_pages 是最可靠的依据
列表页要翻页,接口也要翻页。接口翻页最怕一件事:页数写死。
python
page, total_pages, got = 1, 1, []
while page <= total_pages:
payload = requests.get(base_url + "/api/books",
params={"page": page, "size": 20}, timeout=5).json()
lst = payload["data"]["list"]
total_pages = payload["data"]["total_pages"] # ← 每翻一页都更新它
got.extend(lst)
print(f" 第 {page}/{total_pages} 页 → {len(lst)} 条")
page += 1
print(f"合计 {len(got)} 条,id 范围 {got[0]['id']} ~ {got[-1]['id']}")
实测(size=20):
第 1/3 页 → 20 条
第 2/3 页 → 20 条
第 3/3 页 → 8 条
合计 48 条,id 范围 1 ~ 48
这里面藏着两个容易翻车的边界。
边界一:别写死页数
python
for page in range(1, 6): # ❌ 反面教材
这行代码在「今天」是对的(5 页),明天上架新书变 6 页就漏掉一整页数据 ;反过来下架一批变 3 页,后两页请求全是空的。页数是会被运营改的,只能读,不能猜。
注意上面 size=20 时 total_pages 是 3 ,不是 5。而第 2、5、8 讲的列表页是每页 10 本、共 5 页 。同一批 48 本书,页数取决于 size:
size |
total |
total_pages |
最后一页条数 |
|---|---|---|---|
| 10 | 48 | 5 | 8 |
| 20 | 48 | 3 | 8 |
| 48 | 48 | 1 | 48 |
48 / 20 = 2.4,向上取整才是 3。 服务器已经替你算好了 total_pages,直接用,别自己 total // size------整除会漏掉最后一页,total // size + 1 又在整除时多出一页空页。
边界二:data 可能是 None
如果接口在越界翻页时返回 {"code": 0, "data": null},payload["data"]["list"] 会直接 TypeError。这就是 parse_api_books() 要写 payload.get("data") or {} 的原因------每个 .get() 后面跟一个 or 兜底,是吃接口的肌肉记忆。
四、正则:不是用来解 HTML 的,是用来抠零碎东西的
先把立场说清楚:不要用正则去解析整个 HTML。 嵌套标签是正则的天敌,写出来的表达式长到没人看得懂,还一改就崩。HTML 解析交给 BeautifulSoup / lxml。
但有些场合正则无可替代------从一个已知格式的字符串里,抠出一个小小的值 。看靶场详情页(/book/12)的五个场景:
python
patterns = {
"书名": r'<h1 id="book-title">([^<]+)</h1>',
"价格": r'class="price">价格:¥([\d.]+)',
"库存数字": r'库存:(\d+)',
"所有标签": r'<li>(畅销|新书|经典|赠源码|彩印|签名版)</li>',
"简介字数": r'<div class="desc">(.{0,30})',
}
for name, pat in patterns.items():
m = re.search(pat, html)
print(f" {name:8s} : {m.group(1) if m else '(没匹配到)'}")
实测:
书名 : 从入门到精通量子物理
价格 : 73.7
库存数字 : 113
所有标签 : 彩印
简介字数 : 《从入门到精通量子物理》由冯晓岚撰写,属于文学类目。本书从最
三条使用纪律,逐条对照实测:
① 不要解整个 HTML。 上面抽的都是单行、格式固定 的小字段(<h1 id="book-title">、库存:),不是整个文档。这是正则的舒适区。
② 一定要加边界,别写 .*。 看 书名 那条:([^<]+) 里的 [^<]+ 意思是「一堆非 < 的字符」------明确告诉正则「到 < 就停」 。如果写成 .*,它会贪婪地一路吃到整段 HTML 的最后一个 </h1>(如果有的话),或者吃到行尾。
贪婪匹配 是正则第一天坑:
.*默认「能多拿就多拿」。[^<]+/.*?(非贪婪)才是你真正想要的「拿一点点」。
③ re.search 只返回第一个匹配。 这就是输出里 所有标签 : 彩印 只有一个 的原因------/book/12 显然有多个标签,但 re.search 命中第一个就收工了。
这是一个真实的观察,不是失误 :源码故意用 re.search,于是只拿到第一个标签 彩印。要拿全部,得换 re.findall:
python
all_tags = re.findall(r'<li>(畅销|新书|经典|赠源码|彩印|签名版)</li>', html)
# → ['彩印', '畅销', ...] 全部标签
| 你要 | 用 | 返回 |
|---|---|---|
| 第一个匹配 | re.search(pat, s) |
Match 或 None |
| 全部匹配 | re.findall(pat, s) |
liststr |
| 从头必须匹配 | re.match(pat, s) |
Match 或 None |
换成真实站点时 :正则最适合的是「内嵌在
<script>里的 JSON」「URL 里的 ID」「形如2026-10-13的日期」。凡是需要「理解标签嵌套」的活,一律交给解析器。另外记得用原始字符串r'...'------\d在普通字符串里是退格符,不写r你会拿到一个「看起来对、其实错」的正则。
五、从 <script> 里抠 JSON:SPA 的典型特征
有一类页面,HTML 里干干净净什么都没有,数据全在 JavaScript 变量里。这就是 SPA(单页应用)的常见形态。
源码里模拟了这种结构,用正则把那段 JSON 抠出来再解析:
python
fake_js = ('<script>window.__DATA__ = {"total": 48, "page": 1, '
'"items": [{"id": 1, "score": 4.5}]};</script>')
m = re.search(r"window\.__DATA__\s*=\s*(\{.*?\});", fake_js)
if m:
parsed = json.loads(m.group(1))
print("从 <script> 里抠出 JSON:", parsed)
实测:
从 <script> 里抠出 JSON: {'total': 48, 'page': 1, 'items': [{'id': 1, 'score': 4.5}]}
这段表达式值得拆开看,它同时用到了前面所有纪律:
| 片段 | 作用 |
|---|---|
window\.__DATA__ |
. 要转义成 \.,否则它会匹配任意字符 |
\s*=\s* |
容忍等号两边的空格 |
(\{.*?\}) |
非贪婪 .*? 配 } 边界,正好卡在第一个 }; 停住 |
; |
明确告诉正则 JSON 到分号结束 |
re.S(这里没用但常要) |
真实页面 JSON 常有换行,跨行匹配要加 re.S |
如果
.*?写成.*会怎样? 它会一路贪婪到最后一个},把后面标签里的内容也吃进来,json.loads直接报JSONDecodeError。这就是为什么「非贪婪.*?」几乎总是配合一个明确的收尾符号一起用。
当然,抠 <script> 里的 JSON 是「能拿到源码时的办法」。如果数据是页面加载后 才由 JS 请求、或者要点击才加载的(靶场的 /dynamic、/dynamic2),requests 拿到的源码里根本没有这段数据------那就得上 Selenium(第 16 讲)。
换成真实站点时 :优先顺序永远是 ① 找接口(Network 面板)→ ② 抠
<script>里的内嵌 JSON → ③ 解析 HTML。只有到了「JS 动态渲染、接口又做了签名加密」这一步,才轮到 Selenium。
六、本讲小结
| 能力 | 一句话要点 |
|---|---|
| 吃接口 | Content-Type: application/json 就用 r.json(),比解析 HTML 快且稳 |
| 包装层 | 先判 status_code,再判业务 code;HTTP 200 ≠ 拿到数据 |
| 翻页 | 读接口的 total_pages,永远别写死页数;注意 total // size 会漏最后一页 |
| 兜底 | payload.get("data") or {},每个 .get() 后面跟一个 or |
| 正则边界 | 用 [^<]+ / .*?,别用 .*(贪婪会吃到底) |
| 搜 vs 找全 | 一个用 re.search,全部用 re.findall |
| 内嵌 JSON | <script> 里的 JSON 用非贪婪正则 + json.loads,是 SPA 的突破口 |
下一讲
三讲的解析课到这里结束了。你已经集齐了全部工具:BeautifulSoup 好写、XPath 精确、JSON 接口省事、正则抠零碎。
但从第 3 讲到现在,我们一直在「单点练习」------一次只抓一个页面、只解析一个字段。
真正的爬虫不是这样的。它要翻完所有页、逐条钻进详情页、把几千条数据去重、落盘、跑完还能给你一份统计。
下一讲 :把前面七讲的全部零件组装成一台能干活的机器 ------一个从列表页翻页、进详情页补字段、去重、落 CSV/JSON、带限速和自检的完整爬虫骨架。它不是一个玩具,改改选择器就能直接上真实项目。