爬虫-JSONPath

这篇讲什么

  • 装包先判定:PyPI 上有个同名老包 jsonpath,最后一版停在 2023-08,实测它「没匹配」和「表达式写错」都返回 False;能用的路线是 jsonpath-ng 1.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
2
3
Name: jsonpath-ng
Version: 1.16.0
Summary: A final implementation of JSONPath for Python that aims to be standard compliant, including arithmetic and binary comparison operators and providing clear AST for metaprogramming.

同名老包 jsonpath(没有 -ng)也还能装,用 --dry-run 看它从哪来:

1
pip install --dry-run --no-cache-dir jsonpath
1
2
3
4
5
6
7
8
9
Collecting jsonpath
Downloading jsonpath-0.82.2.tar.gz (10 kB)
Installing build dependencies: started
Installing build dependencies: finished with status 'done'
Getting requirements to build wheel: started
Getting requirements to build wheel: finished with status 'done'
Preparing metadata (pyproject.toml): started
Preparing metadata (pyproject.toml): finished with status 'done'
Would install jsonpath-0.82.2

两个包的 PyPI 元数据,实测:

1
2
jsonpath     共 7 版  最后一版文件 jsonpath-0.82.2.tar.gz  上传于 2023-08-24T18:57:55  requires_python=''
jsonpath-ng 共 21 版 最后一版文件 jsonpath_ng-1.16.0.tar.gz 上传于 2026-11-04T14:37:02 requires_python='>=3.11'
  • 老包一共只发过 7 版,0.82 到 0.82.2 之间隔了 4 年,而且只发 tar.gz、没有 wheel,装的时候 pip 要现场 build。
  • jsonpath-ng 最新版是几天前发的,声明支持 Python >= 3.11,本机 3.14.10 在范围内。
1
2
3
python -m pip install --no-deps --target E:\Desktop\Test\jp-pk jsonpath
$env:PYTHONPATH = "E:\Desktop\Test\jp-pk"
python -X utf8 jp_old_jsonpath.py

老包在 3.14 上能 import,但行为不能用于工程。把它单独装进一个隔离目录(主环境不装它)跑出来的结果:

jp_old_jsonpath.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 老包 jsonpath(不是 jsonpath-ng),单独装在隔离目录里跑,主环境不装
import jsonpath

DATA = {
"store": {
"book": [
{"author": "路遥", "title": "平凡的世界", "price": 8.95},
{"author": "二月河", "title": "康熙大帝", "isbn": "0-553-21311-3", "price": 28.99},
],
"bicycle": {"color": "red", "price": 19.95},
}
}

print("版本号: %s | 模块里的名字: %s" % (
jsonpath.__version__, [x for x in dir(jsonpath) if not x.startswith("_")]))
for expr in ("$.store.book[*].author", "$.store.nosuch", "$.store.book[)",
"$['store']['bicycle']['color']", '$["store"]["bicycle"]["color"]'):
print("%-38s -> %r" % (expr, jsonpath.jsonpath(DATA, expr)))

ret = jsonpath.jsonpath(DATA, "$.store.nosuch")
print("没匹配时 ret =", repr(ret))
print("ret[0] ->", end=" ")
try:
ret[0]
except Exception as exc:
print("%s: %s" % (type(exc).__name__, exc))
1
2
3
4
5
6
7
8
版本号: 0.82.2 | 模块里的名字: ['jsonpath', 'normalize', 'print_function', 're', 'sys', 'xrange']
$.store.book[*].author -> ['路遥', '二月河']
$.store.nosuch -> False
$.store.book[) -> False
$['store']['bicycle']['color'] -> ['red']
$["store"]["bicycle"]["color"] -> False
没匹配时 ret = False
ret[0] -> TypeError: 'bool' object is not subscriptable
项目 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] 直接崩。

三个入口

jp_entry.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import requests

from jsonpath_ng import parse
from jsonpath_ng.ext import parse as ext_parse

API = "https://spa1.scrape.center/api/movie"
DATA = requests.get(API, timeout=20).json()

print("表达式对象:", repr(ext_parse("$.results[*].name"))[:64])
for fn, name in ((parse, "parse"), (ext_parse, "ext.parse")):
for e in ("$.results[1:3].name", "$.results[?(@.score>9.4)].name"):
try:
print("%-9s %-38s %s" % (name, e, [m.value for m in fn(e).find(DATA)]))
except Exception as exc:
print("%-9s %-38s %s: %s" % (name, e, type(exc).__name__, str(exc)[:52]))
try:
from jsonpath_ng import ExtDict
except ImportError as exc:
print("导入 ExtDict ->", "%s: %s" % (type(exc).__name__, str(exc)[:56]))
from jsonpath_ng import DatumInContext
print("DatumInContext:", DatumInContext.__name__)
1
2
3
4
5
6
7
表达式对象: Child(Child(Child(Root(), Fields('results')), Wildcard()), Field
parse $.results[1:3].name ['这个杀手不太冷', '肖申克的救赎']
parse $.results[?(@.score>9.4)].name JsonPathLexerError: Error on line 1, col 10: Unexpected character: ?
ext.parse $.results[1:3].name ['这个杀手不太冷', '肖申克的救赎']
ext.parse $.results[?(@.score>9.4)].name ['霸王别姬', '这个杀手不太冷', '肖申克的救赎', '泰坦尼克号', '罗马假日', '唐伯虎点秋香', '乱世佳人', '喜剧之王']
导入 ExtDict -> ImportError: cannot import name 'ExtDict' from 'jsonpath_ng' (D:\Prog
DatumInContext: DatumInContext

网上教程常说的「三个入口」,在 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(下划线)。

常用表达式

jp_find.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import requests

from jsonpath_ng.ext import parse as ext_parse

API = "https://spa1.scrape.center/api/movie"
DATA = requests.get(API, timeout=20).json()
WEIRD = {"a.b": {"c d": 1}, "书名": "三国演义"}

EXPRS = ["$.results[*].name", "$.results[0].name",
"$.results[1:3].name", "$.results[0,1].name", "$.results[*].regions[*]",
"$.results[?(@.score>9.4)].name", "$.results[?(@.name =~ '杀')].name",
"$..name", "$.results[?(@.rank)].name", "$.results[99].name",
"$.results[0].categories", "$.results[0].categories[*]",
"$.results[?(@.minute*2>300)].name", "$.results[?(@.score>9 && @.minute<150)].name",
"$.results[?(@.score>9 || @.minute>170)].name", "$.results[?(@.score==9.5)].name",
"$.results[?(@.score!=9.5)].name", "$.results[?(@.categories)].name"]

for e in EXPRS:
got = [m.value for m in ext_parse(e).find(DATA)]
print("%-46s %2d 条 %s" % (e, len(got), repr(got[:3])[:56]))

for e in ["$.a.b", "$['a.b']", "$['书名']"]:
print("%-16s %s" % (e, [m.value for m in ext_parse(e).find(WEIRD)]))
try:
ext_parse("$.书名")
except Exception as exc:
print("%-16s %s: %s" % ("$.书名", type(exc).__name__, str(exc)[:52]))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$.results[*].name                              10 条 ['霸王别姬', '这个杀手不太冷', '肖申克的救赎']
$.results[0].name 1 条 ['霸王别姬']
$.results[1:3].name 2 条 ['这个杀手不太冷', '肖申克的救赎']
$.results[0,1].name 2 条 ['霸王别姬', '这个杀手不太冷']
$.results[*].regions[*] 11 条 ['中国内地', '中国香港', '法国']
$.results[?(@.score>9.4)].name 8 条 ['霸王别姬', '这个杀手不太冷', '肖申克的救赎']
$.results[?(@.name =~ '杀')].name 1 条 ['这个杀手不太冷']
$..name 10 条 ['霸王别姬', '这个杀手不太冷', '肖申克的救赎']
$.results[?(@.rank)].name 0 条 []
$.results[99].name 0 条 []
$.results[0].categories 1 条 [['剧情', '爱情']]
$.results[0].categories[*] 2 条 ['剧情', '爱情']
$.results[?(@.minute*2>300)].name 3 条 ['霸王别姬', '泰坦尼克号', '乱世佳人']
$.results[?(@.score>9 && @.minute<150)].name 5 条 ['这个杀手不太冷', '肖申克的救赎', '罗马假日']
$.results[?(@.score>9 || @.minute>170)].name 8 条 ['霸王别姬', '这个杀手不太冷', '肖申克的救赎']
$.results[?(@.score==9.5)].name 8 条 ['霸王别姬', '这个杀手不太冷', '肖申克的救赎']
$.results[?(@.score!=9.5)].name 2 条 ['楚门的世界', '狮子王']
$.results[?(@.categories)].name 10 条 ['霸王别姬', '这个杀手不太冷', '肖申克的救赎']
$.a.b []
$['a.b'] [{'c d': 1}]
$['书名'] ['三国演义']
$.书名 JsonPathLexerError: Error on line 1, col 2: Unexpected character: 书
  • 四条地基:$ 是整份数据;.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,实测前者有值后者为空。中文键必须加引号,$.书名 直接报词法错误。

表达式写错了

jp_err.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import time

import requests

from jsonpath_ng.exceptions import JSONPathError, JsonPathLexerError, JsonPathParserError
from jsonpath_ng.ext import parse as ext_parse

API = "https://spa1.scrape.center/api/movie"
DATA = requests.get(API, timeout=20).json()

for e in ["$.results[)", "$.results[*", "$.results[(@.length-1)].name",
"$data.results", "$.results[?(@.name in ['x'])]", "$.results.name",
"$.results[?(!(@.score>9))].name"]:
try:
r = ext_parse(e)
print("%-46s 不报错,find -> %s" % (repr(e), repr([m.value for m in r.find(DATA)][:3])))
except JSONPathError as exc:
print("%-46s %s: %s" % (repr(e), type(exc).__name__, str(exc)[:58]))

print("两类异常都继承 JSONPathError:", issubclass(JsonPathParserError, JSONPathError),
issubclass(JsonPathLexerError, JSONPathError))
expr = ext_parse("$.results[*].name")
t = time.perf_counter()
for _ in range(2000):
ext_parse("$.results[*].name")
c1 = time.perf_counter() - t
t = time.perf_counter()
for _ in range(2000):
expr.find(DATA)
print("2000 次 parse: %.3fs | 2000 次复用表达式 find: %.3fs" % (c1, time.perf_counter() - t))
1
2
3
4
5
6
7
8
9
'$.results[)'                                  JsonPathParserError: Parse error at 1:10 near token ) ())
'$.results[*' JsonPathParserError: Parse error near the end of string!
'$.results[(@.length-1)].name' JsonPathParserError: Parse error at 1:10 near token ( (()
'$data.results' JsonPathParserError: Parse error at 1:1 near token data (ID)
"$.results[?(@.name in ['x'])]" JsonPathParserError: Parse error at 1:19 near token in (ID)
'$.results.name' 不报错,find -> []
'$.results[?(!(@.score>9))].name' JsonPathParserError: Parse error at 1:21 near token > (FILTER_OP)
两类异常都继承 JSONPathError: True True
2000 次 parse: 0.760s | 2000 次复用表达式 find: 0.048s
  • 语法错在 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 好。

匹配对象与回写

jp_match.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import requests

from jsonpath_ng.ext import parse as ext_parse

API = "https://spa1.scrape.center/api/movie"
DATA = requests.get(API, timeout=20).json()

res = ext_parse("$.results[*].score").find(DATA)
print("第一个匹配: value=%s path=%s full_path=%s" % (res[0].value, res[0].path, res[0].full_path))
print("返回:", type(res).__name__, "长度", len(res), "| 没匹配:",
ext_parse("$.results[?(@.score>99)]").find(DATA))
print("有 finditer 吗:", hasattr(ext_parse("$.results[*]"), "finditer"))
print("命中下标:", [m.path.indices[0] for m in ext_parse("$.results[?(@.score>9.4)]").find(DATA)])

for m in ext_parse("$.results[*]").find(DATA)[:2]:
print("%d %s %s %s" % (m.path.indices[0], m.value["name"], m.value["score"],
m.value["regions"]))
r = ext_parse("$.results[*]['name','score']").find(DATA)
print("平铺字段并集:", len(r), "条,取到的字段:", sorted({m.path.fields[0] for m in r}))

detail = requests.get(API + "/1", timeout=20).json()
print("详情接口里 rank 的值:", [m.value for m in ext_parse("$.rank").find(detail)])
r2 = ext_parse("$['name','actors.name']").find(detail)
print("并集套嵌套:", len(r2), "条,取到的字段:", sorted({m.path.fields[0] for m in r2}))

obj = requests.get(API, timeout=20).json()
ext_parse("$.results[?(@.score>9.4)].score").update(obj, 0)
print("update 后:", [m.value for m in ext_parse("$.results[*].score").find(obj)])
ext_parse("$.results[*].rank").update(obj, 7)
print("update 不存在的键:", obj["results"][0].get("rank"))
ext_parse("$.results[*].rank").update_or_create(obj, 7)
print("update_or_create 后:", obj["results"][0]["rank"])
1
2
3
4
5
6
7
8
9
10
11
12
第一个匹配: value=9.5 path=score full_path=results.[0].score
返回: list 长度 10 | 没匹配: []
有 finditer 吗: False
命中下标: [0, 1, 2, 3, 4, 5, 6, 7]
0 霸王别姬 9.5 ['中国内地', '中国香港']
1 这个杀手不太冷 9.5 ['法国']
平铺字段并集: 20 条,取到的字段: ['name', 'score']
详情接口里 rank 的值: [1]
并集套嵌套: 1 条,取到的字段: ['name']
update 后: [0, 0, 0, 0, 0, 0, 0, 0, 9.0, 9.0]
update 不存在的键: None
update_or_create 后: 7
  • 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,两边各取一遍:

jp_vs.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
from xml.etree import ElementTree as ET

import requests
from lxml import etree

from jsonpath_ng.ext import parse as ext_parse

API = "https://spa1.scrape.center/api/movie"
DATA = requests.get(API, timeout=20).json()


def to_xml(tag, val):
el = ET.Element(tag)
if isinstance(val, dict):
el.extend(to_xml(k, v) for k, v in val.items())
elif isinstance(val, list):
el.extend(to_xml("item", v) for v in val)
else:
el.text = str(val)
return el


XML = ET.tostring(to_xml("root", DATA), encoding="unicode")
tree = etree.fromstring(XML.encode())
print("转出的 XML:", XML[:88] + "……")

PAIRS = [("$.results[*].name", "/root/results/item/name/text()"),
("$.results[0].name", "/root/results/item[1]/name/text()"),
("$.results[-1].score", "/root/results/item[last()]/score/text()"),
("$.results[?(@.score>9.4)].name", "/root/results/item[score>9.4]/name/text()"),
("$..name", "//name/text()"),
("$.results[*].categories[*]", "/root/results/item/categories/item/text()")]
for jp, xp in PAIRS:
a = [str(m.value) for m in ext_parse(jp).find(DATA)]
b = [str(x) for x in tree.xpath(xp)]
print("jp %-38s %s" % (jp, a[:4]))
print("xp %-38s %s 一致: %s" % (xp, b[:4], a == b))

print("类型对照: jsonpath %r / xpath %r" % (
ext_parse("$.results[0].score").find(DATA)[0].value, tree.xpath("//score/text()")[0]))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
转出的 XML: <root><count>104</count><results><item><id>1</id><name>霸王别姬</name><alias>Farewell My Con……
jp $.results[*].name ['霸王别姬', '这个杀手不太冷', '肖申克的救赎', '泰坦尼克号']
xp /root/results/item/name/text() ['霸王别姬', '这个杀手不太冷', '肖申克的救赎', '泰坦尼克号'] 一致: True
jp $.results[0].name ['霸王别姬']
xp /root/results/item[1]/name/text() ['霸王别姬'] 一致: True
jp $.results[-1].score ['9.0']
xp /root/results/item[last()]/score/text() ['9.0'] 一致: True
jp $.results[?(@.score>9.4)].name ['霸王别姬', '这个杀手不太冷', '肖申克的救赎', '泰坦尼克号']
xp /root/results/item[score>9.4]/name/text() ['霸王别姬', '这个杀手不太冷', '肖申克的救赎', '泰坦尼克号'] 一致: True
jp $..name ['霸王别姬', '这个杀手不太冷', '肖申克的救赎', '泰坦尼克号']
xp //name/text() ['霸王别姬', '这个杀手不太冷', '肖申克的救赎', '泰坦尼克号'] 一致: True
jp $.results[*].categories[*] ['剧情', '爱情', '剧情', '动作']
xp /root/results/item/categories/item/text() ['剧情', '爱情', '剧情', '动作'] 一致: True
类型对照: jsonpath 9.5 / xpath '9.5'
  • 六组全一致,但差别必须记牢:
    • 下标起点不同: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 的,别指望改改符号就两边跑。
  • 结构对应关系:JSON 的列表在 XML 里是重复的同名 <item>,所以 XPath 表达式里多出一层 item,而 JSONPath 用 [*] 或下标穿过列表。上面第六组的 categories 就是这种两层结构。

json 读写与容错

四个函数分工:loads / dumps 在字符串与 Python 对象之间转,load / dump 直接对着文件对象。

jp_json.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
import json
import re
import urllib.request

API = "https://spa1.scrape.center/api/movie"
RAW = urllib.request.urlopen(API, timeout=20).read()
OBJ = json.loads(RAW)
small = {"b": 2, "a": 1, "中文": [1, 2]}

print("read() 类型:", type(RAW).__name__, "-> loads 类型:", type(OBJ).__name__,
"| loads 收 bytearray:", json.loads(bytearray(b'{"a": 1}')))
try:
json.loads(123)
except Exception as exc:
print("loads 收 int:", "%s: %s" % (type(exc).__name__, exc))

CASES = [("dumps 默认", lambda: json.dumps(small)),
("ensure_ascii=False", lambda: json.dumps(small, ensure_ascii=False)),
("sort_keys=True", lambda: json.dumps(small, ensure_ascii=False, sort_keys=True)),
("separators", lambda: json.dumps(small, ensure_ascii=False, separators=(",", ":"))),
("indent=2", lambda: json.dumps(small, ensure_ascii=False, indent=2).replace("\n", "\\n")),
("int 键", lambda: json.dumps({1: "a"})),
("True 和 1 撞键", lambda: json.dumps({True: "yes", 1: "one"})),
("set", lambda: json.dumps({1, 2})),
("default=sorted", lambda: json.dumps({"s": {1, 2}}, default=sorted)),
("skipkeys=True", lambda: json.dumps({"a": 1, (1, 2): "b"}, skipkeys=True)),
("写 NaN", lambda: json.dumps({"a": float("nan"), "b": float("inf")})),
("allow_nan=False", lambda: json.dumps({"a": float("nan")}, allow_nan=False))]
for label, fn in CASES:
try:
print("%-20s %s" % (label, fn()))
except Exception as exc:
print("%-20s %s: %s" % (label, type(exc).__name__, str(exc)[:50]))

print("体积: 接口 %d | 紧凑 %d | ascii 转义 %d | indent=2 %d" % (
len(RAW),
len(json.dumps(OBJ, ensure_ascii=False, separators=(",", ":")).encode()),
len(json.dumps(OBJ).encode()),
len(json.dumps(OBJ, ensure_ascii=False, indent=2).encode())))

with open("jp_dump.json", "w", encoding="utf-8") as fp:
json.dump({"片名": "霸王别姬", "评分": 9.5}, fp, ensure_ascii=False)
with open("jp_dump.json", encoding="utf-8") as fp:
print("load(文本文件):", json.load(fp))
with open("jp_dump.json", "rb") as fp:
print("load(二进制文件):", json.load(fp))

DIRTY = [("NaN", '{"a": NaN, "b": Infinity}'),
("重复键", '{"k": 1, "k": 2}'),
("控制字符", '{"a": "价' + chr(1) + '格"}'),
("单引号", "{'a': 1}"),
("尾逗号", '{"a": 1,}'),
("JSONP", 'cb({"count": 104});'),
("bytes 带 BOM", b'\xef\xbb\xbf{"a": 1}'),
("str 带 BOM", chr(0xFEFF) + '{"a": 1}')]
for label, s in DIRTY:
try:
print("%-14s ->" % label, json.loads(s))
except json.JSONDecodeError as exc:
print("%-14s -> %s: %s" % (label, type(exc).__name__, str(exc)[:44]))

print("strict=False 救控制字符:", json.loads('{"a": "价' + chr(1) + '格"}', strict=False))
try:
json.loads('{"a": 1,}', strict=False)
except json.JSONDecodeError as exc:
print("strict=False 救不了尾逗号:", exc.msg)
try:
json.loads('{"a": NaN}', allow_nan=False)
except Exception as exc:
print("loads 不认 allow_nan:", "%s: %s" % (type(exc).__name__, str(exc)[:52]))
print("object_pairs_hook 看全部:", json.loads('{"k": 1, "k": 2}', object_pairs_hook=list))
print("JSONP 抠大括号:", json.loads(re.search(r"\{.*\}", 'cb({"count": 104});').group()))
try:
json.loads('cb({"count": 104});'.strip('() \n\t\r'))
except json.JSONDecodeError as exc:
print("JSONP 只 strip:", exc.msg)
try:
json.loads('{"a": 1,}')
except json.JSONDecodeError as exc:
print("异常自带定位:", "msg=%r lineno=%s colno=%s pos=%s" % (exc.msg, exc.lineno, exc.colno, exc.pos))
first = OBJ["results"][0]
print("接口单条记录的键:", sorted(first))
print("缺字段 get:", first.get("rank"), end=" | 下标: ")
try:
first["rank"]
except Exception as exc:
print("%s: %s" % (type(exc).__name__, exc))
print("分数类型:", type(first["score"]).__name__, "| 分钟类型:", type(first["minute"]).__name__)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
read() 类型: bytes -> loads 类型: dict | loads 收 bytearray: {'a': 1}
loads 收 int: TypeError: the JSON object must be str, bytes or bytearray, not int
dumps 默认 {"b": 2, "a": 1, "\u4e2d\u6587": [1, 2]}
ensure_ascii=False {"b": 2, "a": 1, "中文": [1, 2]}
sort_keys=True {"a": 1, "b": 2, "中文": [1, 2]}
separators {"b":2,"a":1,"中文":[1,2]}
indent=2 {\n "b": 2,\n "a": 1,\n "中文": [\n 1,\n 2\n ]\n}
int 键 {"1": "a"}
True 和 1 撞键 {"true": "one"}
set TypeError: Object of type set is not JSON serializable
default=sorted {"s": [1, 2]}
skipkeys=True {"a": 1}
写 NaN {"a": NaN, "b": Infinity}
allow_nan=False ValueError: Out of range float values are not JSON compliant:
体积: 接口 2765 | 紧凑 2765 | ascii 转义 3372 | indent=2 4088
load(文本文件): {'片名': '霸王别姬', '评分': 9.5}
load(二进制文件): {'片名': '霸王别姬', '评分': 9.5}
NaN -> {'a': nan, 'b': inf}
重复键 -> {'k': 2}
控制字符 -> JSONDecodeError: Invalid control character at: line 1 column
单引号 -> JSONDecodeError: Expecting property name enclosed in double q
尾逗号 -> JSONDecodeError: Illegal trailing comma before end of object:
JSONP -> JSONDecodeError: Expecting value: line 1 column 1 (char 0)
bytes 带 BOM -> {'a': 1}
str 带 BOM -> JSONDecodeError: Unexpected UTF-8 BOM (decode using utf-8-sig
strict=False 救控制字符: {'a': '价\x01格'}
strict=False 救不了尾逗号: Illegal trailing comma before end of object
loads 不认 allow_nan: TypeError: JSONDecoder.__init__() got an unexpected keyword arg
object_pairs_hook 看全部: [('k', 1), ('k', 2)]
JSONP 抠大括号: {'count': 104}
JSONP 只 strip: Expecting value
异常自带定位: msg='Illegal trailing comma before end of object' lineno=1 colno=8 pos=7
接口单条记录的键: ['alias', 'categories', 'cover', 'id', 'minute', 'name', 'published_at', 'regions', 'score']
缺字段 get: None | 下标: KeyError: 'rank'
分数类型: float | 分钟类型: int
  • 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 直连同一个接口跑出来的是:

jp_web.mjs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { inspect } from "node:util";

const show = (v) => inspect(v, { breakLength: Infinity });
const res = await fetch("https://spa1.scrape.center/api/movie");
const obj = JSON.parse(await res.text());
const movies = obj.results;

console.log("content-type:", res.headers.get("content-type"));
console.log("片名:", show(movies.map((m) => m.name)));
console.log("评分:", show(movies.slice(7, 10).map((m) => m.score)));
console.log("score>9.4:", show(movies.filter((m) => m.score > 9.4).map((m) => m.name)));
console.log("时长合计:", movies.reduce((s, m) => s + m.minute, 0), "分钟");
console.log("分类平铺:", movies.flatMap((m) => m.categories).length, "个");
console.log("按 id 找:", movies.find((m) => m.id === 3).name);
console.log("缺失字段:", show(movies.slice(0, 3).map((m) => m.rank ?? null)));
console.log("JSON.stringify:", JSON.stringify({ n: "霸王别姬", s: 9.5 }));
try {
console.log(movies[0].rank.note);
} catch (err) {
console.log("缺失字段再取一层:", err.constructor.name, "|", String(err.message).slice(0, 34));
}
console.log("node:", process.version);
1
2
3
4
5
6
7
8
9
10
11
content-type: application/json
片名: [ '霸王别姬', '这个杀手不太冷', '肖申克的救赎', '泰坦尼克号', '罗马假日', '唐伯虎点秋香', '乱世佳人', '喜剧之王', '楚门的世界', '狮子王' ]
评分: [ 9.5, 9, 9 ]
score>9.4: [ '霸王别姬', '这个杀手不太冷', '肖申克的救赎', '泰坦尼克号', '罗马假日', '唐伯虎点秋香', '乱世佳人', '喜剧之王' ]
时长合计: 1352 分钟
分类平铺: 28 个
按 id 找: 肖申克的救赎
缺失字段: [ null, null, null ]
JSON.stringify: {"n":"霸王别姬","s":9.5}
缺失字段再取一层: TypeError | Cannot read properties of undefine
node: v26.13.0
  • 对应关系,结果与前面 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(见上一节倒数第二行)。
  • 没有实测的部分:前端专门的 JSONPath 库(jsonpath-plus 一类)本机没装没跑,浏览器端 JSONPath 的支持情况不作结论。这一节只覆盖 JSON.parse + 数组方法这条验证过的路。

实战:翻完全站

先摸清分页参数再谈翻页,这个接口只认 limit / offset:

jp_page.py
1
2
3
4
5
6
7
8
9
10
import requests

from jsonpath_ng.ext import parse as ext_parse

API = "https://spa1.scrape.center/api/movie"

for query in ("", "?page=2", "?page=abc", "?offset=10", "?offset=10&limit=5"):
resp = requests.get(API + query, timeout=20)
ids = [m.value for m in ext_parse("$.results[*].id").find(resp.json())]
print("%-20s %-4s %s" % (query or "(不给参数)", resp.status_code, ids))
1
2
3
4
5
(不给参数)               200  [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
?page=2 200 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
?page=abc 200 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
?offset=10 200 [11, 12, 13, 14, 15, 16, 17, 18, 19, 20]
?offset=10&limit=5 200 [11, 12, 13, 14, 15]
  • 参数名猜错不会报错,只会静默返回第一页:?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:

jp_movies.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import csv
import json
import urllib.request

from jsonpath_ng.ext import parse

BASE = "https://spa1.scrape.center/api/movie"


def values(data, expr):
return [m.value for m in parse(expr).find(data)]


def fetch(query):
with urllib.request.urlopen(BASE + query, timeout=20) as resp:
return json.loads(resp.read())


def crawl():
rows, offset = [], 0
while True:
payload = fetch("?limit=10&offset=%d" % offset)
names = values(payload, "$.results[*].name")
if not names:
print("offset=%-3d 返回 0 条,results 空了就停" % offset)
break
scores = values(payload, "$.results[*].score")
areas = values(payload, "$.results[*].regions")
if not len(names) == len(scores) == len(areas):
raise SystemExit("三个表达式取到的条数不一致,不能硬拼 zip")
rows += [{"片名": n, "评分": s, "地区": "、".join(a or [])}
for n, s, a in zip(names, scores, areas)]
offset += 10
return rows


def main():
rows = crawl()
print("共 %d 条,评分为空 %d 条" % (len(rows), len([r for r in rows if r["评分"] is None])))
for name, kw in (("movies.csv", {"newline": ""}), ("movies_bad.csv", {})):
with open(name, "w", encoding="utf-8-sig", **kw) as fp:
writer = csv.DictWriter(fp, fieldnames=["片名", "评分", "地区"])
writer.writeheader()
writer.writerows(rows)
good, bad = open("movies.csv", "rb").read(), open("movies_bad.csv", "rb").read()
print("头 3 字节:", good[:3])
print("带 newline='': %d 字节、\\r\\n %d 个 | 忘写: %d 字节、\\r\\r\\n %d 个"
% (len(good), good.count(b"\r\n"), len(bad), bad.count(b"\r\r\n")))
lines = good.decode("utf-8-sig").splitlines()
print("CSV 第 2 行:", lines[1])
print("CSV 末行:", repr(lines[-1]))


if __name__ == "__main__":
main()
1
2
3
4
5
6
offset=110 返回 0 条,results 空了就停
共 104 条,评分为空 4 条
头 3 字节: b'\xef\xbb\xbf'
带 newline='': 3524 字节、\r\n 105 个 | 忘写: 3629 字节、\r\r\n 105 个
CSV 第 2 行: 霸王别姬,9.5,中国内地、中国香港
CSV 末行: '中文,,'
  • 落地要点:
    • 翻页用 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,两种取法一起实测:

jp_split.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import re

import requests
from parsel import Selector

from jsonpath_ng.ext import parse as ext_parse

SPA1 = "https://spa1.scrape.center"
SSR1 = "https://ssr1.scrape.center"
API = SPA1 + "/api/movie"

shell = requests.get(SPA1 + "/", timeout=20)
api = requests.get(API, timeout=20)
ssr = requests.get(SSR1 + "/", timeout=20)
for label, resp in (("spa1 首页", shell), ("spa1 接口", api), ("ssr1 首页", ssr)):
print("%-10s %-30s %6d 字节" % (label, resp.headers["Content-Type"], len(resp.content)))

print("spa1 首页里有片名吗:", "霸王别姬" in shell.text)
print("spa1 首页 parsel 取 h2:", Selector(shell.text).css("h2::text").getall())
print("接口 $.results[*].name 前 3 条:",
[m.value for m in ext_parse("$.results[*].name").find(api.json())][:3])
print("服务端渲染的 ssr1 首页 h2 个数:", len(Selector(ssr.text).css("h2")))

try:
print("对 HTML 调 resp.json():", shell.json())
except requests.exceptions.JSONDecodeError as exc:
print("对 HTML 调 resp.json():", type(exc).__name__, "|", str(exc)[:44])

app_path = re.search(r'src=(/js/app[^" ]+\.js)', shell.text).group(1)
app_js = requests.get(SPA1 + app_path, timeout=20).text
print("打包脚本里的接口路径:", sorted(set(re.findall(r'"(/api/[a-z/]+)"', app_js))))
1
2
3
4
5
6
7
8
9
spa1 首页    text/html                         952 字节
spa1 接口 application/json 2765 字节
ssr1 首页 text/html; charset=utf-8 41667 字节
spa1 首页里有片名吗: False
spa1 首页 parsel 取 h2: []
接口 $.results[*].name 前 3 条: ['霸王别姬', '这个杀手不太冷', '肖申克的救赎']
服务端渲染的 ssr1 首页 h2 个数: 10
对 HTML 调 resp.json(): JSONDecodeError | Expecting value: line 1 column 1 (char 0)
打包脚本里的接口路径: ['/api/movie']
  • 判断「该走接口还是该解析 HTML」,按这个顺序:

    1. 浏览器 DevTools 的 Network 面板筛 Fetch/XHR,看候选请求的 Content-Type:application/json 是接口,text/html 是页面。上面前三行就是实测的三种响应头。
    2. 页面渲染有数据但 HTML 里找不到,就顺着脚本找接口:spa1 首页 952 字节里只有一个空 <div id=app>,parsel 实测取不到片名,接口路径藏在它加载的打包脚本里,实测先抠出 /js/app.*.js 的地址、再在里面正则找出 /api/movie。
    3. 拿到候选 URL 先访问一次,能粘出一段 JSON 就定型。
  • 优先接口的三条实测理由:

    • 拿得到:同一时刻 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》

参考

本文结束 感谢您的阅读
正在加载今日诗词....