Coding

Python 如何解析不规则 JSON?

用demjson | 手写解析器 | Python ast解析解析不规则JSON

JamzumSum · ·updated 2026年6月21日 · 5 min read
Irregular
Irregular Photo by @natura_photos.

解析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 中采用了此方案,速度提升约数百倍,具体数值已不记得。然而此方案存在一定问题:

  1. 笔者并非专业开发者。尽管具备一定的编译器知识,思路仍然较为朴素;写出的解析器结构较差,也不够规范。可用性是唯一的优点。
  2. 性能问题。作为曾经的 C++ 用户,用 Python 编写此类代码令笔者感到不适,在性能上仍有巨大的提升空间。
  3. 维护问题。这份代码仅有笔者一人在使用,且完成之后便不愿再回顾。那么,后续由谁来维护呢?

评分:

  • 代码规模: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 后再读取。然而该方法存在以下问题:

  1. 进程间通信。启动 Node 进程的资源开销较大,进程间通信也并非高效手段。
  2. 平台限制。不同平台下 subprocess.PIPE 的缓冲区大小限制不同。尽管笔者在 Windows 上未遇到任何问题,但在 Docker 容器(Linux)中数据被截断。就此问题查找相关资料较为困难。
  3. 算法效率。与常规 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 中将所有变量替换为字符串;对于名为 truefalseundefinednull 的变量,应替换为对应的常量。此外,针对 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 法出现问题之前,笔者认为没有必要再寻找新的方案。此外,也有一些值得关注的第三方库,例如 pyjson5demjson3 等。不过,届时再考虑替换也为时不晚。

分享
作者
JamzumSum

Open-source developer, major language: Python, C++.

Comments

Hook this up to your favourite commenting platform — Giscus, Disqus, or your own.

Continue reading