正在浏览 2.0.3 · Viewing 2.0.3|2.0.4 最新2.0.32.0.22.0.1
Skip to content

HTML 解析(轻量) ​

com.alianga.jkit.html 提供一个零依赖的轻量 HTML 解析器与 CSS 选择器引擎,用于替代此前移除的 Jsoup。它面向网页内容抽取场景:解析脏 HTML → CSS 选择器定位 → 抽取文本/属性。

模块以 Jsoup 1.18.1 为对照做了差分验证,在 119 组「HTML + 选择器」用例上 112 组结果完全一致,剩余差异集中在 Jsoup 专有扩展与 HTML5 收养算法(见 第 7 节)。

1. 快速开始 ​

java
Document doc = Html.parse("<html><head><title>标题</title></head>"
        + "<body><div id=\"main\" class=\"box\"><p>hello</p></div></body></html>");

doc.title();                       // "标题"
doc.select("div#main p").text();   // "hello"
doc.select(".box").attr("id");     // "main"

Html.parse 返回 Document,它是解析树的根,本身也是 Element,因此 select / text 等 API 与元素一致。即使输入是片段,也会补出 html / head / body 骨架:

java
Html.parse("<p>hi</p>").outerHtml();
// <html><head></head><body><p>hi</p></body></html>

2. DOM 模型 ​

类型说明
Node节点基类,提供 parent() / nodeText() / outerHtml()
Element元素:标签名、属性、子节点,外加选择、抽取 API
TextNode标签之间的文本节点,序列化时做实体转义
DataNodescript / style 的原始内容节点,序列化时不转义
Comment<!-- ... --> 注释节点
Document文档根(标签名 #document),额外提供 title() / head() / body()
ElementsList<Element> 的便捷子集(text() / attr() / html() / eachText() / first() / select())

3. 元素 API ​

java
Element div = doc.selectFirst("div");

div.tagName();                  // "div"
div.attr("id");                 // "main",不存在返回空串(不返回 null)
div.hasAttr("id");              // true
div.attributes();               // 属性映射的拷贝
div.id();                       // "main"
div.className();                // "box"
div.classNames();               // ["box"]
div.hasClass("box");            // true
div.text();                     // 归一化后的纯文本(script/style 内容不计入)
div.ownText();                  // 仅直接文本子节点,不含后代元素
div.nodeText();                 // 未归一化的原始文本(含 script/style 内容)
div.innerHtml();                // 子节点序列化
div.outerHtml();                // 含自身标签的序列化
div.children();                 // 直接子元素(仅 Element)
div.child(0);                   // 第 1 个子元素
div.childNodeSize();            // 直接子节点数(含文本与注释)
div.parentElement();            // 父元素,文档根返回 null
div.nextElementSibling();       // 下一个兄弟元素
div.previousElementSibling();   // 上一个兄弟元素
div.elementSiblingIndex();      // 同级下标,从 0 开始
div.val();                      // textarea 取文本,其余取 value 属性
div.select(css);                // 在子树中查询
div.selectFirst(css);           // 首个匹配,无则 null

约定:属性名、标签名按 HTML 语义大小写不敏感(解析时统一小写,匹配时忽略大小写);id / class / 属性值则区分大小写。

text() 的文本归一化与 Jsoup 一致:块级元素之间补一个空格、连续空白折叠为单个空格、去掉首尾空白(含 &nbsp;);pre / textarea 保留原始空白。

4. CSS 选择器 ​

支持的选择器子集:

类别示例
标签 / 通配div、*、div.foo
id / class#main、.box、.a.b(同时含两类)
属性[href]、[href=x]、[href!=x]、[href^=https]、[href$=pdf]、[href*=img]、[title~=a]、[lang|=en]
后代 / 子代div p、ul > li、a b > c
相邻兄弟 / 通用兄弟h1 + p、h1 ~ p
分组p.a, p.b(逗号取并集)
结构性伪类:first-child、:last-child、:only-child、:root、:empty、:not(...)、:nth-child(an+b)、:nth-last-child(an+b)
同类型伪类:first-of-type、:last-of-type、:only-of-type、:nth-of-type(an+b)、:nth-last-of-type(an+b)
文本伪类:contains(text)、:containsOwn(text)、:matches(regex)、:matchesOwn(regex)

an+b 支持 odd / even / 3 / 2n+1 / n+1 / -n+3 等写法,伪类名大小写不敏感。

java
doc.select("div p");                 // 所有后代 p
doc.select("body > div");            // 直接子元素
doc.select("h1 + p");                // h1 紧邻的下一个 p
doc.select("a[href^=https]");        // 属性前缀
doc.select("li:first-child");        // 首个 li
doc.select("li:not(.done)");         // 排除含 done 的 li
doc.select("div :not(a, p)");        // :not 支持选择器列表
doc.select("div b:not(a b)");        // :not 支持含组合器的复杂选择器
doc.select("li:nth-child(2n+1)");    // 奇数项
doc.select("p:contains(详情)");       // 文本包含
doc.selectFirst("title");            // 首个匹配,无则 null

Elements 上的 select(css) 会在集合每个元素的子树中查询并合并去重(嵌套子树命中同一元素只保留一次);Elements.text() 以空格拼接各元素文本。

5. 实体转义 ​

解析阶段会对文本与属性值做实体解码;反之提供转义能力。

java
Html.escape("a<b>&");        // "a&lt;b&gt;&amp;"
Html.unescape("a&amp;b");    // "a&b"
Html.unescape("&#65;");      // "A"
Html.unescape("&#x41;");     // "A"
Html.unescape("&copy;");     // "©"
Html.unescape("&mdash;");    // "—"
Html.unescape("&#128512;");  // "😀"(高于 BMP 的码点生成代理对)

命名实体内置约 200 个高频实体(&copy; &reg; &trade; &mdash; &hellip; &ldquo; 等),数字实体支持十进制 &#65; 与十六进制 &#x41;,无长度限制。未收录的命名实体原样保留——如需全量 HTML5 实体表,请改用 &#nnn; 写法或自行预解码。

6. 容错处理 ​

解析器按 HTML5 的常见规则做容错:

  • 隐式骨架:任何输入都会补出 html / head / body。head 专用标签(title / meta / link / base / style)与 body 之前的 script 归入 head,其余进入 body;<html lang="en"> 标签上的属性会保留;
  • 省略结束标签自动闭合:<p>一<p>二 得到两个并列 p;<ul><li>a<li>b</ul> 得到两个并列 li;同样覆盖 dt/dd、td/th/tr、option、rt/rp、thead/tbody/tfoot。块级标签还会关闭被行内元素埋住的 p(<p>a<b>c<div>d 中 b 与 p 一起闭合);
  • 表格隐式 tbody:<table><tr><td>x</td></tr></table> 自动补 <tbody>,与浏览器一致;
  • 空元素(br / img / input / meta 等)不进栈,序列化时不输出结束标签;
  • 自闭合 <tag/> 与形如 <div/ 的残缺标签都能安全终止,不会进入死循环;
  • <script> / <style> 内容按原始文本处理,不再解析其中的标签;<textarea> 同样按原文处理但会做实体解码;
  • <!--> 与 <!---> 按 HTML5 视为完整的空注释(abrupt closing),不会把后续内容吞进注释;未闭合的注释按规范延伸到文档末尾。

7. 与 Jsoup 的差异 ​

以下差异是刻意保留的取舍,迁移时按清单核对即可:

差异说明
:has()不支持,会抛 SelectorException
:eq() / :lt() / :gt() / :first / :lastJsoup 位置型扩展,不支持
:header不支持(可用 h1, h2, h3, h4, h5, h6 替代)
活动格式化元素重排不实现 HTML5 收养算法。<div><p>a<b>c<div>d</div></div> 中 Jsoup 会把 <b> 重建到后续块内,本模块只做闭合;文本抽取结果一致,结构查询会不同
文档根标签名本模块为 #document,Jsoup 为 #root,仅影响 * 这类全量查询的展示
doctype解析时跳过,不保留在 DOM 中
输出格式outerHtml() 为紧凑输出,不做缩进美化
命名空间与 XML 模式不支持,只按 HTML 解析

8. 异常 ​

异常场景
SelectorException选择器中出现不支持的伪类、组合符缺少操作数,或选择器串本身无法解析

非法选择器一定抛异常,绝不静默降级或死循环——畸形输入在解析期就被拒绝:空选择器 / 空白选择器、分组缺操作数(,p / p,,p)、组合符缺右操作数(p >)、属性选择器未闭合或缺 =(img[src、[src^png])、伪类括号未闭合(p:nth-child(2)、不支持的伪类(table:hover,即使文档里没有 table 也报错)等都会立即得到明确的错误信息。

解析本身不抛异常——所有不规范输入都会被尽量适配为 DOM,不会因 HTML 脏数据中断流程。

9. 性能 ​

同机与 Jsoup 1.18.1 实测对比(预热 3 轮、计时 7 轮取中位数,maxHeap 2 GB,JDK 17;Jsoup 仅用于基准测试,未进入项目依赖)。测试页面为拼接的文章列表页:小页面 2 条约 1.6 KB、中页面 60 条约 30 KB、大页面 600 条约 300 KB。倍数为 Jsoup 耗时 / jkit 耗时,大于 1 表示 jkit 更快。

计时用成对交替:每个场景按 A→B→B→A 各测两轮、各取较小值。原因是固定「先 jkit 后 jsoup」会让先跑的一方吃亏——JIT 编译、分支预测、缓存预热都压在前几轮,实测大页面 .post 在固定顺序下只有 0.60x~0.90x,交替后稳定在 1.3x。不做这个处理,报告里就会出现并不存在的「jkit 更慢」。

场景jkitJsoup 1.18.1jkit 快
小页面解析0.010 ms0.023 ms2.27x
中页面解析0.142 ms0.197 ms1.39x
大页面解析1.294 ms2.275 ms1.76x
大页面解析 + text()1.654 ms2.022 ms1.22x
中页面 #main<0.001 ms0.013 ms68x
中页面 .post0.001 ms0.019 ms14.8x
中页面 article.post h20.002 ms0.024 ms9.8x
中页面 a[href^=/p/]0.002 ms0.018 ms8.8x
中页面 li:first-child0.004 ms0.020 ms4.9x
大页面 #main<0.001 ms0.164 ms1344x
大页面 .post0.015 ms0.185 ms12.7x
大页面 article.post h20.024 ms0.212 ms8.7x
大页面 a[href^=/p/]0.016 ms0.160 ms10.2x
150 份大页面 DOM 常驻堆(只解析)153.3 MB180.2 MB1.18x
150 份大页面 DOM 常驻堆(解析 + 一次查询)159.1 MB180.9 MB1.14x
端到端(解析 + 3 次查询 + 取文本)2.847 ms3.405 ms1.20x

真实站点验证 ​

合成页面容易失真,所以另取一个真实站点(Halo 1.4.5 博客首页,70 KB,含 24 个 script、30 个 img、10 篇 article)做端到端对照——抽最近 10 篇文章的链接、标题、懒加载图 data-src、占位图 src、发布日期,以及文章详情页的 pre code 代码块。

场景jkitJsoup 1.18.1jkit 快
首页解析(70 KB)0.306 ms0.456 ms1.49x
端到端:解析 + 抽 10 篇全部字段0.360 ms0.465 ms1.29x
article.post-list-thumb<0.001 ms0.013 ms26.6x
a.post-title h30.001 ms0.019 ms31.4x
.post-thumb img0.005 ms0.022 ms4.9x
.post-date span.i18n0.008 ms0.028 ms3.5x

正确性上 10 篇文章 5 个字段逐条与 Jsoup 完全一致;3 篇详情页的 pre code 命中数(11 / 13 / 8)、class、以及 text() 与原文 nodeText() 也都与 Jsoup 一致。

结论:

  • 解析快 1.4x–2.3x,端到端快 1.2x–1.3x,选择器在 id / class / 标签索引加持下快 3.5x–1344x;
  • 常驻内存低 15%(153.3 MB vs 180.2 MB),建索引后低 12%(159.1 MB vs 180.9 MB)。索引本身约占一份 DOM 的 4%,换来的是上面那个数量级差距;只解析不查询时完全不建。此前内存反而高 13%,差距全部来自字符串——真实页面里 class 值、模板化文本高度重复,Jsoup 1.18 内置了字符串缓存而本模块此前逐节点各建一份;补上解析期字符串驻留缓存后反超;
  • 索引能这么省心,前提是 DOM 解析完成后不可变——没有 setter、没有增删子节点的入口,索引建一次就再也不会失效。这是本模块与 Jsoup 的一个设计差异(Jsoup 的 DOM 可改),别把它当成限制,它是简化的来源。

索引的使用范围 ​

索引只对查询根是 Document 时生效,即 doc.select(...) / doc.selectFirst(...)。查询根是某个子元素时(如 article.selectFirst("a.post-title"))仍走深度优先遍历——候选集是以整份文档为范围建的,若根是子树,还得逐个判断候选是否落在子树内,子树小的时候反而更慢。所以抽取类代码里,能提到文档级做的查询尽量提到文档级。

键的取法是「能缩小候选范围就用,缩不小就退化」:优先 id,其次 class,最后标签名;div.foo 取 .foo、a[href^=/p/] 取 a(标签名也算索引键,所以带属性的查询一样受益),只有纯属性/伪类查询(如 [data-x]、:empty)才退回全树遍历。候选仍然是超集,最终条件照样逐个校验,结果与不建索引时逐一相同——indexAndTraversalReturnTheSameElements 用例就是把同一批查询分别在文档级(走索引)与元素级(走遍历)跑一遍,断言元素序列逐个 assertSame。

复现方式 ​

压测代码不在 jkit 仓库内——Jsoup 不能进 jkit 依赖,所以放在独立项目 tools-test:

  • com.alianga.test.html.HtmlParseBenchTest:合成页面吞吐、与 Jsoup 的差分比对(119 组用例)、script 密集页退化回归、常驻内存;
  • com.alianga.test.html.HtmlRealSiteBenchTest:真实站点 alianga.com 的抽取正确性与端到端耗时。
bash
mvn test -Dtest='HtmlParseBenchTest,HtmlRealSiteBenchTest'

报告写到 target/html-parse-bench.md 与 target/html-real-site-bench.md,定稿副本归档在 reports/。断言只卡数量级、不卡具体数字,避免换台机器就误报;真实站点用例需要网络,取不到就跳过,不会让构建变红。

优化手段集中在六处:属性表由 LinkedHashMap 改为并行 String[] 并懒创建、标签名与属性名走 Names 驻留表复用;解析期用下标缓存替代 O(栈深) 的栈内查找、属性写入复用缓冲区;script/style/textarea 的结束标签查找去掉整串 toLowerCase(),改为 regionMatches 忽略大小写逐字符扫描;选择器遍历去掉中间列表与迭代器、属性匹配合并为一次扫描、解析结果按访问序缓存(上限 256 条);Document 上的惰性 id / class / 标签索引,把「每次查询全树遍历」变成「取候选 + 校验」;以及解析期字符串驻留缓存,把重复的文本与属性值合并为同一实例(前五项让内存与 Jsoup 持平后仍略高,最后一项才完成反超)。

前五项加起来,选择器在合成页面上只是 1.3x–2.2x、内存仍比 Jsoup 高 13%;加上索引这一步,中页面才到 4.6x–80x、大页面 8.6x–749x;最后补上字符串驻留缓存,常驻内存反超 15%。索引之所以敢这么用,是因为本模块的 DOM 没有 setter、没有 appendChild,解析完就定型了,索引不需要任何失效逻辑。

第三项最关键:此前每个 script 结束标签查找都会复制并扫描一遍全文,复杂度是 O(标签数 × 文档长度)。合成页面只有 1 个 script 影响不明显,真实页面有 24 个 script 时,解析耗时从 0.184 ms 涨到 3.929 ms(比 Jsoup 慢 14 倍)——这个缺陷只有拿真实页面测才暴露得出来。

基于 Apache License 2.0 发布