news 2026/10/6 3:39:57

html-to-json实战:HTML表格高效转JSON的结构化指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
html-to-json实战:HTML表格高效转JSON的结构化指南

简介:这是一款将HTML文档转换为JSON结构的Python开源工具,重点支持智能识别HTML表格并将表头自动映射为JSON键名,适合网页数据抓取、前端开发与自动化测试场景中需要结构化提取页面内容的开发者使用。压缩包共32个文件,包含7个Python源码文件、6个HTML测试页面、4个YAML配置、2个Markdown文档及Dockerfile、Shell脚本等辅助资源,整体仅512KB,代码量精简但功能完整,已有715人学习下载。资源提供了完整的安装说明、API调用示例与测试用例,读者可快速掌握convert(以及convert_tables)等核心接口的用法,并了解如何通过capture_element_values、capture_element_attributes等参数控制文本值与属性的捕获策略。同时,随包附带测试套件与多平台配置文件,能够覆盖常见表格变体,便于二次开发与集成测试。目录结构清晰,含独立测试数据与CI配置,适合作为Python解析类项目实践参考。

1. 文本到结构化:html-to-json 帮你跳过“手撕 HTML”的坑

先说个反直觉的结论:把 HTML 转成 JSON,从来不是JSON.stringify($('body').html())那种“把带标签的字符串原样搬走”。真实场景往往是这样的——你对接了一个老系统,页面上全是表格,领导让你把这张表导进数据仓库,下游同事等着跑 json 查询函数;而你手里只有一个从<!doctype html>开头的静态页面。如果手动写正则去抠每个<td>,今天能跑,明天页面加一列就全线崩。html-to-json 这类工具的意义,就是把“HTML → JSON”变成声明式:你告诉它取哪些节点,它返回干净的 JSON 结构。它最讨喜的功能是智能处理 HTML 表:识别表头,把表头文本直接当作结果里的键。适合谁?写爬虫脚本的数据分析师、给老系统做数据对接的后端、以及一切想把网页表格变成结构化数据但又不想长期维护正则的人。

2. 先跑通最小案例:html-to-json 的安装、解析参数与输出结构

2.1 用 npm 装最小依赖,看 parse 返回了什么

html-to-json 是 npm 生态里解决“HTML 转 JSON”最常见的小库之一,核心逻辑是先解析 DOM,再按你给的映射配置取值。安装就一条命令:

npm install html-to-json --save

安装完成后,最常见的最小调用是下面这种写法:

const htmlToJson = require('html-to-json'); const html = ` <!doctype html> <html lang="zh-cn"> <head> <meta charset="utf-8"> <title>商品列表</title> </head> <body> <h1>笔记本</h1> <ul> <li class="price">7999</li> <li class="stock">有货</li> </ul> </body> </html> `; htmlToJson.parse(html, { 'title': { selector: 'h1' }, 'price': { selector: '.price' }, 'stock': { selector: '.stock' } }).then(result => { console.log(result); // 期望输出: { title: '笔记本', price: '7999', stock: '有货' } });

这段代码的逻辑分三步:库先把 html 字符串解析成可查询的 DOM 结构,再按照配置对象里的 selector 去匹配节点,最后把命中的文本填到对应 key 下面。这里要特别说明,parse 返回的是 Promise,所以要么接 then,要么放进 async 函数里 await;拿同步值操作会直接拿到 undefined,这是新手最容易栽的第一个跟头。

配置对象本身就是输出 JSON 的“图”:外层 key 是希望 JSON 里出现的字段名,value 里的 selector 负责定位。很多团队还会加 extractor,用来决定取的是文本、href 属性还是整段 innerHTML。常见的做法是把一个页面的配置抽成独立 js 对象,页面改版只改 selector,采集函数一行不动。这种模式对长期维护特别友好,比散落一地的正则可解释性强得多。

2.2 配置里决定输出结构的 5 个关键参数

不同的 html-to-json 封装在细节上略有差异,但下面几个参数几乎决定了转换结果的质量。我一般会在项目里固定一张参数说明表,方便新同事接手:

参数作用常用值
selectorCSS 选择器,定位要提取的节点h1、.price、table tr td
extractor从命中节点里取什么内容text / attr('href') / html
ignore跳过某些节点的过滤条件空值节点、隐藏节点
useHeader表格解析时是否把表头当键true / false
columnIndexKey无表头时列索引的命名前缀column_0、column_1

selector 的写法直接决定结果粒度。table tr td会把表格里所有单元格摊成一个扁平的数组,这在调试时会很困惑:你根本分不清哪个值对应哪一列。更好的做法是配合table thead th和table tbody td分开取,然后按列下标手动配对。这也是 html-to-json 这类库的共同思路:解析本身是黑匣子,但配置可以写得很明白。

参数里最容易忽略的是 extractor。默认取 text 的结果会把“¥7,999”这种带格式的文本原样放进 JSON,后续还得二次清洗。如果目标链接是href,默认行为只拿文本就会得到空值。我通常建议把取值规则显式声明出来,哪怕多写几行,也别让库去猜。毕竟解析结果是要给下游 SQL 和报表用的,字段里混进带换行的文本,join 的时候哭都来不及。

3. 重点能力:把 HTML 表转成 JSON,表头才是那个“键”

3.1 表头当键、无表头用下标:两种输出结构怎么选

html-to-json 强调的“智能地”三个字,主要落在表格解析上。网页表格分两种:有<th>表头行的规范表格,和只有一行<td>的无头表格。规范表格的输出是[{ '商品名': '笔记本', '价格': '7999' }],键来自表头文本;无头表格没有语义,只能退化成[{ 'column_0': '笔记本', 'column_1': '7999' }]。这就是参数里 useHeader 和 columnIndexKey 的用武之地。

很多从 Excel 转向网页数据的朋友会问“表头区域的数值取叫什么”,其实就是这里说的键名来源。你打开 WPS 表格时能看到第一行是字段名,html-to-json 做的事就是把<thead>里的文本抽出来当字段名,再把<tbody>每行变成一条 JSON 对象。它的价值在于:如果没有表头,也能退而求其次用column_0这种占位键,保证输出是合法的 json 数组,而不是一团乱麻。

选择哪种输出,取决于下游怎么消费。如果是给 BI 报表用,务必选 useHeader: true,因为 json 查询函数要按字段名过滤;如果只是临时存档,无表头的 column_0 也够用。我自己的习惯是:不管有没有表头,都先跑一遍看输出键是不是稳定。因为键不稳定比值出错更可怕,值错还能靠校验兜住,键一错下游整张表就废了。

3.2 用 cheerio 自写表解析器:colspan、rowspan 与双行表头的对齐逻辑

现成库能覆盖八成简单表格,但遇到双行表头、合并单元格这类“复杂表头导入”场景时,硬调库参数效率很低。html-to-json 的表格处理也未必能把每个colspan都对得严丝合缝。我一般会在工程里留一个用 cheerio 写的自研表格解析函数,专治各种不服——这才是标题里“智能”两个字真正要落到的地方:

const cheerio = require('cheerio'); function tableToJson(html) { const $ = cheerio.load(html); const result = []; const $table = $('table').first(); const headerCells = []; const colCount = 0; // 先取表头行, 兼容逐行 <th>, 也兼容 <thead><tr><td> $table.find('tr').each((ri, tr) => { const row = []; let skip = 0; $(tr).find('th, td').each((ci, cell) => { if (skip > 0) { skip--; return; } const $cell = $(cell); const text = $cell.text().trim(); const colspan = parseInt($cell.attr('colspan') || '1', 10); const rowspan = parseInt($cell.attr('rowspan') || '1', 10); row.push(text); for (let i = 1; i < colspan; i++) { row.push(text); // colspan 占位, 保持对齐 } if (rowspan > 1) { skip = colspan - 1; // 简单场景先跳过下一行同列 } }); headerCells.push(row); }); // 取第一行做键, 其余行做值 const keys = headerCells[0].map((k, i) => k || `column_${i}`); for (let i = 1; i < headerCells.length; i++) { const obj = {}; headerCells[i].forEach((val, idx) => { obj[keys[idx] || `column_${idx}`] = val; }); result.push(obj); } return result; } const html = ` <table> <tr><th>商品</th><th>价格</th></tr> <tr><td>笔记本</td><td>7999</td></tr> <tr><td>鼠标</td><td>99</td></tr> </table> `; console.log(tableToJson(html)); // [{ 商品: '笔记本', 价格: '7999' }, { 商品: '鼠标', 价格: '99' }]

这段代码看起来简单,但隐藏了两个关键点。第一,colspan的处理:发现一个单元格横跨两列时,用同一个文本占两个位置,这样后续行不会被带偏。第二,键的兜底:如果某个表头是空的,自动用column_序号代替,否则 JSON 里会出现undefined键,序列化时直接被丢掉。代码里只处理了 rowspan 的最简场景,真遇到复杂合并单元格,建议把纵向合并单独做成一个状态数组,逐行推进,而不是像我这里用 skip 一刀切。

写这个函数不是为了替代 html-to-json,而是给读者一个“实在不行还能自己来”的落地方案。通用库能覆盖的简单表格场景,直接用现成功能;一旦页面出现双表头、三表头合并这种“easyexcel 看了都皱眉”的结构,拿我这个骨架改一改,比满网找现成解析器都快。实践中我见过太多团队卡在复杂表头上,花一天调库不如花一小时把对齐逻辑写明白。

4. 接进真实采集任务的几个落地姿势与性能边界

4.1 批量抓取列表页时让结果保持稳定

真实项目里很少只转一个页面,常见的是连续抓几十页列表。此时最忌讳的写法是 for 循环里同步转一遍再存文件。我一般用 p-limit 做并发控制,同时保证每个页面的输出都走同一个校验函数:

const pLimit = require('p-limit'); const limit = pLimit(5); // 最多同时解析 5 个页面 const urls = ['https://example.com/list?page=1', /* ... */]; const tasks = urls.map(url => limit(() => fetchPage(url))); const results = await Promise.all(tasks);

关键不在并发数,而在于每个结果都必须是数组。单页解析出来可能是对象,也可能是数组,混在一起会让下游 json 查询函数直接类型报错。我习惯在解析后统一Array.isArray检查,不是数组就包一层。这个习惯省了我很多次跟数据组扯皮的工夫。

4.2 动态渲染页面要先“等 JS 跑完”再喂给解析器

html-to-json 只能处理已经存在于 HTML 里的节点。现在很多表格是前端异步加载的,静态请求返回的 HTML 里 tbody 是空的,解析结果自然就是空数组。这时候正确的姿势是用无头浏览器先渲染,再取最终 HTML:

const puppeteer = require('puppeteer'); const htmlToJson = require('html-to-json'); const browser = await puppeteer.launch(); const page = await browser.newPage(); await page.goto(url, { waitUntil: 'networkidle2' }); const renderedHtml = await page.content(); // 此时的 HTML 已经包含 JS 生成的行 const json = await htmlToJson.parse(renderedHtml, config); await browser.close();

这里的waitUntil: 'networkidle2'不是玄学,它表示等网络请求基本安静下来再取内容。如果页面里有轮询接口,这个等待会一直不满足,建议再配一个超时,或者观察某个具体表格行 selector 出现再继续。动态渲染会显著增加耗时,所以只有确认页面确实异步加载时才上 puppeteer,否则直接用 fetch 拿静态 HTML 就好。

4.3 编码、压缩与超大文档:几个容易翻车的小参数

中文网页最常见的坑是编码。请求头里写charset=utf-8,响应的 HTML 里却是 GBK 编码的中文,解析结果就会变乱码。这不是 html-to-json 的锅,而是你喂给它的字符串在请求层已经被错误解码。解决方法是请求时显式声明编码,Node 里用iconv-lite对 Buffer 做转换后再传给解析器。

大文档是另一个容易翻车的地方。一个几百 KB 的 HTML 解析成 DOM 再构建 JSON,内存开销不小。如果表格里还嵌着表格,嵌套层级一深,解析耗时会指数级上涨。我的经验是:先用正则或 cheerio 粗筛出目标<table>片段,再交给 html-to-json 细解析,能省一半内存。还有一点,HTML 里常常有<!-- 注释 -->包着的老数据,解析前先剥掉注释,否则注释里的伪表格会被当真实数据处理。

5. 从现象到解法:html-to-json 最容易翻车的 5 个坑

5.1 一个 colspan 就能让整排键错位

现象:表格本身很简单,但表头里有个合并单元格,解析结果里所有数据列的键全部往前错一位,价格跑到商品名下面。

原因:解析器按 DOM 顺序逐个读<th>,没有把colspan代表的列宽算进去。表头实际占 4 列,代码却只读了 3 个键。

解决:自定义解析逻辑里读取colspan属性,按列宽推进索引,缺失的键用column_序号补齐。这也是我在 3.2 里坚持用 cheerio 自写解析器的原因——通用配置很难表达“这个表头横跨两列”的语义。

5.2 无表头表格键名重复,下游 join 直接崩溃

现象:一张没有表头的表格转出来是三行对象,每行都长一样,全是column_0、column_1,还得靠行号区分。

原因:columnIndexKey 只生成了列索引,没有把行号带进键名。

解决:在输出对象里追加一行手动编号字段_row,或者干脆用数组套数组的结构,把“无表头”的语义明确下来。我一般会加一列row_index,这样就算键名重复,下游至少还能按行号回溯原始页面。

5.3 中文乱码不是解析器的锅,是入口编码没修

现象:结果 JSON 里中文全部变成�或者一堆问号,英文和数字正常。

原因:请求环节拿到的 Buffer 被按 UTF-8 解码了,但源页面实际是 GBK。解析器拿到的是已经被错误解码的字符串,再怎么转都救不回来。

解决:在请求层判断响应头的 charset,必要时用iconv-lite重新解码 Buffer,再把干净字符串交给 html-to-json。这个坑的隐蔽之处在于,浏览器打开页面是正常的,只有脚本里乱码,容易让人误以为是解析库的问题。

5.4 表格嵌套表格,内层数据被“吞”了

现象:页面是“订单总表里嵌着商品明细子表”,解析结果里只看到外层订单,内层明细全丢了,或者被错误拼到外层字段后。

原因:很多解析逻辑用table tr td这种宽泛选择器,把内外两层表格的行混在一起数,内层行被当成外层表的异常行跳过。

解决:先用$('table').first()或:scope限定根表格,子表单独解析。常见做法是先找到外层表格的边界,再用 3.2 的解析函数分别跑外层和内层,最后在 JSON 里用嵌套对象组织起来。

5.5 动态表格解析为空数组:先检查渲染时机

现象:接口请求成功,转换代码也没报错,但结果是一个空数组,页面在浏览器里明明有数据。

原因:表格行是前端 JavaScript 异步渲染的,静态 HTML 里只有外壳,没有<tr>。

解决:先用curl或 fetch 看原始 HTML 里有没有tbody数据行。没有的话,就需要按 4.2 的方式走 puppeteer 渲染。不要一开始就怀疑库有问题,这是我在动态页面项目里最常说的一句话。

6. 进阶:把表格键变成固定 Schema,让输出直接能入库

6.1 统一键名:清洗、类型转换和一个 50 行的校验脚本

表格转 JSON 只是第一步,真正决定项目成败的是输出能否直接被下游使用。我手里常年留一个清洗函数,专门处理从表格里提取出来的“脏值”:

function cleanCell(key, value) { if (value === undefined || value === null) return ''; let v = String(value).replace(/\s+/g, ' ').trim(); if (key === '价格' || key.toLowerCase().includes('price')) { v = v.replace(/[¥$,]/g, ''); // 去掉货币符号和千分位 } if (key === '库存' || key === '状态') { if (v.includes('有货')) return 'true'; if (v.includes('无货')) return 'false'; } return v; }

这个函数看起来不起眼,但它解决了一个实际问题:原始 JSON 里价格是¥7,999这种字符串,直接塞进数据库的数字字段会报错。清洗函数按 key 名识别列语义,把格式统一成下游要的样子。

写完之后,我会跑一个 50 行左右的校验脚本,检查每一行的键集合是否一致。键多了或少了都说明表格解析时某一行有合并单元格或隐藏列,需要回去修解析逻辑。做法是把第一行的Object.keys()存下来,跟后续每行对比,有差异就打出具体行号和缺失键。我一直的习惯是:表转 JSON 出来,先跑键校验,再谈数据入库;这一步能提前暴露 80% 的解析问题。希望这个“先验键、再验值”的顺序能帮你在自己的项目里少踩几个坑。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 3:39:37

自绘CListCtrl的常见误区:从Owner Draw到NM_CUSTOMDRAW的正确切换

做MFC控件美化的时候&#xff0c;最容易被网上老代码带偏的坑&#xff0c;就是自绘CListCtrl时照搬CListBox那套Owner Draw流程。最近我就在CListCtrl派生类里写了ON_WM_MEASUREITEM_REFLECT&#xff0c;也重写了DrawItem(LPDRAWITEMSTRUCT lpMeasureItemStruct)&#xff0c;样…

作者头像 李华
网站建设 2026/10/6 3:39:36

JProfiler 8.0.2 Windows x64安装与Java性能分析入门实战

JProfiler_windows-x64_8_0_2 这个安装包&#xff0c;我在Windows机器上装过不下十次了&#xff0c;从个人开发机到团队的测试服务器&#xff0c;基本都是同一个套路&#xff1a;双击exe、配许可证、连上Java进程、开分析。它是我在Java性能分析这个方向上用得最多、也最愿意推…

作者头像 李华
网站建设 2026/10/6 3:39:26

Lasso超参数调整与模型选择:L1稀疏原理到sklearn实践

做机器学习的人大概都遇到过这种场景&#xff1a;手里一张宽表&#xff0c;几十个特征&#xff0c;业务方拍着胸脯说"每一个都有业务含义"&#xff0c;可真跑起线性回归来&#xff0c;要么系数奇奇怪怪&#xff0c;要么测试集一验证就崩。这种时候Lasso就是绕不开的选…

作者头像 李华
网站建设 2026/10/6 3:39:12

无感FOC核心算法:龙伯格观测器原理、离散化与参数整定全解析

1. 无感FOC里为什么绕不开状态观测器做无感FOC控制&#xff0c;核心问题就一个&#xff1a;转子位置和速度怎么拿。装编码器或霍尔&#xff0c;成本上去了&#xff0c;而且很多场景根本装不下。所以行业内主流方案是走无感路线——不装位置传感器&#xff0c;靠电机的电压电流反…

作者头像 李华
网站建设 2026/10/6 3:38:58

许三观卖血记:男人的爱不是低三下四,而是关键时刻挺身而出

读《许三观卖血记》是很多年前的事了&#xff0c;但书中那些密密麻麻的细节&#xff0c;像许三观弓着背坐在门槛上喝黄酒的样子、许玉兰站在街口数落他的声音&#xff0c;一直留在我脑子里。后来我把这本书重读了两遍&#xff0c;越读越觉得&#xff0c;余华写这个故事&#xf…

作者头像 李华