从零构建现代搜索引擎(05):正文提取、URL 规范化与去重
上一篇实现的爬虫能够抓回 HTML 页面,但抓回来的原始 HTML 里充满了导航栏、侧边栏、页脚、广告和脚本标签。把这些噪声和正文一起送入索引,搜索"Java 内存模型"可能命中一堆导航菜单里的"Java"字样。
本篇解决三个问题:从 HTML 中提取干净的正文,将 URL 统一为规范形式以避免同一页面被当作不同文档,以及识别内容相同或高度相似的页面以避免重复索引。
从 HTML 到正文
噪声标签移除
HTML 页面中不是所有标签都承载正文内容。<nav>、<header>、<footer>、<aside>、<script>、<style> 以及带有常见 CSS 类名(.sidebar、.menu、.ad)的元素通常是噪声。jsoup 的选择器可以一次性移除:
1 | |
优先取语义化容器
现代网页通常用 <article>、<main> 或 [role=main] 标记主要内容区域。如果这些元素存在,直接从中提取文本,比全局移除噪声后取 <body> 更精确:
1 | |
两层策略叠加:先尝试语义化容器,找不到时回退到噪声移除后的全文。
标题提取
文档标题的来源优先级:<h1> > <title> > URL 路径末段。多个 <h1> 时取第一个。
1 | |
许多站点的 <title> 包含站点名后缀(如"Java 内存模型 - 技术博客"),可以用 - 或 | 分割后取第一段。但这属于启发式规则,对不同站点可能需要调整,本篇不做通用处理。
URL 规范化
同一个页面可以通过多种 URL 形式访问:
1 | |
这六个 URL 指向同一个页面。如果不做规范化,每个变体在 frontier 的 seen 集合中都是独立条目,同一页面会被重复抓取和索引。
规范化步骤
依据 RFC 3986,规范化的标准步骤:
- scheme 和 host 转小写:
HTTPS→https,Example.COM→example.com - 移除默认端口:
:80(http)和:443(https) - 路径归一化:解析
.和..,空路径补/ - 查询参数排序:
b=2&a=1→a=1&b=2 - 移除 fragment:
#section不发送到服务器,不影响内容 - 移除跟踪参数:
utm_source、utm_medium、utm_campaign、utm_term、utm_content、fbclid、gclid - 百分号编码归一化:未保留字符的编码还原(
%41→A),保留字符保持编码
Java 实现
1 | |
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 | |
相同哈希的文档不重复导入索引。这能处理完全相同的内容,比如镜像站点或 HTTP/HTTPS 双版本。
近似去重:SimHash 实验
精确去重处理不了这种情况:两个页面的正文 95% 相同,只有页脚的版权年份或侧边栏的"最新文章"列表不同。如果不做近似去重,搜索结果会被这些几乎相同的页面霸占。
SimHash(Charikar, 2002)将文档映射为 64-bit 指纹,两个指纹的汉明距离越小,文档越相似。
SimHash 计算过程
- 将正文分词,得到一组特征词
- 每个特征词用哈希函数映射为 64-bit 值
- 维护一个 64 维的累加向量 V,初始全零
- 对每个特征词的哈希值:第 i 位是 1 则 V[i] 加权重,是 0 则 V[i] 减权重
- 将 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 | |
一条机器指令(POPCNT)完成。
近似去重的阈值选择
阈值太小(≤ 1)会漏掉许多近似重复;太大(≥ 6)会把不同主题的文档误判为重复。经验值是 3,但需要在自己的数据集上验证。
本篇将 SimHash 作为实验引入:计算全部文档的 SimHash,输出相似度矩阵,人工检查哪些配对确实是近似重复、哪些是误判。不在生产路径上自动合并文档——自动合并需要更多验证,第 28 篇讨论质量控制时再考虑。
不误合并的底线
两个版本的 API 文档(v1 和 v2)内容结构相同但细节不同,SimHash 距离可能很近。策略:
- URL 中包含版本标识(
/v1/、/v2/)的页面,即使 SimHash 距离小也不合并 - 文档的
source_version字段不同时不合并 - 近似去重只用于标记,不自动删除
链接提取与相对路径处理
爬虫从页面中提取链接,送入 frontier。jsoup 的 abs:href 属性自动处理相对路径:
1 | |
需要注意:Jsoup.parse(html, baseUri) 的第二个参数必须传入页面的实际 URL,否则相对路径无法正确解析。
过滤规则:
- 只保留
http和https协议(排除mailto:、javascript:、ftp:等) - 排除明显的非内容链接(
#、空字符串) - 排除常见的资源文件后缀(
.css、.js、.png、.jpg、.pdf)
整合到抓取流程
第 04 篇的爬虫抓回原始 HTML,本篇的组件负责后处理:
1 | |
每个步骤都可以独立测试。正文提取的输入是 HTML 字符串和 base URI,输出是纯文本和标题。URL 规范化的输入是原始 URL,输出是规范形式。去重的输入是正文,输出是是否重复的判定。
当前局限
- 正文提取依赖 HTML 语义化标签,对老旧页面(table 布局、无语义标签)效果差
- URL 规范化的跟踪参数列表需要维护,新的跟踪参数(如各平台的分享链接参数)可能遗漏
- SimHash 只做了实验性引入,没有自动去重——需要人工验证阈值
- 没有处理 JavaScript 渲染的内容——纯 HTML 解析拿不到 SPA 的动态内容
练习
- 准备两个 HTML 文件:一个有
<article>标签,一个没有。对比两种提取策略的输出差异 - 构造 5 个指向同一页面的不同 URL 变体,验证规范化后得到相同结果
- 复制一篇文档,修改最后一段,计算两篇文档的 SimHash 和汉明距离。再修改 50% 的内容,观察距离变化
- 在 fixture 中准备一个页面包含 10 个链接(5 个同域、3 个外域、2 个非 HTTP 协议),验证链接提取和过滤的正确性
延伸阅读
- RFC 3986 URI Generic Syntax: https://www.rfc-editor.org/rfc/rfc3986
- Charikar, M. (2002). “Similarity Estimation Techniques from Rounding Algorithms” — SimHash 原始论文
- Introduction to Information Retrieval, Chapter 19: Web search basics — URL 规范化与去重
- jsoup Cookbook: https://jsoup.org/cookbook/






