☰
JSON嵌套HTML的爬虫解析:从双层提取到JSON-LD结构化采集
2026/10/11 22:34:39 网站建设 项目流程

做爬虫最怕的不是对方上了多牛的反爬,而是数据自己跟自己套娃。明明接口返回的是JSON,打开一看某个字段里面嵌着一整段HTML;等你把这坨HTML整理出来,居然还藏着一份JSON结构化数据。这种JSON里套HTML、HTML里再嵌JSON的场景,在资讯聚合、商品监控、结构化标注采集里相当普遍,也是“精准爬虫”真正烧脑的地方。我直接把拆解思路、代码实现和踩坑记录完整过一遍,适合被嵌套数据折腾过的同学,也适合准备入坑多层数据提取的新手。

1. 问题拆解:JSON嵌套HTML到底难在哪

1.1 三种最常见的套娃现场

先说第一个现场:接口字段里直接嵌正文HTML。比如一个资讯类接口返回{"data": {"content": "<div class=\"article-body\"><p> 正文 </p><img src=\"/images/1.jpg\"></div>"}}。这种数据通常是后端让运营用富文本编辑器维护内容导致的,编辑存的是带样式的HTML,而不是纯文本。爬虫想拿到干净的正文、图片、链接,就得先把JSON拆开,再进HTML里面翻。

第二个现场是反过来的:页面源码里嵌着JSON。很多前端框架首屏渲染时,会把初始化数据直接塞进<script id="__NEXT_DATA__">或window.__INITIAL_STATE__这样的标签里,里面又是一个完整的JSON对象。这种HTML套JSON,本质上也是双层解析,只是第一层变HTML,第二层变JSON,处理思路可以复用。

第三个现场最折腾:JSON字段里的HTML,还藏着JSON-LD。很多详情页会在<script type="application/ld+json">里写结构化数据,用来告诉搜索引擎这篇文章的作者、发布时间、封面图。你爬的时候,如果只提取了正文文本,这些关键元数据就丢掉了。想要拿到全量信息,就得顺着“JSON → HTML → 内嵌JSON”这条链一路穿过去。

1.2 为什么不能靠一个正则全干完

拿到这种嵌套数据,第一反应往往是写个正则直接把HTML里的图片、文本抠出来。说实话,正则不是完全不能用,但它特别容易在嵌套场景里翻车。HTML本质上是一棵DOM树,标签可以嵌套、属性顺序可以换、标签可以不闭合,而正则是一维线性匹配,它没法理解“这个<div>对应哪个</div>”。

更麻烦的是,JSON字段里的HTML往往经过双重转义。原始HTML里有引号,存进JSON字符串时引号会被转义成\",换行会变成\n;如果接口又做了一次URL编码或HTML实体编码,你还要再解一层&quot;、&amp;。这时候写正则要同时处理转义和嵌套,一个边界条件没想清楚,采集结果就会莫名缺字段。

我拿生活里的例子类比:正则像是用一把直尺去量一段台阶,你要一级一级分开量也行,但只要台阶高度稍微不规整,尺子就废了。解析器则是直接沿着台阶走一遍,天然知道每一级在哪。

1.3 整体解析链路与工具选型

所以我的方案不是在某一个函数里把所有事情干完,而是把解析拆成一条分层链路:

  • 第一层:把接口返回的原始JSON解析成Python对象,找到承载HTML的字段;
  • 第二层:通过HTML解析器把字段里的标签片段变成DOM树,提取正文、链接、图片;
  • 第三层:如果HTML片段里还有JSON-LD或嵌入式JSON,继续用JSON解析器取出来;
  • 最后:把多层来源的数据合并、标准化、去重,输出成统一结构。

工具选型上,请求用requests,JSON用标准库json,HTML解析用BeautifulSoup4 + lxml,URL补全用urllib.parse.urljoin。这套组合足够稳,也足够轻量。BeautifulSoup负责把HTML变成好操作的树结构,lxml负责提供底层解析速度,这两个搭配是我用了很久的组合,基本没换过。

2. 第一层解析:把JSON里的HTML字段安全地抠出来

2.1 别用裸索引:多层字段访问要带兜底

新手最容易踩的第一个坑,就是直接写data["data"]["content"]。接口只要有一次返回data为null,或者content字段被去掉,这里就会抛KeyError或TypeError,整个爬虫直接中断。更稳妥的做法是用dict.get()一层层往下拿,并且把每一层都设置默认值。

def dig(data: dict, path: str, default=None): """按点号路径安全取值,例如 dig(data, "data.content")""" current = data for key in path.split("."): if not isinstance(current, dict): return default current = current.get(key) if current is None: return default return current

这个工具函数的核心价值,是把“字段缺失”和“字段值为空”都归到同一个兜底逻辑里。实际接口里,content经常存在但值是null,这种情况下你用content.get("data", {}).get("content", "")会拿到空字符串,而不是报错。我习惯进一步判断:如果字段是None,返回默认的空字符串;如果是其他意外类型,也直接给默认值,免得后面接一个非字符串继续处理时炸掉。

2.2 转义还原有顺序:先json.loads再html.unescape

JSON字段里的HTML之所以难搞,很大程度是因为转义层层叠加。一个正确顺序特别重要:先用json.loads把JSON字符串变成Python对象,让\n还原成真实换行、\"还原成真实双引号,然后再用html.unescape去还原HTML实体。

import json import html raw = '{"content":"<div class=\\"a\\">A &amp; B</div>"}' obj = json.loads(raw) content = html.unescape(obj["content"]) print(content) # <div class="a">A & B</div>

如果你把顺序调反,先对整段原始字符串做html.unescape,再把结果交给json.loads,很容易把JSON结构里的引号也解开,导致解析失败。这个顺序问题,我一开始没注意,后来排查了几次才发现是自己把实体还原和JSON解析搞反了。

另外要注意,html.unescape能把&amp;、&lt;、&nbsp;这类实体转成字符,但它不会去处理JSON转义,所以两个函数必须配合,而不是二选一。

2.3 一个函数打通第一层:字符串、空值和列表都要照顾

实际接口里content字段不一定是字符串。有些系统会把正文存成数组,每一项是一段文本;有些会存成对象,里面又有content、images多个键;还有些干脆就是null。所以第一层解析函数,要在类型判断上做足防护。

def extract_content(data: dict) -> str: content = dig(data, "data.content") if content is None: return "" if isinstance(content, str): return html.unescape(content.strip()) if isinstance(content, list): parts = [html.unescape(str(item).strip()) for item in content if item] return "\n".join(parts) if isinstance(content, dict): return html.unescape(str(content.get("html", content.get("text", ""))).strip()) return str(content).strip()

这段代码的核心思想是把第一层解析收口到单一入口。之后不管接口返回结构怎么变,只需要调整这一个函数,不用跑到全工程到处找data["content"]。我在项目里还会额外加一层日志:如果content既不是字符串也不是列表,说明接口结构变了,记一条warning,方便后面快速定位。

3. 第二层解析:HTML选择器这样写才稳

3.1 解析器选型:别在html.parser上死磕

BeautifulSoup支持多种底层解析器,我默认用lxml,而不是内置的html.parser。原因很简单:lxml是C语言扩展,解析速度快得多;处理残缺HTML的容错性也更好。有些接口返回的HTML片段是截断的,比如少一个</div>,html.parser可能直接放弃治疗,lxml会自动补全标签树。

解析器速度容错性依赖适用场景
html.parser慢一般内置不想装依赖、HTML极规范
lxml快好需安装大部分生产环境,推荐
html5lib很慢最好需安装HTML极其不规范,容错优先

选lxml之后,初始化代码是soup = BeautifulSoup(html_fragment, "lxml")。HTML片段不需要完整的<!DOCTYPE html>,只要标签片段就能解析。但要注意,解析HTML并不会执行里面任何脚本,不用担心代码被页面里的JS影响,这也是用解析器而不是直接用浏览器内核的好处。

3.2 正文、图片、链接的提取套路

第二层解析的难点不是写出选择器,而是写出“换一个页面也能活”的选择器。我一般先找一个稳定的正文容器,比如div.article-body或div#content,然后用select_one定位:

soup = BeautifulSoup(html_fragment, "lxml") body = soup.select_one("div.article-body") or soup.select_one("article") or soup

这里有优先级:先精确匹配已知class,再回退到语义化标签article,最后干脆把整个片段当正文。这样做的原因是很多接口不会告诉你哪个是正文容器,只给了HTML片段,你只能用多级候选来兜底。

提取文本时,我会先把script和style标签删掉,避免把脚本或CSS文本混进正文:

for tag in body.find_all(["script", "style"]): tag.decompose() text = body.get_text(separator="\n", strip=True)

如果你直接调用get_text(),块级元素之间不会自动换行,所有文字会挤成一大段。加上separator="\n"后,段落边界基本能保留,可读性好很多。

提取图片要稍微多想一步。直接遍历所有img是可行的:

images = [] for img in body.find_all("img"): src = img.get("src") or img.get("data-src") or img.get("data-original") if not src: continue images.append(src)

很多运营后台的富文本编辑器,会给图片加懒加载,真实的图片地址放在>from urllib.parse import urljoin base_url = "https://example.com/news/detail/10001" full_url = urljoin(base_url, "/images/1.jpg") print(full_url) # https://example.com/images/1.jpg

srcset属性的处理也不能忽略。现在很多站点响应式图片用srcset写多个分辨率地址,如果你只取src,可能拿到小图。一个简单做法是从srcset中取最后一个地址,通常是最大尺寸:

def get_best_image_src(img): srcset = img.get("srcset") if srcset: candidates = [item.strip().split(" ")[0] for item in srcset.split(",")] return candidates[-1] return img.get("src") or img.get("data-src")

这里有个细节:srcset每个条目之间用逗号分隔,条目格式是“地址 宽度描述”,所以取split(" ")[0]就能拿到地址。我见过不少爬虫在srcset上翻车,就是因为没做拆分直接当成一个URL用,拿到的连接永远是坏的。

4. 第三层链路:HTML里藏着的JSON怎么顺带摸出来

4.1 识别并解析JSON-LD结构化数据

如果HTML字段里还藏着<script type="application/ld+json">,那就是真正的第三层数据。这种JSON-LD通常包含headline、author、datePublished、image等字段,比接口外层字段还规范。解析办法也很直接:

ld_json_list = [] for script in soup.find_all("script", {"type": "application/ld+json"}): script_text = script.get_text() try: ld_json_list.append(json.loads(script_text)) except json.JSONDecodeError as e: print(f"JSON-LD解析失败: {e}")

这里要注意,页面里可能不止一个JSON-LD标签。有些站点会分别输出NewsArticle、BreadcrumbList、Organization等多个结构化数据对象。所以用find_all而不是select_one,把每个都解析出来,然后按照业务需要挑选。

json.loads对JSON-LD文本里可能出现的HTML实体可不会自动处理。有些结构化数据里会有转义后的引号,或者\/这种转义斜杠,如果不放心,可以先做一层html.unescape再交给json.loads。不过这样做有个副作用:如果脚本里的JSON字符串本来就包含&,解开实体可能会改变数据结构。所以我一般先直接解析,失败时再尝试html.unescape后的版本。

4.2 多来源字段合并与优先级设计

第三层数据拿到后,会面临一个问题:同样的字段,接口外层有,JSON-LD里也有,到底信哪个?我的经验是不要无条件信任何一边,而是设计一个优先级,并且记录字段来源。

final_item = { "title": dig(data, "data.title") or ld_data.get("headline") or "", "author": dig(data, "data.author") or ld_data.get("author", {}).get("name") or "", "publish_time": dig(data, "data.publish_time") or ld_data.get("datePublished") or "", "content_text": text, "images": images, }

优先级怎么定要看场景。如果接口外层字段是运营填写的,直接信它;如果外层缺失,再回退到JSON-LD。这样设计的好处是,当某个字段在外层被漏掉时,至少JSON-LD能兜底,不会让结果出现大面积空值。同时,外层字段和JSON-LD不一致的情况也经常见,我会额外存一个fields_conflict标志,方便人工抽检。

4.3 统一Schema与落库去重

多层提取的结果,最终要落到一个统一结构里,否则后面写库、导出、对接都会很痛苦。我习惯定义一个固定的输出字典,字段名统一用下划线命名,所有来源的数据都往这个Schema里塞。落库时还要考虑去重,最稳妥的方式是给页面URL生成一个唯一ID:

import hashlib item_id = hashlib.md5(url.encode("utf-8")).hexdigest()

用URL生成MD5,可以有效避免同一条新闻被重复入库。有些站点同一个内容会生成多个追踪参数,像?utm_source=xxx,存库前最好先把URL参数规范化,去掉这些追踪参数再去算ID,否则还是会重复。

5. 完整实操:一个双层解析精准爬虫示例

5.1 请求层:Session、超时和重试要提前配好

爬虫工程里,请求层决定了上层解析能不能拿到完整数据。我一般用requests.Session(),这样多个请求能复用底层TCP连接,明显比每次新建连接快。超时和重试必须配置,否则遇到网络抖动整个任务就卡死在那里。

import requests from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry session = requests.Session() retry = Retry(total=3, connect=3, read=3, backoff_factor=0.5, status_forcelist=[500, 502, 503, 504]) adapter = HTTPAdapter(max_retries=retry, pool_connections=10, pool_maxsize=10) session.mount("http://", adapter) session.mount("https://", adapter) headers = { "User-Agent": "Mozilla/5.0 (compatible; DataCollector/1.0; +https://example.com)", "Accept": "application/json, text/html, */*", }

重试的backoff_factor=0.5意思是第一次重试等0.5秒,第二次等1秒,第三次等2秒。这种指数退避能降低对目标服务的瞬时压力,也能避免重试风暴把对方打挂。请求频率上,我还会在循环里加入time.sleep(0.5)这种节奏控制,不要让采集行为给对方造成压力。

5.2 主流程:JSON层、HTML层、JSON-LD层依次打通

下面这个示例会把前面所有环节串起来。假设接口返回结构是{"data": {"content": "<div>...</div>"}},我们从请求到解析一气呵成:

import requests import json import html import re from bs4 import BeautifulSoup from urllib.parse import urljoin def fetch_json(url): resp = session.get(url, headers=headers, timeout=10) resp.raise_for_status() # 有些接口返回UTF-8 BOM,直接json.loads会报错,用utf-8-sig解码 return json.loads(resp.content.decode("utf-8-sig")) def parse_article(api_url, page_url): data = fetch_json(api_url) # 第一层:JSON字段提取 content_field = (data.get("data") or {}).get("content") or "" if isinstance(content_field, list): content_field = "".join([str(x) for x in content_field if x]) content_field = html.unescape(content_field) # 第二层:HTML解析 soup = BeautifulSoup(content_field, "lxml") for tag in soup.find_all(["script", "style"]): tag.decompose() body = soup.select_one("div.article-body") or soup.select_one("article") or soup text = body.get_text(separator="\n", strip=True) text = re.sub(r"\n{3,}", "\n\n", text) # 压缩多余空行 images = [] for img in soup.find_all("img"): src = img.get("src") or img.get("data-src") or img.get("data-original") if not src: continue images.append(urljoin(page_url, src)) # 第三层:JSON-LD解析 ld_raw_list = [] for script in soup.find_all("script", {"type": "application/ld+json"}): try: ld_raw_list.append(json.loads(script.get_text())) except json.JSONDecodeError: continue ld_data = ld_raw_list[0] if ld_raw_list else {} return { "url": page_url, "title": (data.get("data") or {}).get("title") or ld_data.get("headline") or "", "author": (data.get("data") or {}).get("author") or (ld_data.get("author") or {}).get("name") or "", "publish_time": (data.get("data") or {}).get("publish_time") or ld_data.get("datePublished") or "", "content": text, "images": images, "ld_json": ld_data, }

这段代码就是一个完整的双层解析示例。注意我在第三层用try/except json.JSONDecodeError包住JSON-LD解析,因为有些站点会在里面放不标准的变量或注释,解析失败不能影响正文已经拿到手的数据。

5.3 结果输出与错误日志

主流程跑完后,结果最好先看一眼再入库。我习惯在调试阶段把结果直接打印成JSON,确认字段没问题再开数据库写入:

item = parse_article( "https://api.example.com/article/10001", "https://example.com/news/detail/10001" ) print(json.dumps(item, ensure_ascii=False, indent=2))

生产环境里,日志比print重要得多。我通常会给每个环节打上标记,哪一层失败就记录哪一层:

import logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger("nested_parser") logger.info("JSON layer ok, content length=%d", len(content_field)) logger.info("HTML layer ok, text length=%d, images=%d", len(text), len(images)) logger.warning("JSON-LD missing, fallback to outer fields")

这样排查问题的时候,你能直接看到是接口结构变了、HTML解析器没命中,还是JSON-LD格式不标准,不用从头到尾人肉推演。

6. 常见问题与避坑清单

6.1 JSON解析报错的定位顺序

json.loads报错有很多种,但常见现场相对固定。如果是Expecting value: line 1 column 1 (char 0),多半是拿到的响应是空字符串或者带BOM;如果是Unterminated string starting at,多半是接口返回内容被截断,或者响应本身不完整。BOM问题用resp.content.decode("utf-8-sig")就能解决,截断问题则需要检查请求是否用了分页、是否需要传更多参数,而不是硬解析。

我遇到JSON解析失败时,第一件事不是改代码,而是把原始响应前200个字符打出来。看一眼就知道是BOM、是HTML错误页、还是空数组,省去大量瞎猜。日志里我会固定记录原始响应的前200字符,这招帮我解决过很多次诡异问题。

6.2 选择器失效时怎么快速找到稳定锚点

HTML选择器失效,大概率是因为站点改了class名。有些前端框架会生成动态class,比如.css-3ty7d,这种选择器今天能用明天就能失效。我的对策是优先用稳定属性、标签结构和位置关系,而不是死记class。

比如正文容器可以先用soup.select_one("div[data-role='content']"),再回退到main > article,最后再尝试带动态class的容器。实在不行,可以用re.compile匹配前缀:

body = soup.find("div", class_=re.compile("^article-content"))

但这个只能作为兜底,因为正则匹配class本身就是临时方案,等到页面彻底改版还是会挂。更好的思路是多级候选、按优先顺序尝试,每个候选都判空,保证不因单个选择器失效而全盘崩溃。

6.3 中文乱码先查编码再查实体

中文乱码和HTML实体是两码事。如果你的结果出现é这种乱码,说明响应编码识别错了。requests有时候会根据响应头猜编码,但不少接口的响应头没写清楚,这时候可以强制用apparent_encoding或直接解码:

resp.encoding = resp.apparent_encoding text = resp.text

如果apparent_encoding也不准,可以先用resp.content.decode("utf-8", errors="replace")看看,再换gb18030试试。国内不少站点用的还是GBK系编码,直接按UTF-8解必然乱。而&nbsp;这类问题属于HTML实体,要交给html.unescape,两个工具最好在各自层级分别处理,不要混在一起。

6.4 性能、频率和合规红线

有些人写爬虫只关注解析,不关注频率,结果把目标站点的服务都拖慢了。我的习惯是:并发不要贪多,任务间隔保持在合理范围;能用一个小并发池就不要开一堆协程。Session复用连接池已经能省掉大量握手开销,剩下的就是老老实实控制节奏。

还有一个底线问题:尽量只采集公开数据,不碰需要登录或明确有访问限制的资源。采集前可以看一眼对方的robots.txt,无论技术上能不能绕过,都要尊重站点规则。做数据采集不是为了跟对方较劲,而是为了稳定、长期地拿到自己需要的数据。

最后分享一个我自己受用的习惯:我会把“JSON字段提取”和“HTML解析”拆成两个独立模块,各自带好日志和容错。以后不管接口字段怎么改、页面结构怎么动,只需要换一层函数,另一层完全不受影响。这个设计,比任何花哨的解析技巧都重要。

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

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

立即咨询