简介:这是一份面向Python开发者与数据处理人员的HTML解析工具包,核心功能是将HTML文档及HTML表格智能转换为JSON结构,并在表格转换时自动提取表头作为结果JSON的键名,适合需要做网页数据抽取、表格结构化或爬虫后处理的场景。压缩包共32个文件、约512KB,以py源码为主体,辅以html测试样例、yml与toml等配置、md说明文档及Dockerfile、docker-compose等容器化文件,另含测试脚本与示例图片,结构完整便于直接运行与二次开发。资源提供convert接口,可通过capture_element_values、capture_element_attributes等参数灵活控制文本值与属性的捕获行为,兼顾默认易用与定制需求。目前已有715人学习下载,读者可借此快速掌握HTML转JSON的调用方式,理解表格键值映射逻辑,并参考其测试用例与工程配置搭建自己的解析流程。
1. 从一坨 HTML 到干净 JSON:为什么我宁愿自己写解析也不愿再手抠表格
上周帮一个做数据迁移的朋友处理一批历史报表,文件全是 HTML 格式,几百个页面里嵌着结构各异的表格。他一开始想用正则硬抠,结果遇到合并单元格、嵌套表头、空行占位就全线崩溃。我接手后换了个思路:先把 HTML 整体转成 JSON,再针对表格做结构化提取,用表头当键名,整个过程从「玄学调参」变成了可复现的流水线。这就是 html-to-json 这个方向要解决的问题——不是简单地把标签转成键值对,而是让表格数据能直接喂给下游的数据库或分析脚本。适合谁?做爬虫后处理、报表迁移、后台管理系统数据导入的工程师,以及任何需要把半结构化 HTML 变成程序可读 JSON 的从业者。下面我把这套方案的选型、实现和踩坑点拆开讲。
2. 先想清楚:HTML 转 JSON 到底转的是什么
2.1 两种转换目标的本质区别
很多人一听到「HTML 转 JSON」就以为是整个文档树序列化,其实在实际工程里至少分两种目标。第一种是结构保真型:把 DOM 的标签、属性、文本按层级映射成 JSON 对象,保留所有节点信息,适合做页面快照或差异对比。第二种是语义提取型:只关心特定元素(比如表格、列表、表单),把它们的业务数据抽出来,忽略无关的 div 和样式。标题里提到的「智能地将 HTML 表转换为 JSON」明显属于第二种,而且额外要求用表头作为键名——这意味着解析器必须能识别 thead、th 以及 rowspan/colspan 的语义。
我一般会先问自己:下游拿到这个 JSON 是要直接入库,还是要再做二次清洗?如果要入库,键名必须稳定且可预测,那就得走语义提取;如果只是存档备查,结构保真更省事。选错方向的话,后面会陷入「解析出来的 JSON 嵌套太深没法用」或者「丢了原始信息没法回溯」的两难。
2.2 为什么表头当键名是个「智能」需求
普通 HTML 表格转 JSON 最粗暴的做法是按行遍历,每行输出一个数组。但这样下游拿到的是[["姓名","年龄"],["张三","28"]],还得自己记住第一行是表头。而「用表头作为键」意味着输出直接是[{"姓名":"张三","年龄":"28"}],字段名一目了然。这个需求看似简单,实际要处理的情况不少:表头可能不在第一行(前面有标题行)、可能有多级表头(两行合并)、可能有空表头单元格、可能表头里还嵌了<a>或<span>。一个能落地的解析器必须把这些边界都覆盖到,否则换个页面就翻车。
2.3 选型:自己写解析器还是用现成库
常见做法是直接用现成的 HTML 解析库,比如 Python 生态里的 BeautifulSoup、lxml,JavaScript 生态里的 cheerio、jsdom。这些库负责把 HTML 字符串变成可遍历的 DOM 树,你只需要在树上写提取逻辑。我的建议是:不要从零写 HTML 词法分析器,那是另一个量级的工程。但也不要指望某个库直接提供「表格转 JSON 且表头当键」的一站式函数——这个逻辑通常得自己封装,因为业务对「智能」的定义千差万别。
我一般会选 BeautifulSoup + lxml 解析器作为底层,因为容错性好,遇到不闭合的标签也能修。然后在它上面写一个table_to_json函数,专门处理表格。下面给出可复现的实现。
2.4 最小可运行实现:把表格转成带表头键的 JSON
先安装依赖,然后跑一个完整示例。代码里我会用虚构的 HTML 片段,包含普通表头、合并表头、空单元格三种情况。
pip install beautifulsoup4 lxmlfrom bs4 import BeautifulSoup import json def table_to_json(html: str) -> list: """ 将 HTML 中的第一个表格转换为 JSON 列表。 表头(th 或第一行 td)作为键名,支持 colspan 简单展开。 """ soup = BeautifulSoup(html, "lxml") table = soup.find("table") if not table: return [] # 提取所有行 rows = table.find_all("tr") if not rows: return [] # 判断表头:优先找 thead 里的 th,否则用第一行的 th/td headers = [] thead = table.find("thead") if thead: header_cells = thead.find_all(["th", "td"]) else: header_cells = rows[0].find_all(["th", "td"]) rows = rows[1:] # 第一行已作为表头,从数据行里去掉 for cell in header_cells: text = cell.get_text(strip=True) colspan = int(cell.get("colspan", 1)) # 合并列时,后续列用同名键加序号,避免键冲突 if colspan > 1: for i in range(colspan): headers.append(f"{text}_{i+1}" if i > 0 else text) else: headers.append(text) result = [] for row in rows: cells = row.find_all(["td", "th"]) if not cells: continue record = {} col_idx = 0 for cell in cells: text = cell.get_text(strip=True) colspan = int(cell.get("colspan", 1)) for i in range(colspan): if col_idx < len(headers): key = headers[col_idx] # 空表头时用列索引兜底 if not key: key = f"col_{col_idx}" record[key] = text if i == 0 else "" col_idx += 1 # 补齐缺失的列 while col_idx < len(headers): key = headers[col_idx] or f"col_{col_idx}" record[key] = "" col_idx += 1 result.append(record) return result # 测试用例 html_sample = """ <table> <thead> <tr><th>姓名</th><th colspan="2">联系方式</th><th>备注</th></tr> <tr><th></th><th>电话</th><th>邮箱</th><th></th></tr> </thead> <tbody> <tr><td>张三</td><td>13800000000</td><td>zhang@example.com</td><td>VIP</td></tr> <tr><td>李四</td><td></td><td>li@example.com</td><td></td></tr> </tbody> </table> """ data = table_to_json(html_sample) print(json.dumps(data, ensure_ascii=False, indent=2))这段代码的逻辑说明:先定位 table,再判断表头来源。如果存在 thead,就从中提取所有 th/td 作为表头;否则把第一行当表头并从数据行里移除。处理 colspan 时,简单展开成多个键,避免后续数据列错位。数据行遍历时按列索引对齐表头,空单元格补空字符串。参数方面,strip=True去掉首尾空白,colspan默认 1,rowspan这里没处理——这是有意为之,因为 rowspan 的语义展开更复杂,后面避坑章节会讲。
2.5 多级表头的合并策略
上面的代码对两行表头其实是「拍平」处理的:第一行「联系方式」占两列,展开成「联系方式」和「联系方式_2」,第二行「电话」「邮箱」会覆盖到对应列。实际输出里,第二行表头会覆盖第一行的展开值,因为代码是先收集 thead 里所有单元格再按顺序分配。这导致「联系方式」这个父表头丢失了。更合理的做法是识别多级表头并拼接键名,比如「联系方式_电话」。但拼接规则因业务而异,有的要下划线,有的要短横线,有的只要子表头。我的经验是:如果表格有多级表头,先跟下游确认键名格式,再决定拍平还是拼接。不要自己拍脑袋定,否则返工成本很高。
3. 把解析器接进真实流水线:从单文件到批量处理
3.1 批量读取与编码处理
单文件测试通过后,下一步是批量处理。真实场景里 HTML 文件可能来自不同系统,编码五花八门。我一般用pathlib遍历目录,用chardet探测编码,再用 BeautifulSoup 解析。下面是一个批量脚本的骨架。
from pathlib import Path import chardet from bs4 import BeautifulSoup import json def detect_encoding(file_path: Path) -> str: raw = file_path.read_bytes() result = chardet.detect(raw) return result.get("encoding") or "utf-8" def batch_convert(input_dir: str, output_dir: str): in_path = Path(input_dir) out_path = Path(output_dir) out_path.mkdir(parents=True, exist_ok=True) for html_file in in_path.glob("*.html"): encoding = detect_encoding(html_file) text = html_file.read_text(encoding=encoding, errors="replace") data = table_to_json(text) # 复用上一节的函数 out_file = out_path / (html_file.stem + ".json") out_file.write_text( json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" ) print(f"转换完成: {html_file.name} -> {out_file.name}, 记录数: {len(data)}") batch_convert("./html_pages", "./json_output")逻辑说明:detect_encoding用 chardet 探测字节流编码,避免用错编码导致乱码。errors="replace"保证遇到无法解码的字节时用替换符占位,不让整个流程中断。输出统一用 UTF-8,方便下游读取。参数上,glob("*.html")只匹配 html 后缀,如果文件是.htm或.xhtml,需要改成glob("*.*")再过滤。indent=2是为了人工检查方便,如果追求体积可以去掉。
3.2 处理嵌套表格与无关表格
一个页面里可能有多个表格,有的用来布局,有的才是数据表。我的做法是给table_to_json加一个可选参数table_index,默认 0 表示第一个表格,也可以传 -1 表示最后一个,或者传 CSS 选择器来定位。更稳妥的方式是先根据表格的 class 或 id 过滤,只处理目标表格。如果表格嵌套(表格里还有表格),BeautifulSoup 的find_all("tr")会把内层表格的行也抓出来,导致数据错乱。解决办法是只取直接子级行:table.find_all("tr", recursive=False)配合 tbody 处理。但 recursive=False 会漏掉 tbody 里的行,所以更常见的做法是先找 tbody,再在 tbody 里找 tr。
def extract_table_rows(table): """只提取当前表格的直接行,避免嵌套表格干扰""" tbody = table.find("tbody") if tbody: return tbody.find_all("tr", recursive=False) return table.find_all("tr", recursive=False)这个函数替换掉之前直接table.find_all("tr")的写法,能过滤掉内层表格的行。参数说明:recursive=False只找直接子节点,tbody 存在时在其内部找,否则在 table 下找。注意有些页面不写 tbody,浏览器会自动补,但 BeautifulSoup 用 lxml 解析时也会补,所以通常能拿到。
3.3 输出 JSON 的键名清洗与冲突处理
表头文本里可能包含空格、换行、特殊符号,直接当键名会让下游查询很痛苦。我一般会做一轮清洗:去掉首尾空白、把连续空白替换成下划线、去掉括号和斜杠。如果清洗后出现重复键名,加数字后缀区分。下面是一个清洗函数。
import re def clean_key(raw: str, existing: set) -> str: """清洗表头文本作为键名,处理重复""" key = raw.strip() key = re.sub(r"\s+", "_", key) key = re.sub(r"[^\w\u4e00-\u9fff]", "", key) # 保留字母数字下划线和中文 if not key: key = "unnamed" original = key counter = 1 while key in existing: key = f"{original}_{counter}" counter += 1 existing.add(key) return key逻辑说明:先用正则把空白替换成下划线,再删掉非单词字符(保留中文)。如果清洗后为空,用 unnamed 兜底。重复时加_1、_2后缀。参数上,\w包含字母数字下划线,\u4e00-\u9fff覆盖常用中文。这个函数在构建表头列表时调用,传入一个 set 记录已用键名。
3.4 性能考量:大文件与流式处理
如果单个 HTML 文件几十兆,BeautifulSoup 全量加载会吃内存。这时候可以考虑用lxml.etree.iterparse做流式解析,只保留表格相关节点。但大多数业务场景下,HTML 报表不会那么大,BeautifulSoup 足够。我的经验是:超过 10MB 的 HTML 再考虑流式,否则优化收益不明显,反而增加代码复杂度。如果确实要流式,可以用selectolax或lxml的 iterparse,但表格转 JSON 的逻辑需要重写,因为流式解析没有完整的 DOM 树。
4. 避坑与排查:那些让我加班到凌晨的表格
4.1 现象:合并单元格导致列错位,数据串行
原因:rowspan 没有展开,后续行的列索引整体前移。比如第一行第一列 rowspan=2,第二行就少一个 td,按顺序对齐表头时会把第二列的数据填到第一列。
解决:遇到 rowspan 时,维护一个「待填充」队列。遍历行时,先检查上一行是否有 rowspan 遗留的单元格,有就先占位。实现上可以用一个字典记录每列被占用的行数,每行开始时先填充这些占位。代码略复杂,但核心是「按列索引而不是按单元格顺序」来分配数据。
4.2 现象:表头里有隐藏元素,get_text 拿到空字符串
原因:表头单元格里可能包含<span style="display:none">或注释节点,get_text(strip=True)会忽略隐藏元素的文本,但有时隐藏元素里才是真正的字段名(比如响应式表格用 CSS 控制显示)。
解决:不要只依赖get_text,可以检查cell.get("data-title")或aria-label属性,这些常被用来存字段名。如果都没有,再回退到文本提取。我一般会写一个extract_cell_text函数,按优先级取属性值、文本、空字符串。
4.3 现象:编码探测错误,中文变成乱码
原因:chardet 对短文本或混合编码的探测准确率不高,可能把 GBK 识别成 ISO-8859-1。
解决:优先看 HTML 里的<meta charset>声明,如果有就用它;没有再用 chardet。另外,如果文件来自已知系统,直接硬编码编码更可靠。我一般会加一个--encoding命令行参数,允许手动覆盖。
4.4 现象:表格里嵌套列表或段落,get_text 把内容挤成一行
原因:get_text(strip=True)会把所有子节点的文本拼接,丢失分隔符。比如<td><p>第一行</p><p>第二行</p></td>会变成「第一行第二行」。
解决:根据业务决定分隔符。如果单元格内是多行文本,可以用get_text(separator="\n")保留换行。但要注意,这样可能引入多余空行,需要再清洗。我的习惯是:先按\n提取,再用正则把连续空行合并。
4.5 现象:页面有多个表格,只转了一个或转错了
原因:soup.find("table")只返回第一个,如果第一个是布局表格,数据就丢了。
解决:用find_all("table")遍历,根据 class、id 或行列数过滤。可以加一个启发式规则:行数大于 2 且列数大于 1 的表格才视为数据表。或者让调用方传入 CSS 选择器,精确指定。
5. 进阶:让表格转 JSON 更「智能」的两个技巧
5.1 用表头语义推断数据类型
基础版输出全是字符串,下游还得自己转数字和日期。进阶做法是在提取时根据表头关键词推断类型:包含「金额」「价格」「数量」的列尝试转 float,包含「日期」「时间」的尝试转 datetime。这样输出的 JSON 直接可用。实现上可以维护一个关键词到类型的映射表,在record[key] = text之前做转换。注意要加 try-except,转换失败就保留原字符串,不要抛异常中断。
import datetime TYPE_HINTS = { "金额": float, "价格": float, "数量": int, "年龄": int, "日期": lambda x: datetime.datetime.strptime(x, "%Y-%m-%d").isoformat(), } def infer_and_convert(key: str, value: str): for hint, converter in TYPE_HINTS.items(): if hint in key: try: return converter(value) except (ValueError, TypeError): return value return value这个函数在构建 record 时调用,参数是键名和原始文本。注意日期格式可能多样,实际项目里建议用dateutil解析而不是硬编码格式。
5.2 保留原始 HTML 片段作为溯源字段
有时候下游发现数据有问题,想回溯原始单元格。可以在 JSON 里加一个_raw字段,存该行的原始 HTML 或单元格的 outerHTML。这样排查时不用翻原文件。代价是 JSON 体积变大,所以建议只在调试模式开启,生产环境关掉。我一般用环境变量控制,比如DEBUG_RAW=1时才加。
5.3 验证输出:用 JSON Schema 做回归测试
批量转换最怕改了解析逻辑后,某些页面的输出悄悄变了。我的习惯是给几个典型页面写 JSON Schema,每次修改后用jsonschema库校验输出是否符合预期。Schema 里定义必填字段、类型、是否允许额外字段。这样能在 CI 里跑,避免人工检查遗漏。下面是一个简单示例。
from jsonschema import validate schema = { "type": "array", "items": { "type": "object", "properties": { "姓名": {"type": "string"}, "年龄": {"type": ["integer", "string"]}, }, "required": ["姓名"], } } validate(instance=data, schema=schema)参数说明:required列出必须存在的键,type允许联合类型以兼容转换失败的情况。这个校验跑在批量脚本的最后,任何页面不符合就报错并记录文件名。
这套方案我从单文件调试到批量跑通大概花了一个下午,后面处理几千个页面时又陆续补了 rowspan 和编码的坑。最大的教训是:不要等到解析出错才去处理边界,一开始就把表头来源、合并单元格、编码探测这三件事想清楚,后面能省掉大量返工。希望帮到你。
本文还有配套的精品资源,点击获取