
做数据抓取、接口联调、日志分析的人十有八九都经历过这种崩溃瞬间明明对方说好返回 JSON结果拿进json.loads()直接红字报错。仔细一看单引号代替双引号、键值对最后多了个逗号、字符串前面还混着几行日志输出……这不是标准 JSON这是“脏乱差”仓库。但工作还得继续数据还得解析怎么办我这些年处理过大量不规则数据逐渐攒下来一套应对思路。这篇文章拿 Python 当主角专门解决那种“看着像 JSON但怎么解析都会炸”的准 JSON。每招我都会讲清楚原理、给出完整代码再补上实际操作里容易忽略的细节。不管你是刚接触 Python 的新手还是每天都在跟接口数据搏斗的资深开发这套方法都能直接复制到你的项目里用。先说个结论不规则 JSON 没有银弹但如果你能掌握“清洗文本 - 兜底容错 - 结构归一”这条链路绝大多数脏数据都能救回来。1. 手撕“脏乱差”JSON之前先认清它到底脏在哪很多人一遇到解析报错就急着上网搜“Python JSON 解析失败怎么办”但如果不先搞清楚这段数据到底“脏”在哪里修复思路就会很乱。我习惯先把不规则数据分成三个类型“脏”“乱”“差”。“脏”指的是语法层面不符合 JSON 规范。最常见的就是单引号{name: 张三}。这种东西其实更接近 Python 的字典写法不是 JSON。还有键名不带双引号比如{name: 张三}这在 JavaScript 里合法在 JSON 标准里却是错的。再比如尾逗号{a: 1, b: 2,}很多生成端为了拼字符串方便会在最后一个元素后面多加个逗号标准解析器直接不认。“乱”指的是结构层面难以预测。同一个字段这一条数据里有下一条数据里就没了同一个字段的值这次是字符串下次变成了数组再下次可能直接是null。还有嵌套层次不固定比如某个data字段有时是data[items]有时又是data[list][items]。这种数据不是语法错误json.loads()能跑通但业务逻辑处理起来会疯狂抛KeyError或TypeError。“差”指的是数据存储和编码层面的问题。文件多了个 UTF-8 BOM 头读进来第一个字符变成\ufeff键名就匹配不上了接口返回的明明是 GBK 编码的中文你用 UTF-8 去解码直接变成乱码还有大整数被解析成浮点数导致精度丢失价格字段被解析成1.2000000000000002这种鬼样子。为了更直观地感受这三类问题我做了个对照表类型常见表现典型例子直接后果脏单引号、无引号键、尾逗号、注释{a: 1,}json.loads 抛 JSONDecodeError乱字段缺失、类型漂移、层级不固定data.info时有时无业务代码抛 KeyError / TypeError差BOM、编码错乱、精度丢失\ufeff{a: 1}键匹配失败、数据失真看清楚问题出在哪一层再去选工具和写法才不会事倍功半。下面三节我就按“脏”“乱”“差”分别给方案。2. 第一板斧把准JSON拉回正轨的ast.literal_eval和正则修复处理“脏”数据的核心思路就一句话先洗回合法 JSON再交给标准解析器。具体怎么做要看脏的程度。2.1 单引号和无引号键优先考虑 ast.literal_evalPython 里有一个经常被忽略的标准库函数ast.literal_eval。它能安全地解析“Python 字面量”结构包括字典、列表、字符串、数字等。对于单引号、元组写法这类“类 Python”文本它比eval安全得多不会执行任意代码。import ast raw {name: 张三, age: 18, tags: [a, b]} try: data ast.literal_eval(raw) print(type(data)) # class dict print(data[name]) except (ValueError, SyntaxError) as e: print(解析失败:, e)但要注意ast.literal_eval只适合键名带了单引号或双引号的情况。如果键名连引号都没有比如{name: 张三}literal_eval也会报错。这种时候可以先用正则给键名补上双引号import re def quote_json_keys(text): # 匹配 { 或 , 后面跟着的、不带引号的键名 pattern re.compile(r([{,]\s*)([A-Za-z_][A-Za-z0-9_]*)(\s*:)) return pattern.sub(r\1\2\3, text)这个方法我实测过很多次能覆盖大部分“无引号键”的修复场景。不过正则总归有边界遇到特别复杂的嵌套对象建议配合literal_eval反复试错。2.2 从混合文本里剜出 JSON 块正则 花括号配对真实项目里更常见的是这样接口返回的是一段日志JSON 被夹在中间前面是[INFO] 2025-06-01 10:00:00 response:后面可能还有别的字符。这时候要先定位 JSON 的起始位置再把完整 JSON 块剪出来。我常用的策略是先找到第一个{或[然后用一个计数器做花括号配对找到匹配的结束位置。因为 JSON 字符串里的花括号需要跳过所以这里要处理转义和字符串状态def extract_json_block(text): start -1 for idx, ch in enumerate(text): if ch in {[: start idx break if start -1: return None depth 0 in_string False escape False for i in range(start, len(text)): ch text[i] if in_string: if escape: escape False elif ch \\: escape True elif ch : in_string False else: if ch : in_string True elif ch in {[: depth 1 elif ch in }]: depth - 1 if depth 0: return text[start:i1] return None这个函数的思路比单纯用正则更稳因为 JSON 的嵌套深度可能很深而正则无法优雅地处理“任意层级的嵌套”。提取出来之后再配合 2.1 节的修复逻辑去解析。2.3 一个完整的 JSON 清洗函数把前面的手段组合起来写一个基础版的清洗函数能应付大部分“脏” JSONdef clean_json_text(text): # 1. 先切出疑似 JSON 的块 block extract_json_block(text) if block is None: raise ValueError(找不到 JSON 起始标记) # 2. 去掉块外的日志噪声 block block.strip() # 3. 去掉注释很多内部接口喜欢在 JSON 里加 // 注释 block re.sub(r//[^\n]*, , block) # 4. 去掉尾逗号 block re.sub(r,\s*([}\]]), r\1, block) # 5. 如果单引号版本解析失败再尝试补键名引号 try: return ast.literal_eval(block) except (ValueError, SyntaxError): block quote_json_keys(block) return json.loads(block)这里每一步都有原因。先提取块是为了避免日志文本污染解析去注释和尾逗号是为了满足标准 JSON 语法literal_eval失败后再用正则补键名引号则是兼容极端写法。实际用下来这套组合拳能救回至少七成“准 JSON”文本。注意如果 JSON 字符串内部本身包含//比如某个字段值是 URLhttp://example.com上面的去注释正则就可能会误伤。保险做法是先检查//前有没有引号上下文或者干脆只用literal_eval优先解析。3. 第二板斧结构乱不慌用递归和默认值兜底语法洗干净了接下来要处理“乱”的问题。结构乱的核心是字段的存在性和类型不稳定。应对手段不是让数据生成端去改——往往你根本联系不上对方——而是写代码的时候用一套“容错思维”来消费数据。3.1 fixture 式的默认值别再让 KeyError 教你做人新手拿到字典第一反应是data[name]但如果是data.get(name)当键不存在时不会抛异常而是返回None。进阶一点你可以通过二次参数给一个业务默认值name data.get(name, 未知用户) age data.get(age, 0) tokens data.get(tokens) or []第三行的or []是很多人容易忽略的妙招当tokens键不存在时.get返回NoneNone or []会得到[]当tokens值恰好是空字符串同样会落回[]。这样在后续写for token in tokens:的时候就不会因为None报错。不过.get只解决深度为 1 的缺失问题。如果数据是嵌套的data.get(a, {}).get(b, {})这种连写就会变得又臭又长。这时候我更推荐写一个工具函数def deep_get(data, path, defaultNone): 从嵌套字典中按路径取值路径不存在时返回默认值。 current data for part in path: if not isinstance(current, dict): return default current current.get(part) if current is None: return default return current用法很简单deep_get(data, [user, profile, age], default18)。路径里的某一层只要是字典就继续往下取但凡中间出现None立刻返回默认值。这个函数我放到几乎所有项目通用工具模块里因为它把复杂的多层判断压缩成了一行而且可读性特别好。3.2 递归遍历处理键名躲得特别深的情况有些脏数据不只是嵌套深连键名都是动态的。比如一个配置接口返回的config是{id_123: {...}, id_456: {...}}具体 key 你根本猜不到。这时候需要遍历所有键而不是死写某个固定键名。我一般会写一个递归生成器把嵌套结构里的所有(路径, 值)对都吐出来def iter_flatten(obj, pathNone): if path is None: path [] if isinstance(obj, dict): for key, value in obj.items(): yield from iter_flatten(value, path [key]) elif isinstance(obj, list): for idx, value in enumerate(obj): yield from iter_flatten(value, path [idx]) else: yield path, obj有了它你可以做很多事情统计所有值类型、检查有没有异常值、把某些字段单独拎出来打印。比如想知道这堆数据里有几个是字符串、几个是数字写个Counter一跑就清楚了。遇到数组里套数组、字典里套字典的“千层饼”递归一次搞定比手写 N 层循环不知道稳到哪里去了。3.3 数组内类型混乱的归一化“乱”的另一种典型表现是同一个字段在数组内一会儿是字符串、一会儿是数字。假设goods字段有时是[苹果, 香蕉]有时是[{name: 苹果}, {name: 香蕉}]业务层肯定没法统一处理。这时候我做归一化def normalize_goods(goods): # 归一化把各种形态统一成纯字符串列表 result [] if not isinstance(goods, list): return result for item in goods: if isinstance(item, str): result.append(item) elif isinstance(item, dict) and name in item: result.append(str(item[name])) elif isinstance(item, (int, float)): result.append(str(item)) return result归一化的核心原则是提前定义好你业务侧期望的最终形态然后把所有可能出现的情况映射过去。这样后续逻辑永远只面对一种数据结构心智负担能小很多。类似的需求还有“把时间字段统一成标准字符串”“把价格字段统一成 Decimal”等思路完全一样。4. 第三板斧编码、精度、空值的“差”数据修复语法和结构都搞定了还有一类问题跟数据本身无关却会卡死解析编码不对、精度丢失、空值形态不一致。别小看这些问题线上事故常常就出在这几处。4.1 先查 BOM再谈解析读取文本文件时如果文件开头带了 UTF-8 BOM\ufeff你用json.load(f)解析就会报错。正确做法是先用encodingutf-8-sig读取Python 会自动剥掉 BOMwith open(data.json, encodingutf-8-sig) as f: data json.load(f)如果是接口文本BOM 会藏在字符串头部直接json.loads大概率报错。先剥 BOM 再解析import json text \ufeff{name: 张三} if text.startswith(\ufeff): text text[1:] data json.loads(text)另外还要提防编码错乱。Python 的requests库会根据响应头猜测编码但猜错并不罕见。稳妥做法是拿到原始字节流后优先按utf-8解码失败再尝试gbkraw response.content try: text raw.decode(utf-8) except UnicodeDecodeError: text raw.decode(gbk, errorsreplace)errorsreplace的意思是个别无法解码的字符用替换符代替先保证整体能解析后面再做数据校验。4.2 精度敏感字段parse_float 与 parse_intJSON 标准里只有一种数字类型对应到 Python 默认会变成float。但很多场景下接口返回的 ID、金额、流水号是长整数一旦被解析成浮点数精度就毁了。比如12345678901234567890默认解析成1.2345678901234568e19业务上直接没法用。解决方法是用json.loads的parse_int和parse_float参数把数字解析成你想要的高精度类型from decimal import Decimal data json.loads( {order_id: 12345678901234567890, price: 19.99}, parse_intDecimal, parse_floatDecimal ) print(data[order_id]) # 12345678901234567890 print(data[price]) # 19.99Decimal的好处是能精确表示十进制小数不会出现19.990000000000002这种浮点误差。但要注意Decimal并不是线程安全的默认选择如果你只是本地处理一次完全没问题。4.3 null、None、空字符串的归一化JSON 里的null解析成 Python 的None空字符串却不会变成None。这两种“空”在业务里的语义可能一样也可能不一样取决于你希望怎么处理。我习惯写一个统一空值函数def clean_empty(value, defaultNone): if value is None: return default if isinstance(value, str) and value.strip() : return default if isinstance(value, (list, dict)) and len(value) 0: return default return value比如解析用户信息时用户没有昵称返回的是我把它统一转成匿名用户后面做展示就省事很多。这个函数可以嵌套到前面的deep_get里一起用效果更好。5. 完整实战从日志文本里提JSON并转成DataFrame前面讲了一堆零散技巧这一节把整个流程串起来。场景很常见运维日志里混着接口返回的 JSON我需要把这些散落的 JSON 解析出来清洗后转成结构化表格方便后续分析。5.1 模拟一份脏日志[INFO] 2025-06-01 10:00:01 request start [INFO] response: {user: {name: 张三, age: 25, tags: [vip, new]}, order: {id: 12345678901234567890, amount: 19.99}} [INFO] 2025-06-01 10:00:02 request start [INFO] response: {user: {name: 李四, age: 30}, order: {id: 98765432109876543210, amount: 299.00}} [ERROR] response error: invalid request每条日志里的 JSON 都是用单引号写的而且李四这条没有tags字段最后还有一条夹杂了错误文本。想直接按行json.loads是行不通的。5.2 完整解析与清洗代码我来一步步写。先把日志读进来一行一行提取 JSON 块然后用clean_json_text清洗接着用容错方式取出字段并归一最后装进列表转成 DataFrame。import ast import json import re from decimal import Decimal from pathlib import Path import pandas as pd # 前面定义的 extract_json_block / clean_json_text / deep_get 放这里 ... def parse_log_to_records(log_path): records [] for line in Path(log_path).read_text(encodingutf-8).splitlines(): if response not in line: continue try: data clean_json_text(line) except (ValueError, SyntaxError, json.JSONDecodeError): # 解析不了的日志行跳过并记录 print(f跳过无法解析的行: {line[:80]}) continue record { user_name: deep_get(data, [user, name], default未知), user_age: deep_get(data, [user, age], default0), user_tags: ,.join(deep_get(data, [user, tags], default[])), order_id: str(deep_get(data, [order, id], default)), order_amount: deep_get(data, [order, amount], default0), } records.append(record) return records这段代码里有几个细节很关键。order_id我特意转成了字符串因为 20 位的长整数在 Excel 或某些数据库里会变形。user_tags用逗号把列表拼起来是为了方便直接看和归类如果你后续要做标签统计也可以保留成列表再展开。deep_get配合默认值让缺失tags的李四这条记录也能正常落库不会中断解析。5.3 转成 DataFrame 并验证records parse_log_to_records(app.log) df pd.DataFrame(records) print(df.head()) print(df.dtypes)输出大致是user_name user_age user_tags order_id order_amount 0 张三 25 vip,new 12345678901234567890 19.99 1 李四 30 98765432109876543210 299.00如果你发现user_tags列出现了 NaN说明某条记录里字段确实缺失或为空列表deep_get返回了默认值[]拼出来是空字符串。再往上追一层就看deep_get里current is None的提前返回逻辑是否合理。这种逐层验证的习惯能帮你很快定位到底是“解析阶段”出问题还是“数据本身”有问题。6. 最后想说的几点容易踩的小坑和更省事的思路处理不规则 JSON 的经验说到底是在“效率”和“严谨”之间找平衡。这里把我实际操作里几次踩坑后的心得分享一下。第一清洗逻辑别写太满。有人会花一整晚去实现一个通用 JSON 修复库试图覆盖所有畸形情况。但多数项目的真实需求只是处理某一个固定数据源。我建议先收集一周的样本看看到底有哪几种错误模式再针对性写修复逻辑二八定律在这里非常明显。第二能用成熟库就别重复造轮子。Python 里有几个第三方库值得关注demjson3、json5、jsonrepair它们对引号、注释、尾逗号的处理比手写正则更健壮。如果你的数据源特别混乱先用这些库跑一遍能省很多事。我之前在做一个爬虫项目时面对 JS 里直接塞过来的对象字面量jsonrepair基本一把梭解决。但话说回来导入第三方库之前还是优先确认它维护活跃、行为符合预期。第三解析后的验证一定要跟上。清洗只是入口数据准不准要看出口。我常用的校验手段包括抽样打印、检查字段类型是否符合预期、统计空值数量和解析失败行数。哪怕是简单到“如果解析失败行数超过总数 5% 就报警”也能避免你在脏数据海里一直埋头游泳。第四对数据源保持敬畏。很多“脏乱差” JSON 背后其实是生成端 bug 或接口文档没更新。修好解析端只是止血有条件的话还是要反馈给上游。我记得有一次排查了半天最后发现是运维脚本把两个日志的换行符吞了导致 JSON 被硬生生切断。这种时候单纯加强解析反而会把问题掩盖掉。如果你是刚开始接触这方面建议先把这篇文章里的extract_json_block、deep_get、clean_json_text这三个函数存进自己的工具库遇到脏数据就拿出来组合用。等遇到更多真实案例你会慢慢形成自己的修复套路。到时候你会发现所谓“不规则数据”其实大多数只是“规则和数据源描述不一致”摸清规律就没什么可怕的。