Python 如何解析不规则 JSON?
用demjson | 手写解析器 | Python ast解析解析不规则JSON
解析Qzone3TG系列第一篇,不规则 JSON 解析器。
Motivation
aioqzone 中有一个名为 jssupport 的包,专门处理 Qzone 各种 js-style 的返回值。其中包含一个名为 jsjson 的模块,用于解析 Qzone 返回的 js 字典对象。
{
html: "<html></html>",
code: 0,
hasmore: true,
merge: [undefined]
}
如上所示,这种格式很容易让人联想到 JSON,毕竟 {meth}json.loads 实在深入人心。然而这段数据并非规范的 JSON,其主要特征在于键虽然是字符串,却没有引号。直接使用 json.loads 解析会报错。那么,应当如何解析这类数据呢?下文给出笔者曾使用过的四种方法。
demjson
若在百度搜索 python 解析不规则 JSON,许多文章会推荐使用 demjson。笔者长期使用 demjson,直至发现爬虫速度过慢。经性能分析,瓶颈集中在 demjson。查阅其仓库,最近一次提交在 2015 年,issue 与 PR 也已长期无人维护。这促使笔者自行寻找替代方案。
注:
demjson存在一些 fork,例如 demjson3,笔者未曾使用,不清楚其性能表现。读者可以尝试,毕竟 Python 社区并不鼓励重复造轮子。
评分:
- 代码规模:A
- 速度:D
- 可维护性:D
手写 JSON 解析器
JSON 的语法相对简单,用 Python 编写一个解析器并不十分困难。笔者在 Qzone2TG 中采用了此方案,速度提升约数百倍,具体数值已不记得。然而此方案存在一定问题:
- 笔者并非专业开发者。尽管具备一定的编译器知识,思路仍然较为朴素;写出的解析器结构较差,也不够规范。可用性是唯一的优点。
- 性能问题。作为曾经的 C++ 用户,用 Python 编写此类代码令笔者感到不适,在性能上仍有巨大的提升空间。
- 维护问题。这份代码仅有笔者一人在使用,且完成之后便不愿再回顾。那么,后续由谁来维护呢?
评分:
- 代码规模:D
- 速度:A
- 可维护性:D
Stringify
前文已提到,这段数据本质上是 js 字典。因此显然 Node 可以直接解析这段数据,再通过 JSON 格式与 Python 交换。由于 jssupport 包中已包含与 node 通信的代码,因此可以简单复用:
from .execjs import ExecJS
class NodeLoader:
jsonStringify = ExecJS(js="").bind("JSON.stringify", new=False)
@classmethod
async def json_loads(
cls, js: str, try_load_first: bool = True, parser: Callable[[str], JsonValue] = json.loads
) -> JsonValue:
"""
This function converts a string representation of JS/JSON data into a Python object.
It may use :node:meth:`JSON.stringify` to convert js to json.
:param js: Used to Pass in the json string.
:param try_load_first: Used to Specify whether to try loading the json string with the `parser` first.
:param parser: Used to Specify the function that will be used to parse the string.
:return: A python object that represents the same content as the js/json string.
"""
if try_load_first:
try:
return parser(js)
except json.JSONDecodeError:
pass
json_str = await cls.jsonStringify(js, asis=True)
try:
return parser(json_str)
except json.JSONDecodeError as e:
logger.exception("Failed to decode json input!")
logger.debug("json_str=%s", json_str)
raise e
上述代码逻辑清晰,借助 JSON.stringify 这个 Node 函数将 js 转化为 JSON 后再读取。然而该方法存在以下问题:
- 进程间通信。启动 Node 进程的资源开销较大,进程间通信也并非高效手段。
- 平台限制。不同平台下
subprocess.PIPE的缓冲区大小限制不同。尽管笔者在 Windows 上未遇到任何问题,但在 Docker 容器(Linux)中数据被截断。就此问题查找相关资料较为困难。 - 算法效率。与常规 JSON 解析器相比,该方法对字符串进行了两次读取,这在算法层面不够合理。
评分:
- 代码规模:A-
- 速度:B
- 可维护性:B
问题 2 较为复杂,若日后能弄清楚,或可另撰文详述。
ast
这是目前 aioqzone 的默认方法,不过 Stringify 也被保留作为备选。观察数据可知,若不考虑变量是否定义、关键字差异等问题,js 字典与 Python 字典几乎无异。Python 内置的 ast 库可用于解析 Python 代码,那么是否可以利用它来解析 js 字典呢?
class RewriteUndef(ast.NodeTransformer):
def __init__(self) -> None:
if int(python_version_tuple()[1]) < 8:
# NOTE: visit_Constant not available on py37
self.visit_Str = lambda node: ast.Str(s=node.s.replace("\\/", "/"))
const = {
"undefined": ast.Constant(value=None),
"null": ast.Constant(value=None),
"true": ast.Constant(value=True),
"false": ast.Constant(value=False),
}
def visit_Name(self, node: ast.Name):
if node.id in self.const:
return self.const[node.id]
return ast.Str(s=node.id)
def visit_Constant(self, node: ast.Constant) -> Union[ast.Constant, ast.Str]:
if not isinstance(node.value, str):
return node
return ast.Str(s=node.value.replace("\\/", "/"))
当解析一段"Python 代码"得到语法树之后,ast.NodeTransformer 可用于修改这棵树。由于输入的 js 中不包含任何变量定义,因此任何被识别为变量的 token 实际上只是没有引号的字符串。在 visit_Name 中将所有变量替换为字符串;对于名为 true、false、undefined、null 的变量,应替换为对应的常量。此外,针对 JSON 与 Python 转义规则之间的差异,还需去除不必要的转义符。
py37 支持:
visit_Constant是 py38 引入的接口,为支持 py37 需使用visit_Str。
from textwrap import dedent
class AstLoader:
class RewriteUndef(ast.NodeTransformer): ...
@classmethod
def json_loads(cls, js: str, filename: str = "stdin") -> JsonValue:
"""
The json_loads function loads a JSON object from a js/json string. It uses standard
:mod:`ast` module to parse the js/json.
:param js: Used to Pass the js/json string to be parsed.
:param filename: Used to Specify the name of the file that is being read. This is only for debug use.
:return: A jsonvalue object.
"""
node = ast.parse(dedent(js), mode="eval")
node = ast.fix_missing_locations(cls.RewriteUndef().visit(node))
code = compile(node, filename, mode="eval")
return eval(code)
如上所述,这段代码不支持解析所有不规则 JSON,但对于解析 Qzone 的返回值而言已足够。笔者使用 feeds3_html_more 的返回值测试其准确性和性能,速度与解析器方法相差无几,约为 Stringify 的十倍。更重要的是,该方法基于 Python 内置库实现,能够受益于 Python 版本优化带来的性能提升。多年前笔者曾见有人使用 ast 处理不规则 JSON,但此方法并非主流。
评分:
- 代码规模:A-
- 速度:A
- 可维护性:A
后记
在 ast 法出现问题之前,笔者认为没有必要再寻找新的方案。此外,也有一些值得关注的第三方库,例如 pyjson5、demjson3 等。不过,届时再考虑替换也为时不晚。
Hook this up to your favourite commenting platform — Giscus, Disqus, or your own.