爬虫-JSONPath
这篇讲什么
- 装包先判定:PyPI 上有个同名老包
jsonpath,最后一版停在 2023-08,实测它「没匹配」和「表达式写错」都返回False;能用的路线是jsonpath-ng1.16.0(本机 Python 3.14.10)。 - 语法面按「根 → 子节点 → 下标/切片/并集 → 过滤器 → 递归下降」过一遍,每条表达式都在 scrape.center 的电影接口上逐字跑过,写错报什么也一并贴出来。
json模块一节讲完:ensure_ascii、separators、default、重复键、NaN、BOM、strict=False、hook 参数,全部实测。- 落地三件事:JSONPath 与 XPath 的对照(同一份数据两边逐条比对)、前端 JS 的等价写法(本机 Node 跑过)、一份翻完全站 104 条再导出 CSV 的脚本。
装哪个包
本机装的是 jsonpath-ng,实测:
1 | pip show jsonpath-ng |
1 | Name: jsonpath-ng |
同名老包 jsonpath(没有 -ng)也还能装,用 --dry-run 看它从哪来:
1 | pip install --dry-run --no-cache-dir jsonpath |
1 | Collecting jsonpath |
两个包的 PyPI 元数据,实测:
1 | jsonpath 共 7 版 最后一版文件 jsonpath-0.82.2.tar.gz 上传于 2023-08-24T18:57:55 requires_python='' |
- 老包一共只发过 7 版,0.82 到 0.82.2 之间隔了 4 年,而且只发 tar.gz、没有 wheel,装的时候 pip 要现场 build。
- jsonpath-ng 最新版是几天前发的,声明支持 Python >= 3.11,本机 3.14.10 在范围内。
1 | python -m pip install --no-deps --target E:\Desktop\Test\jp-pk jsonpath |
老包在 3.14 上能 import,但行为不能用于工程。把它单独装进一个隔离目录(主环境不装它)跑出来的结果:
1 | # 老包 jsonpath(不是 jsonpath-ng),单独装在隔离目录里跑,主环境不装 |
1 | 版本号: 0.82.2 | 模块里的名字: ['jsonpath', 'normalize', 'print_function', 're', 'sys', 'xrange'] |
| 项目 | jsonpath 0.82.2 | jsonpath-ng 1.16.0 |
|---|---|---|
| 没匹配 | 返回 False,取下标就 TypeError |
返回 [],可以放心迭代 |
| 表达式写错 | 同样返回 False,静默 |
解析阶段抛 JsonPathParserError |
| 引号形式 | 只认单引号,$["k"] 返回 False |
单双引号都认 |
| 取回的东西 | 值的列表 | 匹配对象列表,带 path / full_path |
| 维护状态 | 2023-08 最后一版,只有 sdist | 活跃,声明支持 3.11+ |
结论:新代码一律 pip install jsonpath-ng。老包只在接手很旧的脚本时才会碰到,而「没匹配返回 False」会把判空逻辑一路带错——if ret: 走 else,ret[0] 直接崩。
三个入口
1 | import requests |
1 | 表达式对象: Child(Child(Child(Root(), Fields('results')), Wildcard()), Field |
网上教程常说的「三个入口」,在 1.16.0 上实测只剩两个半:
jsonpath_ng.parse(表达式):基础语法入口,通配、下标、切片、并集都支持,但过滤器?(...)在词法阶段就报错。jsonpath_ng.ext.parse(表达式):扩展语法入口,过滤器、&&、=~都在这边。表达式里出现?就必须从ext导入,两个入口返回的是同一种对象。DatumInContext:不是入口,是find()返回的匹配对象的类型,取数据不需要手动构造它。ExtDict:这个类已经没了,实测ImportError。老教程里「先把 dict 转成 ExtDict 再 find」这一步现在不用做,find()自己就能给出full_path。
另外两点:jsonpath_ng.__version__ 实测吃一条 DeprecationWarning,官方替代是 importlib.metadata.version("jsonpath-ng");安装名写 jsonpath-ng(连字符),导入名写 jsonpath_ng(下划线)。
常用表达式
1 | import requests |
1 | $.results[*].name 10 条 ['霸王别姬', '这个杀手不太冷', '肖申克的救赎'] |
- 四条地基:
$是整份数据;.k和['k']都是取字段;[*]是「列表的每一项」或「dict 的每个值」;..是递归下降,不管隔几层。 - 下标从 0 开始,切片和 Python 一致:
[1:3]是第 2、3 条,[0,1]是并集,[-1]取最后一条(后文 XPath 那一节里[-1]和last()逐条比对过)。 - 取不到不报错,返回空列表:越界
[99]是[],字段不存在[?(@.rank)]也是[]。rank这个键列表接口没有,单条详情接口里才有,同一份数据换个端点字段集就变了。 - 值本身是列表时加不加
[*]结果不同:$.results[0].categories拿到 1 个匹配(整个列表),$.results[0].categories[*]拿到 2 个。新手问得最多的「为什么多套一层」就是这条。 $.results[*].regions[*]是 11 条而不是 10 条:regions本身是列表,第一页里有片子带两个地区,展平后条数比记录数多。- 过滤器
?(...)实测可用:>、<、==、!=、&&、||、=~(正则)、算术(@.minute*2>300)、存在性(?(@.categories))。实测不可用:in和!(...),两者都报Parse error,见下一节。 - 键名特殊就换引号形式:
$['a.b']取的是键名叫a.b的字段,$.a.b取的是a下的b,实测前者有值后者为空。中文键必须加引号,$.书名直接报词法错误。
表达式写错了
1 | import time |
1 | '$.results[)' JsonPathParserError: Parse error at 1:10 near token ) ()) |
- 语法错在
parse()这一步就抛出,不拖到find(),所以启动时把表达式统一 parse 一遍能提前发现问题。 - 异常两类,都继承
jsonpath_ng.exceptions.JSONPathError:JsonPathParserError(语法错)和JsonPathLexerError(出现不认识的字符,比如中文键没加引号)。except 抓JSONPathError一网打尽。 - 报错自带位置,
Parse error at 1:10就是第 1 行第 10 列,数列字符能定位。 - 最危险的是「能解析但取不到」:
$.results.name少写[*],一声不响返回[]。写完先跑一次看条数,别直接上线。 - 老教程取最后一条的
$.results[(@.length-1)]在 jsonpath-ng 里是语法错误(上面第三行),改成[-1]才对。 parse()不便宜:2000 次 parse 要 0.7 秒上下,复用同一表达式 find 2000 次只有几十毫秒(最后一行实测)。循环里的表达式要在循环外先 parse 好。
匹配对象与回写
1 | import requests |
1 | 第一个匹配: value=9.5 path=score full_path=results.[0].score |
find()返回DatumInContext的列表,三个属性够用:.value:真正的值,类型原样保留(9.5是 float,整条记录是 dict)。.path:这一段的路径,字段是Fields、下标是Index。.full_path:从数据根算起的完整路径,实测长这样results.[0].score(下标前的点是它的显示风格,不是能粘回表达式的写法)。
- 要把「过滤器命中的行」映射回原列表,取下标最稳:
m.path.indices[0]在?(@.score>9.4)上给[0, 1, 2, 3, 4, 5, 6, 7],正好是 8 条 9.5 分。 - 多字段取数别拼三个平行列表,直接
$.results[*]拿整条记录再按字段取,实测就是上面那两行,条数和顺序天然对齐。 - 两个实测的坑:
finditer()在 1.16.0 上没有(hasattr是 False),只有find()返回列表。- 并集里写带点的嵌套字段会静默丢掉:详情接口的
actors是对象数组,$['name','actors.name']实测只取到 name,另一个字段一个没取到,也不报错。并集里只放平铺字段名。
update()与update_or_create()的差别就在「键不存在时」:前者什么都不做(实测取到None),后者补上(实测得到7)。纯爬取用不到,做数据修补时才有用。
对照 XPath
把 spa1 接口那份 JSON 按「字段名当标签、列表元素当 <item>」转成同结构 XML,两边各取一遍:
1 | from xml.etree import ElementTree as ET |
1 | 转出的 XML: <root><count>104</count><results><item><id>1</id><name>霸王别姬</name><alias>Farewell My Con…… |
- 六组全一致,但差别必须记牢:
- 下标起点不同:XPath 从 1 开始(
item[1]是第一条),JSONPath 从 0 开始([0]);取末尾分别写last()和[-1]。 - XPath 取文本要显式写
text(),不写拿到的是节点对象;JSONPath 的.value直接就是 Python 值。 - 类型:为了公平比对,上面两边都
str()过。真实情况下 XPath 出来的是'9.5'字符串,JSONPath 是9.5(float);从 HTML/XML 取数要自己转类型,从 JSON 取不用。 - 函数与轴不通用:
count()、text()、ancestor::是 XPath 的,$、?()、=~是 JSONPath 的,别指望改改符号就两边跑。
- 下标起点不同:XPath 从 1 开始(
- 结构对应关系:JSON 的列表在 XML 里是重复的同名
<item>,所以 XPath 表达式里多出一层item,而 JSONPath 用[*]或下标穿过列表。上面第六组的categories就是这种两层结构。
json 读写与容错
四个函数分工:loads / dumps 在字符串与 Python 对象之间转,load / dump 直接对着文件对象。
1 | import json |
1 | read() 类型: bytes -> loads 类型: dict | loads 收 bytearray: {'a': 1} |
dumps/dump的参数(默认值全部实测):ensure_ascii=True:非 ASCII 一律转\uXXXX,中文出来就是"\u4e2d\u6587"。给人看的 JSON 就写ensure_ascii=False。indent=None:默认压成一行;给整数是缩进空格数,给字符串(如"\t")用它当缩进。separators=(', ', ': '):默认逗号冒号后带空格,传(',', ':')去掉。实测 spa1 接口原样 2765 字节就是「不转义中文 + 紧凑分隔符」的写法,dumps默认反而涨到 3372 字节。sort_keys=False:True时按键排序,写进 Git 的 JSON 用它减少无意义差异。default=None:不认识的对象直接TypeError,传函数兜底(实测sorted能救 set)。skipkeys=False:键不是 str/int/float/bool/None 时抛TypeError,True时跳过该键。allow_nan=True:会写出NaN/Infinity,这不是合法 JSON;设False抛ValueError。
- 类型映射实测:dict 的 int 键变字符串、
True和1在 dict 里本来就是同一个键(实测{True: "yes", 1: "one"}只剩{"true": "one"})、tuple变数组。 loads的入参可以是str、bytes、bytearray,所以resp.read()拿到的 bytes 直接喂进去就行,不必先.decode();传 int 报TypeError。load/dump要文件对象,文本模式和二进制模式实测都能读。- 脏数据的实测结论:
NaN/Infinity默认是接受的,可它们不是合法 JSON;loads没有allow_nan参数(实测TypeError),要拒绝只能传parse_constant。- 重复键默认最后一个赢;要看全部键值对用
object_pairs_hook,它收到的参数是[(键, 值), ...]。 parse_float/parse_int能换掉默认的float/int构造器(金额建议换Decimal),object_hook在每个 dict 建好后再包一层。strict=False只解决「字符串里有裸控制字符」这一件事;单引号、尾逗号、JSONP,它一个都救不了。- JSONP 用
re.search(r"\{.*\}", text)抠出大括号再 loads,实测有效;老教程的strip('() \n\t\r')没用,回调名cb不在被删字符集里,实测仍报Expecting value。 - BOM 处理不一致:
bytes带 BOM 能直接 loads,str带 BOM 报错,改用open(path, encoding="utf-8-sig")读。
- 报错对象是
json.JSONDecodeError(ValueError子类),自带msg/doc/lineno/colno/pos;把出错那段一起写进日志,比反复猜哪个接口坏了快得多。
前端等价写法
浏览器里不需要 JSONPath:JSON.parse 之后就是普通对象,数组方法足够。本机 Node v26.13.0 直连同一个接口跑出来的是:
1 | import { inspect } from "node:util"; |
1 | content-type: application/json |
- 对应关系,结果与前面 Python 的实测一致:
$.results[*].name==movies.map(m => m.name)$.results[?(@.score>9.4)].name==movies.filter(m => m.score > 9.4).map(m => m.name)$['results'][0]['name']==obj.results[0].name
- 三个语言差异:
- JS 的
JSON.stringify不转义中文(实测{"n":"霸王别姬","s":9.5}),Python 的dumps默认转义成\uXXXX——这就是「前端存的文件看着正常、Python 存的一堆转义」的原因。 - JS 只有一种 number,接口里的
9.0实测在 JS 里打印成9,Python 里同一份数据是9.0(float),minute是int。 - 缺失字段两边都取不到,但报错不一样:JS 拿到
undefined,再往下取一层实测报TypeError,Python 对应dict.get("rank")返回None、["rank"]实测报KeyError(见上一节倒数第二行)。
- JS 的
- 没有实测的部分:前端专门的 JSONPath 库(jsonpath-plus 一类)本机没装没跑,浏览器端 JSONPath 的支持情况不作结论。这一节只覆盖
JSON.parse+ 数组方法这条验证过的路。
实战:翻完全站
先摸清分页参数再谈翻页,这个接口只认 limit / offset:
1 | import requests |
1 | (不给参数) 200 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] |
- 参数名猜错不会报错,只会静默返回第一页:
?page=2的实测状态码是 200,results里的 id 还是 1–10。 - 猜错的写法不止一种:
page=0、page=-1、page=abc、干脆不给参数,实测四种拿到的都是 id 1–10。 - 真正往后翻的是
?offset=10,id 从 11 开始;每页几条由limit说了算,?offset=10&limit=5给 id 11–15。 - 越界同样不报错:
?offset=200还是 200,只是results变成空列表,所以循环只能判results空不空,判状态码等不到尽头。 - 全站
count是 104 条,limit=10翻完要 11 次请求,再加一次空页确认;limit=200实测一次抱回全部 104 条。
翻完全站并导出 CSV:
1 | import csv |
1 | offset=110 返回 0 条,results 空了就停 |
- 落地要点:
- 翻页用
limit/offset,结束条件判results空不空,不判状态码。 fetch()把urlopen(...).read()直接喂给json.loads:读出来是 bytes,别str(resp),那是对象的字符串表示,不是 JSON。- 三个表达式先比长度再
zip:接口少给一个字段时三个列表不等长,zip会静默截断,宁可显式报错。 - 尾部 4 条(id 105–108)是站主留下的测试数据,末 3 条片名是
admin、测试、中文,score和regions都是null。 "、".join(a or [])是给regions兜底:不兜住None,实测就报TypeError: can only join an iterable。csv的open一定带newline="":实测忘带时 105 行全变成\r\r\n,文件从 3524 字节涨到 3629 字节。encoding="utf-8-sig"写出的头三个字节实测是b'\xef\xbb\xbf'(BOM),用途是让中文 Windows 的 Excel 认出 UTF-8。
- 翻页用
JSON 接口优先
同一个练习站既给 HTML 也给 JSON,两种取法一起实测:
1 | import re |
1 | spa1 首页 text/html 952 字节 |
-
判断「该走接口还是该解析 HTML」,按这个顺序:
- 浏览器 DevTools 的 Network 面板筛 Fetch/XHR,看候选请求的
Content-Type:application/json是接口,text/html是页面。上面前三行就是实测的三种响应头。 - 页面渲染有数据但 HTML 里找不到,就顺着脚本找接口:spa1 首页 952 字节里只有一个空
<div id=app>,parsel实测取不到片名,接口路径藏在它加载的打包脚本里,实测先抠出/js/app.*.js的地址、再在里面正则找出/api/movie。 - 拿到候选 URL 先访问一次,能粘出一段 JSON 就定型。
- 浏览器 DevTools 的 Network 面板筛 Fetch/XHR,看候选请求的
-
优先接口的三条实测理由:
- 拿得到:同一时刻 spa1 首页 0 条、接口 10 条,HTML 那条路必须等浏览器渲染。
- 数据更全:列表接口一次给
count和 10 条完整记录,字段是id / name / alias / cover / categories / published_at / minute / score / regions;页面只把片名和评分显示出来。 - 更稳:HTML 换个 class 名或调一下属性顺序就取不到值;接口字段名的变更是「版本」级的,一般有兼容期。
-
requests的resp.json()内部就是调json.loads,所以上一节的json容错结论对它同样适用;对 HTML 响应调它,实测抛requests.exceptions.JSONDecodeError。 -
工具分工:
数据长什么样 用什么 详见 接口返回 JSON jsonpath-ng+json本篇 HTML 里的结构字段,层级复杂 XPath(lxml / parsel) 《爬虫-XPath》 HTML 里要按文本找节点、要改文档 BeautifulSoup 《爬虫-BeautifulSoup》 纯文本里抽手机号 / 日期 / 金额 正则 《爬虫-正则》 接口要登录、带签名,拿不到 浏览器渲染后再取 《爬虫-Selenium和PhantomJS》
参考
- jsonpath-ng 官方文档
- jsonpath-ng 源码仓库
- jsonpath-ng 在 PyPI 上的页面
- 老包 jsonpath 在 PyPI 上的页面
- RFC 9535:JSONPath — Query Expressions for JSON
- Python 官方文档:json 模块
- MDN:JSON.parse()
- scrape.center:《Python3 网络爬虫开发实战》作者搭的公开练习站,专门给爬虫练手,本篇的接口示例都打在它的
spa1子站上。