Spider-第07讲-JSON接口与正则提取

第 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、带限速和自检的完整爬虫骨架。它不是一个玩具,改改选择器就能直接上真实项目。

相关推荐
2601_9679873742 分钟前
Sqlite参考手册
数据库·python·sqlite·api·教程
用户019027581611 小时前
A 股到价提醒机器人:AlphaFeed + 飞书/钉钉 Webhook
python
特立独行的猫A2 小时前
Ionic + 仓颉语言的 cj-tauri 开发框架实战:开发精美移动端 APP
java·后端·python
高洁012 小时前
数据同化与动态状态估计:让孪生持续贴合现实
人工智能·python·深度学习·transformer·迁移学习
feasibility.2 小时前
一物降一物?AI逆向没有终局:REA拆App,BLA设套,LASIK清场
人工智能·爬虫·github·逆向·skill·rea
通信仿真爱好者2 小时前
第【139】期--基于深度学习的OFDM信道估计方法研究--python完整代码
python·深度学习·ofdm·信道估计
威哥爱编程2 小时前
模拟面试智能体开发实战 07:答完一题就点评一题,结构化输出给分建议,让每道题都变成复盘
python·agent·harmonyos
cpolar技术支持3 小时前
中文文件名排序跟拼音对不上:os.listdir 返回的是文件系统顺序,三个中文名按字节排结果正好相反
python·文件系统·cpolar·编码·排序
Thanks_ks3 小时前
【第 008 讲】在终端中运行 Python 程序:交互模式与脚本模式 | 绝对路径与相对路径 | 以模块方式运行 | 常见报错的识别与排查
python·异常处理·终端·脚本·命令行·cmd·python 解释器