上一篇实现的爬虫能够抓回 HTML 页面,但抓回来的原始 HTML 里充满了导航栏、侧边栏、页脚、广告和脚本标签。把这些噪声和正文一起送入索引,搜索"Java 内存模型"可能命中一堆导航菜单里的"Java"字样。

本篇解决三个问题:从 HTML 中提取干净的正文,将 URL 统一为规范形式以避免同一页面被当作不同文档,以及识别内容相同或高度相似的页面以避免重复索引。

从 HTML 到正文

噪声标签移除

HTML 页面中不是所有标签都承载正文内容。<nav><header><footer><aside><script><style> 以及带有常见 CSS 类名(.sidebar.menu.ad)的元素通常是噪声。jsoup 的选择器可以一次性移除:

1
2
3
4
5
6
Document doc = Jsoup.parse(html);
doc.select(
"nav, header, footer, aside, script, style, "
+ ".sidebar, .menu, .ad, .advertisement, "
+ "[role=navigation], [role=banner]"
).remove();

优先取语义化容器

现代网页通常用 <article><main>[role=main] 标记主要内容区域。如果这些元素存在,直接从中提取文本,比全局移除噪声后取 <body> 更精确:

1
2
3
4
5
Element content = doc.selectFirst(
"main, article, [role=main]");
String text = (content != null)
? content.text()
: doc.body().text();

两层策略叠加:先尝试语义化容器,找不到时回退到噪声移除后的全文。

标题提取

文档标题的来源优先级:<h1> > <title> > URL 路径末段。多个 <h1> 时取第一个。

1
2
3
4
5
6
7
8
9
10
String title = null;
Element h1 = doc.selectFirst("h1");
if (h1 != null) title = h1.text();
if (title == null || title.isBlank()) {
title = doc.title();
}
if (title == null || title.isBlank()) {
title = uri.getPath().replaceAll(".*/", "")
.replace("-", " ");
}

许多站点的 <title> 包含站点名后缀(如"Java 内存模型 - 技术博客"),可以用 -| 分割后取第一段。但这属于启发式规则,对不同站点可能需要调整,本篇不做通用处理。

URL 规范化

同一个页面可以通过多种 URL 形式访问:

1
2
3
4
5
6
https://Example.COM/path/page.html
https://example.com:443/path/page.html
https://example.com/path/../path/page.html
https://example.com/path/page.html?b=2&a=1
https://example.com/path/page.html?a=1&b=2#section
https://example.com/path/page.html?a=1&b=2&utm_source=twitter

这六个 URL 指向同一个页面。如果不做规范化,每个变体在 frontier 的 seen 集合中都是独立条目,同一页面会被重复抓取和索引。

规范化步骤

依据 RFC 3986,规范化的标准步骤:

  1. scheme 和 host 转小写HTTPShttpsExample.COMexample.com
  2. 移除默认端口:80(http)和 :443(https)
  3. 路径归一化:解析 ...,空路径补 /
  4. 查询参数排序b=2&a=1a=1&b=2
  5. 移除 fragment#section 不发送到服务器,不影响内容
  6. 移除跟踪参数utm_sourceutm_mediumutm_campaignutm_termutm_contentfbclidgclid
  7. 百分号编码归一化:未保留字符的编码还原(%41A),保留字符保持编码

Java 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static String normalize(String rawUrl) {
URI uri = URI.create(rawUrl.strip());
String scheme = uri.getScheme().toLowerCase();
String host = uri.getHost().toLowerCase();

int port = uri.getPort();
if ((scheme.equals("http") && port == 80)
|| (scheme.equals("https") && port == 443)) {
port = -1;
}

String path = uri.getPath();
if (path == null || path.isEmpty()) path = "/";
path = URI.create(path).normalize().getPath();

String sortedQuery = sortAndFilter(uri.getRawQuery());

return new URI(scheme, null, host, port,
path, sortedQuery, null)
.toASCIIString();
}

sortAndFilter 方法将查询字符串按 key 排序,同时过滤跟踪参数。

fragment 策略

fragment(# 后的部分)在 HTTP 协议中不发送到服务器,两个仅 fragment 不同的 URL 获取的是相同内容。规范化时统一移除 fragment。

但有一类例外:单页应用(SPA)使用 #!#/path 做客户端路由,不同 fragment 对应不同内容。本篇的爬虫不处理 JavaScript 渲染的页面,因此不涉及这个问题。如果后续支持动态渲染,需要对 hash-based routing 单独处理。

参数顺序的实际意义

参数排序看起来只是整洁,实际上影响去重效果。许多内容管理系统在不同页面生成的链接中参数顺序不一致。如果不排序,?page=1&sort=date?sort=date&page=1 会被视为两个不同的 URL,导致重复抓取。

精确去重:SHA-256

第 02 篇已经为文档对象计算了 SHA-256 内容哈希。这里复用同一机制:对提取后的正文做空白归一化,再计算哈希。

1
2
3
4
String normalized = text.strip().replaceAll("\\s+", " ");
byte[] hash = MessageDigest.getInstance("SHA-256")
.digest(normalized.getBytes(UTF_8));
String hex = HexFormat.of().formatHex(hash);

相同哈希的文档不重复导入索引。这能处理完全相同的内容,比如镜像站点或 HTTP/HTTPS 双版本。

近似去重:SimHash 实验

精确去重处理不了这种情况:两个页面的正文 95% 相同,只有页脚的版权年份或侧边栏的"最新文章"列表不同。如果不做近似去重,搜索结果会被这些几乎相同的页面霸占。

SimHash(Charikar, 2002)将文档映射为 64-bit 指纹,两个指纹的汉明距离越小,文档越相似。

SimHash 计算过程

  1. 将正文分词,得到一组特征词
  2. 每个特征词用哈希函数映射为 64-bit 值
  3. 维护一个 64 维的累加向量 V,初始全零
  4. 对每个特征词的哈希值:第 i 位是 1 则 V[i] 加权重,是 0 则 V[i] 减权重
  5. 将 V 的每个分量转为 bit:正数为 1,零或负数为 0

手算示例

假设文档包含三个词,简化为 4-bit 哈希:

权重 哈希
搜索 3 1010
引擎 2 1100
架构 1 0110

累加向量:

词×权重 bit3 bit2 bit1 bit0
搜索×3 +3 -3 +3 -3
引擎×2 +2 +2 -2 -2
架构×1 -1 +1 +1 -1
合计 +4 0 +2 -6

SimHash = 1010(正数→1,零或负数→0)。

两篇文档的 SimHash 汉明距离 ≤ 3(在 64-bit 场景下),判定为近似重复。

汉明距离计算

1
2
3
static int hammingDistance(long a, long b) {
return Long.bitCount(a ^ b);
}

一条机器指令(POPCNT)完成。

近似去重的阈值选择

阈值太小(≤ 1)会漏掉许多近似重复;太大(≥ 6)会把不同主题的文档误判为重复。经验值是 3,但需要在自己的数据集上验证。

本篇将 SimHash 作为实验引入:计算全部文档的 SimHash,输出相似度矩阵,人工检查哪些配对确实是近似重复、哪些是误判。不在生产路径上自动合并文档——自动合并需要更多验证,第 28 篇讨论质量控制时再考虑。

不误合并的底线

两个版本的 API 文档(v1 和 v2)内容结构相同但细节不同,SimHash 距离可能很近。策略:

  1. URL 中包含版本标识(/v1//v2/)的页面,即使 SimHash 距离小也不合并
  2. 文档的 source_version 字段不同时不合并
  3. 近似去重只用于标记,不自动删除

链接提取与相对路径处理

爬虫从页面中提取链接,送入 frontier。jsoup 的 abs:href 属性自动处理相对路径:

1
2
3
4
5
6
7
8
Elements links = doc.select("a[href]");
for (Element link : links) {
String absUrl = link.absUrl("href");
if (!absUrl.isBlank()) {
String normalized = UrlNormalizer.normalize(absUrl);
frontier.offer(normalized, currentDepth);
}
}

需要注意:Jsoup.parse(html, baseUri) 的第二个参数必须传入页面的实际 URL,否则相对路径无法正确解析。

过滤规则:

  • 只保留 httphttps 协议(排除 mailto:javascript:ftp: 等)
  • 排除明显的非内容链接(#、空字符串)
  • 排除常见的资源文件后缀(.css.js.png.jpg.pdf

整合到抓取流程

第 04 篇的爬虫抓回原始 HTML,本篇的组件负责后处理:

1
2
3
4
5
6
7
8
原始 HTML
→ 噪声移除 + 正文提取(jsoup)
→ 标题提取
→ URL 规范化
SHA-256 精确去重
→ SimHash 计算(实验性)
→ 链接提取 → 回到 frontier
→ 构建 SearchDocument → 送入索引

每个步骤都可以独立测试。正文提取的输入是 HTML 字符串和 base URI,输出是纯文本和标题。URL 规范化的输入是原始 URL,输出是规范形式。去重的输入是正文,输出是是否重复的判定。

当前局限

  • 正文提取依赖 HTML 语义化标签,对老旧页面(table 布局、无语义标签)效果差
  • URL 规范化的跟踪参数列表需要维护,新的跟踪参数(如各平台的分享链接参数)可能遗漏
  • SimHash 只做了实验性引入,没有自动去重——需要人工验证阈值
  • 没有处理 JavaScript 渲染的内容——纯 HTML 解析拿不到 SPA 的动态内容

练习

  1. 准备两个 HTML 文件:一个有 <article> 标签,一个没有。对比两种提取策略的输出差异
  2. 构造 5 个指向同一页面的不同 URL 变体,验证规范化后得到相同结果
  3. 复制一篇文档,修改最后一段,计算两篇文档的 SimHash 和汉明距离。再修改 50% 的内容,观察距离变化
  4. 在 fixture 中准备一个页面包含 10 个链接(5 个同域、3 个外域、2 个非 HTTP 协议),验证链接提取和过滤的正确性

延伸阅读