Python JSON解析失败?这套字符串修复与字典读取方案值得参考

发布时间:2026/10/9 7:03:33
Python JSON解析失败?这套字符串修复与字典读取方案值得参考
做数据清洗和后端对接的时间长了你就会发现一件事真正从线上拿回来的JSON字符串十个里面能有两三个是不符合标准的。日志里被截断一半的记录、老系统导出时顺手把字典repr塞进字段的文本、接口偶尔夹带HTML说明的数据……当你把这些字符串交给json.loads()抛出的JSONDecodeError几乎是必然的。这时候需要的不只是报错信息而是一套能自动修复字符串JSON格式问题、再把数据按照字典形式读出来的方案。这篇文章就把我踩过的坑、写下的工具函数、以及最终稳定使用的完整流程从头到尾整理一遍给那些被类似问题折磨的Python开发者做个参考。1. 为什么字符串里的JSON总是带病从第一次碰壁说起1.1 一个看似JSON却解析失败的样本我第一次被这个问题卡住是在对接一个老系统导出的数据文件。文件里每一行都长这样{user_id: 1024, name: 张三, tags: [vip, 深圳], active: True, last_login: None}乍一看这是字典嘛Python里天天见。我直接套上json.loads()结果就是熟悉的JSONDecodeError: Expecting property name enclosed in double quotes。因为json.loads()只认标准JSON键必须用双引号包起来字符串也必须用双引号布尔值只能是小写true/false空值只能是null。这种数据其实是某个同事用str(dict)直接存进数据库、或者打印到日志里的字典外貌文本。它长得像JSON但根本不符合JSON规范。后来我又陆续遇到很多变体单引号包裹的、键没加引号的、末尾多逗号的、中文引号的、含有控制字符的、被HTML包裹的……处理多了我总结出一个判断标准凡是出现在日志、数据库字段、配置文件、爬虫抓取结果里的JSON字符串都要默认它是带病的先清洗修复再尝试解析。1.2 JSON标准为什么这么苛刻很多人不理解为什么JSON标准这么严格键必须双引号、字符串不能用单引号、不能有尾逗号、不能写注释规矩比Python字典多得多。原因是JSON设计目标就是跨语言交换数据。它要保证JavaScript、Java、C、Python等不同语言都能用同一个解析器读懂同一段文本。如果允许单引号那user_id到底是一个字符串还是键如果允许尾逗号哪些解析器该处理为了消除歧义标准就把所有边角都堵死了。这正是为什么Python字典的str()输出没法直接当JSON用因为Python的字符串字面量天然支持单引号字典的repr也很宽松。理解了这点你就明白了修复字符串JSON格式问题的本质是把一段长得像JSON但不是JSON的文本转成完全符合RFC 8259标准的文本让严格模式下json.loads()能一次通过。1.3 数据源头问题都在哪里产生从经验来看带病JSON主要来自这几个源头老系统或跨语言接口用str(dict)、var_dump、toString()等直接把数据结构打印出来当返回值。日志截断大JSON写入日志时被日志系统按长度截断或者被日志轮转切成了两半。网页内嵌数据script标签里写的data {...}经常混着单引号、注释、尾逗号和undefined。手工拼接研发为了方便用字符串拼接方式组装JSON一旦字段值里有引号或反斜杠就会拼出不合法结果。代理层或网关加工中间设备对响应做了改写可能插入HTML片段、错误提示、BOM头等杂质。知道源头之后修复方案才有针对性。比如来自日志的截断JSON你无论怎么修都是补不全的只能做闭合边界判断并标记异常来自网页的undefined替换成null就能救活大部分数据。2. 四类最常见的JSON字符串格式问题识别与定位2.1 引号劈了腿单引号、中文引号、裸键最普遍的问题就是引号不规范。常见有三种单引号代替双引号如{name: 张三}。外来字符串里可能还有its这种撇号修复时很容易误伤所以不能简单做全局替换。中文引号如{name: “张三”}。手写配置或OCR识别常出现好在替换规则非常干净把“和”统一替换为标准双引号即可。裸键如{name: 张三}。键没有引号包裹而值是双引号字符串说明是某种方言JSON或手工拼写。裸键的修复要小心最稳妥的做法是只在{、,、空格之后紧跟字母或下划线、后面紧跟:的位置补引号。用正则表达式的顺序要控制好避免误伤字符串内部内容。2.2 逗号走了样尾逗号、连续逗号、缺失逗号尾逗号{a: 1, b: 2,}。JS和Python都允许JSON不允许。修复规则很简单把},、],以及}、]之前紧挨着的逗号去掉。连续逗号{a: 1,, b: 2}。通常是生成方拼接逻辑有bug导致中间多了空元素。将连续多个逗号压缩成一个即可。逗号缺失{a: 1 b: 2}。这个最难修因为无法单纯靠正则判断边界。遇到这种我会建议直接人工介入或回溯上游而不是在清洗层硬猜。2.3 转义和编码乱了套控制字符、反斜杠、中文乱码标准JSON字符串内不允许出现未经转义的控制字符比如\x00到\x1f之间的字符其中只有\t、\n、\r可以合法出现。可现实中从数据库导出的文本偶尔会带着换行符、回车符甚至\x00截断符直接塞进JSON字段里解析就会报Invalid control character。另一类坑是反斜杠被吃掉。例如Windows路径C:\Users\name如果拼JSON时没有写双反斜杠解析时\U会被当成Unicode转义开头直接报错。这类问题要用unicode_escape或正则去识别和修复修复原则是把看起来是路径或转义候选位置的多余反斜杠补成双反斜杠。中文乱码则通常来自编码声明不一致比如文件是GBK编码却按UTF-8读取。这种在字符串层面很难修应该在读文件时就用正确编码而不是修完字符串再补救。2.4 结构缺胳膊少腿截断、杂质、非标准常量截断最头痛的一种。比如日志系统把后半段}切掉了字符串变成{a: 1, b:。这时候任何修复都无济于事最合理的处理是检测括号是否闭合并记录这条数据为不完整。杂质返回内容在JSON前后混入了HTML、BOM、中文提示等。例如爬虫拿到的scriptwindow.data {...}/script或者接口报错时返回error: {code: 500}。修复的第一步永远是提取出真正的JSON块而不是直接在原串上做替换。非标准常量NaN、Infinity、undefined。这是很多JS前端序列化时留下的产物标准JSON里不允许。把三者统一替换成null即可语义上也能接受。3. 一步步写出修复-解析一体化工具函数3.1 整体流程设计先提取再修复最后兜底我建议的工具函数逻辑不是一把梭而是分层处理尝试直接解析数据本来就能解析的话不走修复流程节省性能。提取JSON块把字符串中真正的JSON区块抠出来。修复引号、逗号、常量和控制字符按从易到难的顺序逐级处理。再次标准解析修复完立刻用json.loads()验证。兜底解析如果标准解析还不行再用ast.literal_eval()兜底。返回默认值全部失败时返回调用方传入的默认值并把错误信息记录到日志。这个流程的精髓在于每次修复都紧跟着校验能修则修修不了不硬撑。3.2 提取JSON文本块从一堆垃圾中把JSON捞出来面对混有HTML、BOM、错误提示的文本第一件事永远是提取JSON片段。我的思路是先用字符扫描定位最外层{或[的位置然后从那个位置开始用状态机匹配成对的}或]把外层大括号/中括号完整地取出来。状态机需要跳过字符串内部的括号和转义字符否则很容易在字符串内容里误判结束位置。这一步做好之后后续所有修复都能在一个干净的JSON片段上进行。3.3 修复引号与裸键引号修复不能只做str.replace(, )那样会把字符串内部的撇号也换掉。安全做法是用一个逐字符扫描的循环维护in_single_string和in_double_string两个状态。只有不在双引号字符串内部时才能把单引号当字符串边界替换成双引号。对于已经成对的双引号保持不变。裸键的修复我比较推荐用正则配合白名单字符集匹配{、,或空白之后的字母/下划线开头、后面直接跟冒号的片段把它替换成带双引号的键。注意顺序要在单引号修复之后因为标准JSON的键已经变成双引号了正则就不会误伤正常键。3.4 修复逗号与非法常量尾逗号直接正则处理把,\s*([}\]])替换成\1连续逗号把,替换成单个,。非法常量处理也很机械把NaN、Infinity、undefined统一替换成null。但要注意大小写和边界避免把字段名里的NaN也给替换。所以替换前最好加词边界判断或者用正则限定为独立单词。3.5 控制字符清洗与最终校验控制字符清洗的规则是允许\t、\n、\r把其他\x00-\x1f范围内的字符直接删除。删除而不是替换成空格是为了避免破坏JSON结构。清洗完成后再次用json.loads()做最终校验。如果还不行就进入兜底解析。兜底用ast.literal_eval()非常有效因为Python字面量支持单引号、支持True/False/None。但要注意它只接受Python字面量不接受null、true这种JSON写法所以必须在标准解析失败后才用。同时我们最终只接受解析结果是dict类型可以避免一些异常形状被当成合法数据返回。3.6 完整代码一个可放到项目里的repair_load_json以下是我在多个项目里实际用过的清洗函数去掉了一些内部日志保留了核心逻辑import json import re import ast from typing import Any def extract_json_block(text: str) - str: 从混杂文本中提取最外层的JSON块字符串或数组 text text.lstrip(\ufeff).strip() if not text: return text start -1 for idx, ch in enumerate(text): if ch in {[: start idx break if start -1: return text depth 0 in_string False escape False quote_char None for i in range(start, len(text)): ch text[i] if in_string: if escape: escape False elif ch \\: escape True elif ch quote_char: in_string False else: if ch in \: in_string True quote_char ch elif ch in {[: depth 1 elif ch in }]: depth - 1 if depth 0: return text[start:i 1] return text[start:]def fix_single_quotes(text: str) - str: 把非双引号字符串边界处的单引号替换为双引号 result [] in_single False in_double False escape False for ch in text: if escape: result.append(ch) escape False continue if ch \\: result.append(ch) escape True continue if ch and not in_single: in_double not in_double result.append(ch) elif ch and not in_double: in_single not in_single result.append() else: result.append(ch) return .join(result)def fix_missing_key_quotes(text: str) - str: 修复没有引号的键例如 {name: zhang} - {name: zhang} pattern re.compile(r([{,]\s*)([A-Za-z_][A-Za-z0-9_]*)\s*:) return pattern.sub(r\1\2:, text) def fix_trailing_and_dup_commas(text: str) - str: text re.sub(r,\s*([}\]]), r\1, text) text re.sub(r,{2,}, ,, text) return text def fix_illegal_constants(text: str) - str: text re.sub(r\bNaN\b, null, text) text re.sub(r\bInfinity\b, null, text) text re.sub(r\bundefined\b, null, text) return text def fix_control_chars(text: str) - str: return re.sub(r[\x00-\x08\x0b\x0c\x0e-\x1f], , text)def repair_load_json(value_str: Any, default: Any None) - Any: 修复并解析JSON字符串成功返回字典/列表失败返回默认值 if isinstance(value_str, (dict, list)): return value_str if not isinstance(value_str, str): return default # 先直接尝试 try: data json.loads(value_str) if isinstance(data, dict): return data except (json.JSONDecodeError, TypeError): pass text extract_json_block(value_str) text text.replace(“, ).replace(”, ) text text.replace(‘, ).replace(’, ) text fix_single_quotes(text) text fix_missing_key_quotes(text) text fix_trailing_and_dup_commas(text) text fix_illegal_constants(text) text fix_control_chars(text) try: data json.loads(text) if isinstance(data, dict): return data except (json.JSONDecodeError, TypeError): pass # 兜底ast.literal_eval 能处理 python 风格字面量 try: data ast.literal_eval(text) if isinstance(data, dict): return data except (ValueError, SyntaxError, TypeError): pass return default使用示例raw {user_id: 1024, name: 张三, tags: [vip, 深圳], active: True} data repair_load_json(raw) print(data[name], data[user_id], data[active])这段代码能解决我上面提到的绝大多数带病JSON问题。有个前提要讲清楚对截断很严重的JSONextract_json_block拿到的仍是不完整片段json.loads还是会失败最后只会落到兜底失败并返回默认值。这不是bug而是我们在清洗层只能做到这个程度。4. 解析成功后如何把数据当字典优雅地读取4.1 基础取值直接索引、get、逐级展开清理并解析出字典之后读取数据反而是很多人容易踩坑的地方。最基础的是data[key]但键不存在时会抛KeyError做日志和报警场景时我习惯用data.get(key)取不到就返回None程序流程不会断。如果需要设置默认值可以写data.get(key, 默认值)。还有一个技巧get只对一层有效多层嵌套时不能连写data.get(a).get(b)因为如果a不存在data.get(a)返回None继续.get(b)就会抛AttributeError。正确的多层写法是data.get(a, {}).get(b)这样即使a缺失也会拿到一个空字典继续往下找。不过这种写法只适合层级固定且不多的情况。4.2 嵌套数据不慌张写一个deep_get实际数据经常嵌套三四层比如订单信息里的data.order.pay_info.amount。我建议封装一个深度取值函数用列表传路径比手写一长串get舒服得多def deep_get(mapping, path, defaultNone): cur mapping for key in path: if isinstance(cur, dict) and key in cur: cur cur[key] else: return default return cur # 用法 amount deep_get(data, [data, order, pay_info, amount], 0)这个函数的好处是天然处理了中间某一层不是字典的情况不会因为类型错误让程序直接崩溃。对从脏数据里清洗出的字典来说这点特别重要因为数据结构可能不像接口文档承诺的那样稳定。4.3 缺键时别让程序崩默认值策略数据清洗落地后的一大痛点是同一字段在不同记录里可能缺失。处理策略我总结为三条读值全部走get/deep_get不直接[]索引。数值类字段给一个类型安全的默认值比如金额给0数量给0避免后续计算时报TypeError。关键业务字段缺失时主动记录日志而不是默默填默认值。如果整批数据需要频繁做缺键、填默认值的操作可以直接用collections.defaultdict包装from collections import defaultdict def as_default_dict(data): return defaultdict(lambda: None, data)这样访问不存在的键时返回None不至于抛异常。4.4 用json.dumps和pprint看清复杂结构拿到字典之后第一件事别急着取值先看看结构长什么样。数据量小时用pprint.pprint(data)能按层级清晰展示数据量大或需要保存现场时用json.dumps(data, ensure_asciiFalse, indent2)输出到文件肉眼排查比直接打印一行长串高效得多。还有一个实用技巧某些字段值本身是JSON字符串比如data[payload]是字符串形态的字典。对这种双层JSON先json.loads(data[payload])展开一层再走字典读取。我见过不少人卡在取值环节其实是没意识到内层还是字符串。5. 真实案例复盘从垃圾字符串到干净字典5.1 日志里被截断的JSON判断闭合边界而不是硬补有一次我在排查线上订单数据发现日志里几百条订单记录是残缺的格式类似INFO order_data: {order_id: 98271, amount: 99.9, pay_time: 2024-05-20 10:00:00, items: [{sku: A001, price: 99.9}最后少了一个}显然是因为日志系统把超长行截断了。用repair_load_json处理这种串extract_json_block能提取到从{开始的一大段但扫描完整个字符串后depth没有归零函数只会返回从{到字符串末尾的整个文本然后json.loads失败兜底也失败最终返回默认值。我额外加了一个判断提取函数外再检查括号是否闭合。没有闭合的数据不再走修复流程而是单独标记为truncated丢进另一个重试队列。重试队列的任务是回源接口或数据库重新拉数据。修复层不是万能的识别出这条数据根本修不了比硬凑一个错误结果更有价值。5.2 网页内嵌JSON连