简介:这是一套轻量级HTML转JSON工具的完整开源实现,面向Python开发者、前端工程师及数据处理人员,解决网页结构化数据提取与表格语义化转换的常见需求。资源包共32个文件,涵盖7个核心Python模块(含html_to_json主库、HTML与表格转换双引擎)、6个测试用HTML样例、4个配置类YML/TOML/INI文件、2个Markdown文档(README与CHANGELOG)及LICENSE等工程规范文件,整体仅512KB,便于快速集成与二次开发。已有716人学习下载,适合需要将网页内容、尤其是带表头的HTML表格自动映射为键值清晰的JSON对象的中初级开发者。读者可直接复用convert_html.py与convert_html_tables.py两个核心逻辑模块,参考test_html_tables_to_json.py中的多变体表格测试用例(含jpg示意图),并借助Dockerfile、mypy.ini、.coveragerc等工程配置快速搭建本地开发与测试环境。
1. HTML转JSON不是字符串替换:为什么表头自动映射键名这件事,90%的脚本都做错了
你手头有一份从某内部系统导出的 HTML 报表,结构规整、有<thead>、有<th>、每行<tr>下<td>数量一致——但它是 HTML,不是 API 返回的 JSON。你想把它喂进前端图表库、塞进 Python 数据分析流水线、或作为测试 fixture 加载进单元测试。这时候搜“html to json”,出来的方案要么是粗暴innerHTML+ 正则硬切,要么依赖重量级浏览器环境跑 Puppeteer,要么把<table>当成普通 DOM 树暴力递归,结果键名变成"td_0": "张三", "td_1": "28"——完全丢失语义。
html-to-json这个名字看似简单,实则暗藏两层关键能力:第一层是 HTML 解析的健壮性(能吃掉不闭合标签、嵌套错位、命名空间混乱);第二层是语义还原的智能性(识别<th>是否为列头、处理跨行/跨列合并、区分数据行与分组行)。它不是转换器,而是HTML 表格语义提取器。适合两类人:一是需要快速将运营导出页转为结构化数据的后端开发者;二是做低代码平台表格解析模块的前端工程师。如果你的 HTML 来自 Excel 导出、CMS 后台报表、或老旧系统页面快照,这个方向值得深挖——但必须从解析器选型开始踩准每一步。
2. 为什么不用 BeautifulSoup + 手写遍历?选对解析器才是省下三天调试的关键
2.1 三种解析器在真实 HTML 上的表现对比:谁在<colgroup>和<tbody>缺失时翻车
真实业务 HTML 从不按 W3C 标准写。我们拿某高校教务系统导出的课表 HTML(含<colgroup>定义宽度、<tbody>被省略、部分<th>带rowspan="2")做压力测试:
| 解析器 | <colgroup>支持 | 自动补全<tbody> | <th>跨行合并识别 | 内存峰值(10MB HTML) |
|---|---|---|---|---|
html.parser(Python 标准库) | ❌ 忽略全部<col> | ❌ 无<tbody>则所有<tr>直接挂到<table>下 | ❌ 将rowspan="2"视为普通属性,不参与列对齐 | 42 MB |
lxml(推荐) | ✅ 完整保留<col>结构 | ✅ 自动注入<tbody>包裹孤立<tr> | ✅ 提供getnext()和getparent()精确追踪跨行单元格归属 | 68 MB |
cheerio(Node.js) | ⚠️ 仅保留<col>标签,不解析width属性 | ✅ 补全<tbody> | ⚠️ 能读取rowspan,但需手动计算跨行影响,无内置列对齐逻辑 | 51 MB |
提示:
lxml的etree.HTML()在解析阶段就完成 DOM 修复,比运行时用 JS 模拟浏览器行为的方案更可控。cheerio适合已用 Node.js 构建 pipeline 的团队,但跨行逻辑需自己补全——这不是“能不能做”,而是“要不要为每种合并场景写 if-else”。
2.2 表头智能映射的底层逻辑:不是找<th>,而是重建列坐标系
很多人以为“取第一行<th>文本当 key”就够了。但真实表格常有这些情况:
- 多级表头:
<tr><th colspan="2">成绩</th><th>学分</th></tr><tr><th>平时</th><th>期末</th><th></th></tr>→ 实际列应为["平时", "期末", "学分"],而非["成绩", "成绩", "学分"] - 空表头:
<th></th><th>姓名</th>→ 第一列 key 应为"col_0"而非""(空字符串作 key 会破坏 JSON 可读性) - 重复表头:
<th>姓名</th><th>姓名</th>→ 需生成"姓名_0", "姓名_1"避免覆盖
html-to-json的核心算法是:
- 扫描所有
<tr>,标记哪些是表头行(依据<th>密度 > 70% 或<th>占该行单元格数 ≥ 50%) - 对每个
<th>计算其“有效列跨度”:colspan × rowspan,并累加到对应列索引的权重池 - 按列索引顺序,取权重最高的
<th>文本(去空格、去换行、去不可见字符)作为该列 key;若全为空,则 fallback 为"col_{index}"
这解释了为什么不能用querySelectorAll('th')简单遍历——你拿到的是扁平列表,而列坐标系是二维的。
2.3 用 lxml 实现最小可行转换:5 行代码跑通基础流程
from lxml import etree import json def html_table_to_json(html_content: str) -> list: tree = etree.HTML(html_content) # 自动修复缺失标签 tables = tree.xpath('//table') if not tables: return [] table = tables[0] headers = [] rows = [] # 提取表头:只取第一组连续的 <th> 行(跳过 <td> 行) for tr in table.xpath('.//tr'): ths = tr.xpath('./th') if ths: # 本行是表头行 headers = [h.text_content().strip() for h in ths] break # 提取数据行:从第一个非表头行开始 for tr in table.xpath('.//tr'): tds = tr.xpath('./td') if not tds: # 跳过纯表头行 continue row_data = [td.text_content().strip() for td in tds] if len(row_data) == len(headers): rows.append(dict(zip(headers, row_data))) return rows # 测试调用 html_sample = """ <table> <tr><th>姓名</th><th>年龄</th><th>城市</th></tr> <tr><td>李四</td><td>32</td><td>杭州</td></tr> </table> """ print(json.dumps(html_table_to_json(html_sample), indent=2, ensure_ascii=False))逻辑说明:
etree.HTML()是关键入口,它比etree.fromstring()更宽容,能处理<br>不闭合、<img>无结束符等脏数据xpath('.//tr')获取所有行,避免手动遍历childNodes(易漏<tbody>)text_content()比text更可靠——它会合并<span>内文本,而text只取直接子文本节点zip(headers, row_data)是安全配对的前提:必须确保len(row_data) == len(headers),否则dict()会截断或报错(这点在后续避坑章重点讲)
3. 表格结构异常时的三大翻车现场:现象、原因、一行命令定位
3.1 现象:JSON 输出里突然多出"col_0": "姓名", "col_1": "年龄",但原始 HTML 明明有<th>
原因:<th>行被解析为<td>—— 常见于 CMS 导出 HTML 中<th>被错误写成<td class="header">,或<th>外层被<div>包裹导致 XPath 未匹配到。
解决:
# 先用 xpath 查看实际解析结果(Linux/macOS) echo "$html_sample" | xmllint --html --xpath '//table//tr[1]//th | //table//tr[1]//td[@class="header"]' - # 若无输出,说明解析器没看到 `<th>`,改用更宽松的选择器: # //table//tr[1]//*[self::th or (@class="header" and self::td)]3.2 现象:同一列数据在不同行里错位,如"城市": "32"(年龄值跑到城市字段)
原因:某行<td>数量少于表头列数,zip()截断后错位。例如表头 3 列,但某行只有 2 个<td>,zip(['姓名','年龄','城市'], ['王五','25'])生成{"姓名":"王五","年龄":"25"},第三列丢失,后续行全部左移。
解决:强制补齐缺失单元格:
# 替换原代码中 row_data 构建逻辑: row_data = [td.text_content().strip() for td in tds] # 改为: row_data = [td.text_content().strip() for td in tds] + [""] * (len(headers) - len(tds))3.3 现象:JSON 里出现"姓名": "张三\n\n\t",前后有大量空白和换行
原因:text_content()包含所有子节点文本,而<th>内常有<br>、<span>或缩进空格。
解决:用正则清洗,而非简单.strip():
import re def clean_text(text: str) -> str: return re.sub(r'\s+', ' ', text).strip() # \s+ 匹配所有空白符(空格、\n、\t、\r) # 在构建 headers 和 row_data 时统一调用: headers = [clean_text(h.text_content()) for h in ths] row_data = [clean_text(td.text_content()) for td in tds]3.4 现象:<table>里嵌套了另一个<table>,子表内容混入主表 JSON
原因:xpath('.//tr')是深度优先遍历,会抓取所有后代<tr>,包括子表格的行。
解决:限定只取直接子<tr>(或<tbody>下的<tr>):
# 错误:table.xpath('.//tr') # 所有后代 # 正确:table.xpath('./tr | ./tbody/tr | ./thead/tr') # 仅一级子元素4. 处理复杂表格的进阶技巧:跨行合并、多 tbody、动态列宽
4.1 跨行合并单元格(rowspan)的列对齐算法:用坐标偏移量代替硬编码
当<th rowspan="2">课程</th>出现在第一行,它实际占据第 0 列的第 0 行和第 1 行。后续行的<td>必须跳过该列。标准做法是维护一个col_offset数组,记录每列当前是否被上一行的rowspan占据:
def parse_table_with_rowspan(table_elem): headers = [] rows = [] col_offset = [] # 每列是否被上一行 rowspan 占据(True=已占,跳过) # 第一步:扫描所有表头行,构建 headers 并初始化 col_offset header_rows = [] for tr in table_elem.xpath('./tr | ./thead/tr'): ths = tr.xpath('./th') if ths: header_rows.append(ths) # 合并多级表头(简化版:只处理两级) if len(header_rows) == 2: # 第二级表头(如 ["平时","期末","学分"])覆盖第一级(如 ["成绩","成绩","学分"]) headers = [clean_text(th.text_content()) for th in header_rows[1]] else: headers = [clean_text(th.text_content()) for th in header_rows[0]] col_offset = [False] * len(headers) # 第二步:逐行解析数据 for tr in table_elem.xpath('./tr | ./tbody/tr'): tds = tr.xpath('./td') if not tds: # 跳过表头行 continue row_data = [] td_idx = 0 for col_idx in range(len(headers)): if col_offset[col_idx]: # 该列被上一行 rowspan 占据 row_data.append("") # 占位空字符串 continue if td_idx < len(tds): td = tds[td_idx] cell_text = clean_text(td.text_content()) row_data.append(cell_text) # 更新 offset:若当前 td 有 rowspan > 1,则标记后续行该列被占 rowspan = int(td.get('rowspan', '1')) if rowspan > 1: # 标记后续 (rowspan-1) 行的该列 pass # 实际项目中需维护跨行状态机,此处简化 td_idx += 1 else: row_data.append("") if row_data: rows.append(dict(zip(headers, row_data))) return rows血泪经验:
rowspan处理必须结合colspan。一个<td colspan="2" rowspan="2">同时影响列和行偏移,完整实现需用二维布尔矩阵标记占用状态。生产环境建议用pandas.read_html()作为兜底——它内部已实现该算法。
4.2 多<tbody>场景:如何区分“数据分组”和“分页分割”
某些报表用多个<tbody>区分学期、年级或状态(如<tbody class="active">,<tbody class="archived">)。若统一解析,会导致 JSON 混淆。解决方案是按 class 或># 按 class 分组解析 tbody_groups = {} for tbody in table_elem.xpath('./tbody'): group_name = tbody.get('class', 'default') tbody_groups.setdefault(group_name, []).extend(tbody.xpath('./tr')) # 对每个组单独生成 JSON 数组 result = {} for group_name, trs in tbody_groups.items(): result[group_name] = parse_trs_as_json(trs, headers)
4.3 动态列宽适配:当<col width="200">影响视觉但不影响语义时
<col>标签定义列宽,但 JSON 不需要存储宽度。不过,有些系统用<col>的class标记列类型(如<col class="numeric">),这时可提取为元数据:
col_meta = {} for idx, col in enumerate(table_elem.xpath('./col')): col_class = col.get('class', '') if col_class: col_meta[f"col_{idx}"] = {"class": col_class} # 最终 JSON 可附加 metadata 字段 output = { "data": rows, "metadata": { "headers": headers, "column_types": col_meta } }5. 验证转换正确性的三把尺子:从人工抽检到自动化断言
5.1 人工验证:用 diff 工具比对原始 HTML 表格与 JSON 渲染结果
最朴素但最有效的方法:把生成的 JSON 用json2html库转回 HTML 表格,和原始 HTML 用diff对比:
# 1. 生成 JSON python html2json.py input.html > output.json # 2. JSON 转回 HTML(需安装 json2html:pip install json2html) python -c " import json, sys from json2html import * data = json.load(sys.stdin) print(json2html.convert(json=data)) " < output.json > rendered.html # 3. 提取表格主体进行 diff(忽略 class、style 等无关属性) diff <(grep -A 100 '<table' input.html | grep -E '(<tr|<td|<th)') \ <(grep -A 100 '<table' rendered.html | grep -E '(<tr|<td|<th)')若 diff 输出为空,说明结构一致;若有差异,重点检查<th>文本是否被截断、空单元格是否补全、跨行是否对齐。
5.2 单元测试断言:用 pytest 写可复用的校验逻辑
为防重构引入回归 bug,给核心函数加测试:
import pytest def test_html_table_to_json(): html = """ <table> <tr><th>产品</th><th>销量</th></tr> <tr><td>手机</td><td>1200</td></tr> <tr><td>电脑</td><td>800</td></tr> </table> """ result = html_table_to_json(html) assert len(result) == 2 assert result[0]["产品"] == "手机" assert result[0]["销量"] == "1200" assert result[1]["产品"] == "电脑" assert result[1]["销量"] == "800" def test_empty_header_fallback(): html = "<table><tr><th></th><th>名称</th></tr><tr><td>A</td><td>B</td></tr></table>" result = html_table_to_json(html) assert result[0]["col_0"] == "A" # 空表头 fallback 为 col_0 assert result[0]["名称"] == "B"5.3 生产环境监控:在日志里埋点统计“列对齐失败率”
上线后最怕静默错误。在解析循环中加入计数器:
def html_table_to_json_with_stats(html_content: str) -> dict: tree = etree.HTML(html_content) table = tree.xpath('//table')[0] headers = extract_headers(table) # 自定义函数 rows = [] misalign_count = 0 for tr in table.xpath('./tr | ./tbody/tr'): tds = tr.xpath('./td') if len(tds) != len(headers): misalign_count += 1 # 记录哪一行错位,方便告警 logger.warning(f"Row misalignment at {etree.tostring(tr, encoding='unicode')[:100]}") continue rows.append(dict(zip(headers, [clean_text(td.text_content()) for td in tds]))) return { "data": rows, "stats": { "total_rows": len(rows), "misaligned_rows": misalign_count, "misalign_rate": misalign_count / (len(rows) + misalign_count) if (len(rows) + misalign_count) else 0 } }我的习惯:只要
misalign_rate > 0.5%,就触发企业微信告警,并自动保存原始 HTML 到 S3 归档桶。三年来靠这个机制捕获了 7 次上游系统 HTML 模板变更——比任何文档更新都及时。希望帮到你。
本文还有配套的精品资源,点击获取