☰
爬虫实战:JSON中嵌套HTML的双重解析技巧
2026/10/16 4:02:44 网站建设 项目流程

爬虫这行干久了,你会发现真正的麻烦往往不是网站反爬,而是数据本身的结构问题。尤其是“JSON里套HTML”这种嵌套结构——外层是规规矩矩的JSON字段,某一层里却混着一大段前端渲染的HTML模板,再往里一层还塞着动态数据。直接用json.loads解析出来是字符串,拿正则硬抠又容易抠花眼,稍不留神就把不该抓的标签、注释、转义字符一起收了进来。

最近我做了一个专门应对这种场景的爬虫项目,核心任务就是从多层JSON中提取指定节点,再把节点里的HTML模板和动态数据做二次解析。整个过程踩了不少坑,也整理出了一套比较稳的流程。这篇文章把完整的思路、代码片段和排查经验分享出来,适合那些已经会写基础爬虫、但遇到“解析层叠数据”就头疼的朋友。

1. 需求拆解:双重解析到底“双”在哪里

很多爬虫教程只教到“拿到JSON、提取目标字段”就结束了。但现实中的数据往往没那么听话,这个项目里我面对的数据结构大致长这样:

{ "code": 0, "data": { "page": { "template": "<div class='item'>def find_key_in_json(obj, target_key): """递归地在JSON对象中查找第一个出现的target_key对应的值""" if isinstance(obj, dict): for k, v in obj.items(): if k == target_key: yield v else: yield from find_key_in_json(v, target_key) elif isinstance(obj, list): for item in obj: yield from find_key_in_json(item, target_key)

这个生成器写法的好处是,不管字段藏在数组里还是对象的第三层,只要Key名字没变,就能找到。万一Key名字会变,但结构层级固定,那就得反过来,写一个“按路径取数”的工具函数:

def get_by_path(data, path): """按路径列表逐级取值,path示例: ['data', 'page', 'template']""" cur = data for key in path: if isinstance(cur, dict): cur = cur.get(key) elif isinstance(cur, list): cur = cur[int(key)] else: return None if cur is None: return None return cur

实测下来,递归找字段的方式更适合“多页面通用抓取”,路径取值适合“单页面精确抓取”。我在项目里把两种方式都写了,目的不同,用起来不冲突。

2.2 定位HTML字段之前,先观察三种典型脏数据

就算成功定位到字段,拿到的字符串也未必是“干净”的HTML。我在项目里遇到的脏数据主要有三类,放出来给大家提前排雷:

  • 转义符号未解码:比如\u003d表示=,\u0026表示&。如果你直接拿原始字符串去匹配HTML属性,很可能什么都匹配不到。
  • 标签被JSON缩进和换行打散:字段值中可能出现\n、\r、多余空格,导致标签看起来断成了几截。
  • 内嵌模板占位符:像{{name}}这种模板变量,和真实HTML混在一起,需要单独区分。

处理方案也不复杂:先做一次Unicode解码,把\uXXXX转成真实字符,再压缩空白符,最后再去解析HTML。顺序反了,解析结果就会很奇怪。比如你不解码就去做BeautifulSoup,解析器可能会把\u003d原样保留在属性名里,然后整个标签树就乱了。

3. 从HTML字符串里精准“抠”数据的三板斧

HTML解析的方式有很多,可以用BeautifulSoup,也可以上lxml,甚至在个别极端情况下手写正则。我的做法是分层递进:优先用解析库,解析库搞不定的退回正则,正则还是搞不定的,再检查是不是数据本身结构有问题。

3.1 BeautifulSoup提取目标节点时的常用姿势

如果HTML片段比较简单,用BeautifulSoup+SoupStrainer可以节省不少内存。所谓SoupStrainer是只解析你关心的节点,不去构建整棵解析树。之前我抓某站点内嵌模板时,整个HTML片段非常长,里面塞了几百个div,全量解析耗时严重,后来换成只解析含有class="item"的节点,速度提升非常明显。

from bs4 import BeautifulSoup, SoupStrainer strainer = SoupStrainer(class_="item") soup = BeautifulSoup(html_text, "lxml", parse_only=strainer) for node in soup.find_all("span", class_="name"): print(node.get_text(strip=True))

这段代码里有两个坑值得注意。

第一个坑是class_参数的写法。因为class是Python的保留字,所以BeautifulSoup把参数名改成了class_。如果你把class_误写成class,运行时会直接报语法错误。

第二个坑是lxml解析器对格式不规范的HTML容忍度很高,但它不负责解码JSON转义字符。如果你在HTML里看到&amp;,取出文本后它会变成&还是保持&amp;,取决于你用的方法。用get_text()取出来的文本通常是解码后的,但用正则直接抓属性值时,大概率拿到的是原始转义形态。

3.2 模板占位符与真实渲染数据的区分策略

嵌套HTML里最头疼的是“既有模板变量,又有真实数据”。比如template字段是<span class='name'>{{name}}</span>,而list中的html字段是<span class='name'>商品A</span>。两种数据结构不同,但你要提取的目标都是span.name的文字。

我的处理策略很简单:先判断字符串中是否包含{{或{%这类模板标记。如果包含,说明当前是模板占位符,需要走“渲染替换”逻辑;如果不包含,说明是真实数据,直接解析提取即可。

import re def is_template(text): return bool(re.search(r"\{\{.*?\}\}|<%.*?%>", text)) def parse_template(text, data_mapping): """把模板中的占位符替换为真实数据""" def repl(match): expr = match.group(0)[2:-2].strip() return str(data_mapping.get(expr, match.group(0))) return re.sub(r"\{\{(.*?)\}\}", repl, text)

为什么不用现成模板引擎?因为项目里只是极简占位符,犯不上引入完整依赖。但如果你要处理的是复杂逻辑,比如带条件的片段、循环列表,那还是建议直接上Jinja2,只要把默认的起始标记和结束标记配置好就行。

3.3 正则只做“最后一公里”提取

很多场景下,解析库能完成90%的工作,剩下10%反而是正则更高效。比如你想从HTML标签的属性里拿>pattern = r'data-id="(\d+)"\s+class="([^"]+)"' matches = re.findall(pattern, html_text)

需要注意,正则适合处理“结构稳定、嵌套不深”的片段。一旦HTML标签超复杂、属性顺序不固定,正则的表达能力就不够用了。我的经验是:能用lxml的XPath解决就不要硬写正则是应对千奇百怪HTML的核心原则。你看着眼前这段好像很简单,但换一个接口的HTML片段可能就会多出几十种陌生写法,只有结构化解析能兜住。

4. 性能与稳定性:并发请求与解析器选型的平衡点

项目里如果数据量少,解析性能不是大问题。但一旦对接的是每天几十万条数据更新的异步接口,解析速度和请求频率就得认真考虑了。

4.1 优先使用lxml解析器而不是html.parser

同样一段HTML,用BeautifulSoup默认的html.parser和lxml,速度差距在数倍左右。官方文档里也明确建议使用lxml作为后端解析器。测试下来,在包含大量重复标签的页面上,lxml的解析速度几乎是html.parser的3倍以上,CPU占用也更低。

如果你对内存有极致要求,可以考虑用selectolax或parsel。其中parsel的语法是XPath+CSS选择器,适合想绕开BeautifulSoup API的人。但别为了性能牺牲可读性,除非项目有硬性指标,否则“团队里大家都会用”才是选型第一优先级。

4.2 使用Session复用连接并限制并发数

请求层面,我会用requests.Session创建一个长连接会话,这样多个请求之间可以复用TCP连接,显著降低握手开销。并发方面,用ThreadPoolExecutor把单线程串行改成多线程并发。当然,并发的线程数要保守,一般设3到5个,否则容易被服务端限流。

from concurrent.futures import ThreadPoolExecutor def fetch_one(url): with requests.Session() as s: resp = s.get(url, timeout=10) return parse_html_to_model(resp.text) with ThreadPoolExecutor(max_workers=3) as executor: results = list(executor.map(fetch_one, url_list))

注意这里有一个容易踩的坑:ThreadPoolExecutor.map返回的结果顺序是有序的,和传入的url_list顺序一致。但这不意味着请求是按顺序发出的,只是结果收集时按顺序对齐。如果你依赖“第一个请求的结果先处理”,这种写法没问题;如果你需要“先到先处理”,得改用as_completed。

4.3 解析失败时的断点续抓与日志记录

爬虫最怕的不是抓不到数据,而是抓取了一多半之后挂了,重新跑一遍又浪费大量时间。所以在解析循环里,我会给每个URL生成一个任务ID,把已经抓取成功的结果写入本地JSONL文件,解析失败的记录写入失败日志,下次启动时先读失败日志,只重试失败项。

import json def save_success(item, filename="result.jsonl"): with open(filename, "a", encoding="utf-8") as f: f.write(json.dumps(item, ensure_ascii=False) + "\n") def log_failure(url, error_msg, filename="failed.log"): with open(filename, "a", encoding="utf-8") as f: f.write(json.dumps({"url": url, "error": str(error_msg)}) + "\n")

这个方法看似简单,但救了我好多次。有一次我抓一个规模不小的站点,跑到第4000条的时候遇到网络抖动,连续三四个请求异常,如果没有记录失败日志,整个任务就得从头再来。有了增量续爬,只用了几十个请求就把缺口补上了。

5. 项目实战复盘:一次“解析链路”完整走读

说了这么多思路和代码片段,不如用一个完整的走读来串一遍。我以项目中一个模拟场景为例,带着大家从原始JSON入口开始,一路走到最终结构化数据。

5.1 原始响应长什么样

某个模拟接口返回的JSON片段如下(已脱敏):

{ "result": { "components": [ { "type": "banner", "content_model": { "template_code": "<section class=\"hero\">raw_json = json.loads(resp.text) template_code = None default_value = None card_htmls = [] def walk(obj): global template_code, default_value if isinstance(obj, dict): if "template_code" in obj: template_code = obj["template_code"] if "default_value" in obj: default_value = obj["default_value"] for v in obj.values(): walk(v) elif isinstance(obj, list): for item in obj: walk(item) walk(raw_json) # 真实卡片从 components[1].items 里取 cards = raw_json["result"]["components"][1]["items"] card_htmls = [card["card_html"] for card in cards]

第二步:渲染模板,获得预期HTML样式。

rendered_template = render_simple_template(template_code, default_value)

第三步:分别解析模板HTML和真实卡片HTML,抽出关键字段。

from bs4 import BeautifulSoup def extract_fields_from_html(html): soup = BeautifulSoup(html, "lxml") return { "pid": soup.find("article")["data-pid"] if soup.find("article") else None, "title": soup.find("h2").get_text(strip=True) if soup.find("h2") else None, "desc": soup.find("p").get_text(strip=True) if soup.find("p") else None, } result = [] for html in card_htmls: result.append(extract_fields_from_html(html))

第四步:把模板渲染结果和真实解析结果写成一个统一结构,输出JSONL。

这一步的代码虽然简单,但体现了整个双重解析的关键——模板与真实数据走的提取路径不一样,最后又要归并到同一个输出模型里。我在项目中把这个归并操作封装成了一个merge_into_standard_model()函数,把所有字段名统一成小写驼峰风格,方便下游消费。

5.3 这次复盘中暴露的四个典型问题

这里单独把复盘过程中遇到的问题列一下,都是实际项目里踩过的坑。

第一,模板中的{% if %}条件标签在渲染时被我当成普通文本处理了,结果页面里出现了一堆“{% if title %}”的草稿。最后是通过识别{%和%}标记,暂时全部剔除才恢复了干净文本。

第二,有的HTML字段值里存在\r\n换行,导致BeautifulSoup在解析时把标签中间的空格当成文本节点,影响查找。解决办法是在解析前统一执行re.sub(r'\s+', ' ', html),将连续空白压缩为单个空格。

第三,在使用SoupStrainer时,如果不小心把class_="item"写成字符串而不是字符串列表,当目标元素有多个class时,解析会失败。实际发现部分节点是class="item active",等于选择器条件不匹配。改成列表形式class_=["item", "active"]后,所有节点都正常了。

第四,JSON里某个字段的值是null,而不是字符串类型。解析函数里没有兼容None的情况,导致调用len()或.strip()时直接崩溃。后来我写了一个ensure_str()包装函数,传入None就返回空字符串,所有解析入口统一走这个包装,杜绝类型问题。

6. 从项目里沉淀下来的四个通用小经验

如果你以后也要做“JSON套HTML”类项目的解析,下面几条经验可以直接借鉴。

6.1 解析前先做数据体检

拿到接口响应后,先别急着写业务代码。用几个print或日志把响应里关键字段的字节长度、字符样本、转义情况打出来看看。很多时候,解析失败的原因不是代码逻辑不对,而是你对数据形态的判断有偏。比如某字段在测试环境返回的是字符串,在线上环境返回的却是数组,这类差异只有通过体检才能提前暴露。

6.2 用中间层协议隔离接口变化

接口字段名的变化是爬虫项目最大的维护风险。我习惯在解析器和上层业务之间放一个“中间模型层”,也就是先用函数把原始字段映射为统一模型字段,再让主逻辑依赖这个模型。这样即便上游把template_code改成template,我只需要改映射函数一处代码。这个思想不算高级,但确实能省下大量时间。

6.3 谨慎使用“万能解析库”

市面上有不少号称“自动识别网页正文”的解析库,确实很方便,但如果你抓的是固定接口的HTML片段,不要迷信它们。因为自动识别的前提是“内容与噪音混杂”,而我们的场景往往是“结构化模板+结构化数据”,用明确的选择器和属性规则反而更精准,也更便于回溯。

6.4 保留原始数据,不要只存结果

我刚开始做爬虫时,总是习惯直接把解析后的结果存进数据库,原始JSON直接丢掉。后来遇到一次数据回溯需求,发现没有原始报文,连问题都定位不了。现在所有任务都会把原始响应做一份GZip压缩存档,至少保留30天,既保证隐私又不占太多空间。这个习惯在某些数据合规要求严格的场景下尤其重要。

整个“JSON嵌套HTML双重解析”的项目做完,我的核心感受是:解析不是一个技术难点,而是一个“流程管理”问题。把数据分层、把解析步骤拆开、把每一步的边界划清楚,复杂结构也能处理得干净利落。希望这篇文章里的思路和踩坑经验能给你一些参考,下次遇到类似的需求时不用再上上下下折腾半天。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询