在做网页采集、内容分析或者数据清洗的时候,重复数据几乎是绕不开的问题。同一个页面可能因为URL参数不同被抓取多次,同一段正文可能被几十个站点转载,如果不去重,后续的存储、索引和分析都会被大量冗余数据拖慢。HTML数据的去重比普通文本去重更麻烦,因为两个内容完全相同的页面,其HTML源码可能因为标签顺序、空白字符、属性写法的差异而完全不同。这篇文章就来系统地聊聊HTML去重的几种思路,从简单的精确匹配讲到复杂的近似去重。

一、去重前先做好数据规范化
很多人拿到HTML就直接算哈希,结果内容一样的页面判成不重复,问题就出在没有做规范化预处理。HTML的冗余来源非常多:换行符、缩进、无意义的空白,标签属性顺序不同,单引号双引号混用,还有脚本、样式、注释这些和正文无关的内容。
规范化的第一步是提取正文。可以用正则或者lxml、BeautifulSoup这类解析库,把<script>、<style>、<nav>、<footer>等与正文无关的部分剥掉,只保留真正的内容区域。第二步是统一格式,包括去除连续空白、统一小写化标签和属性名、去掉属性的引号差异。做完这些处理后,两个转载页面的HTML才会变得高度一致。
import re
def normalize_html(html):
# 去掉脚本和样式
html = re.sub(r'<(script|style)[^>]*>.*?</\1>', '', html, flags=re.S|re.I)
# 去掉HTML注释
html = re.sub(r'<!--.*?-->', '', html, flags=re.S)
# 去掉所有标签,只保留文本
text = re.sub(r'<[^>]+>', '', html)
# 压缩空白字符
text = re.sub(r'\s+', ' ', text).strip()
return text值得注意的一点是,规范化的粒度决定了去重的效果。如果你只关心正文是否重复,那就提取纯文本再比较;如果你关心的是整个页面结构是否一致,就要保留标签结构做规范化。粒度选错了,要么误杀,要么漏判。
二、精确去重:哈希指纹与集合判重
规范化之后,最直接的做法就是计算整条数据的哈希值,用哈希作为唯一标识来判重。MD5、SHA1这类加密哈希函数输出稳定,任何一位字符的变化都会导致结果完全不同,非常适合精确去重的场景。
工程实现上通常用一个集合或者数据库唯一索引来存储已经出现过的指纹。以Python为例,判断一条新数据是否重复只需要一次哈希计算加一次集合查询,时间复杂度接近O(1)。对于百万级别的数据,内存占用也在可接受范围内。
import hashlib
seen = set()
def is_duplicate(text):
fp = hashlib.md5(text.encode('utf-8')).hexdigest()
if fp in seen:
return True
seen.add(fp)
return False这种方法的局限也很明显:只要页面改了一个标点、加了一个广告位,指纹就变了,系统会把它当成新数据。也就是说,精确去重只能处理完全一致的重复,对近似重复无能为力。此外,当数据量上到亿级,集合本身的内存开销也会成为瓶颈,这时候就需要后面要讲的布隆过滤器来节省空间。
三、近似去重:SimHash算法的实现与优化
网页去重真正的主流方案是局部敏感哈希(LSH),其中SimHash是Google论文里提出并验证过的经典算法。它的核心思想是:把一篇文档映射成一个64位的指纹,内容越相似的两个文档,其指纹对应的二进制位差异就越少。
SimHash的计算过程分几步:先对文本分词并计算每个词的权重(一般用词频),然后每个词算一个普通哈希并把哈希值的每一位按权重参与累加,某位为1就加权重,为0就减权重,最后把累加结果的每一位转成0或1,得到整篇文档的指纹。两个指纹的海明距离(不同位的个数)小于3,一般就认为文档重复。
def simhash(tokens):
v = [0] * 64
for t in tokens:
h = hash(t) & 0xFFFFFFFFFFFFFFFF
w = 1 # 简化处理,实际应使用词频或TF-IDF权重
for i in range(64):
bit = (h >> i) & 1
v[i] += w if bit else -w
fp = 0
for i in range(64):
if v[i] > 0:
fp |= (1 << i)
return fp
def hamming(a, b):
return bin(a ^ b).count('1')直接对两两指纹比较海明距离在数据量大时是O(n²)的复杂度,显然不可行。常见的优化是把64位指纹切成四段各16位,建立四个分块索引。查询时取新指纹的四段分别去对应索引里找候选,只要有一段完全相同就成为候选集,再对候选做精确的海明距离校验。根据鸽巢原理,海明距离不超过3的两个指纹必然至少有一段完全相同,所以这种做法不会漏判,查询效率却能提升几个数量级。
另一个优化点是分词和权重的选择。对HTML做SimHash之前,建议先抽正文再分词,避免导航栏、版权声明这些模板内容稀释了正文的特征。权重用TF-IDF代替简单词频,可以让指纹更能体现文档的核心内容,减少误判。
四、海量数据场景:布隆过滤器的引入
当数据规模达到数亿甚至更多,即使是64位指纹的存储也会带来不小的内存压力,这时布隆过滤器就派上用场了。它用一个位数组加多个哈希函数来表示一个集合,判定存在时可能误判,判定不存在时绝对准确。这个特性刚好和去重场景契合:宁可漏掉极少数重复,也不能误杀正常数据时,只要调整误判率参数即可。
import mmh3
from bitarray import bitarray
class BloomFilter:
def __init__(self, size=1 << 30, hash_num=7):
self.size = size
self.hash_num = hash_num
self.bits = bitarray(size)
self.bits.setall(0)
def add(self, item):
for i in range(self.hash_num):
idx = mmh3.hash(item, i) % self.size
self.bits[idx] = 1
def might_contain(self, item):
return all(
self.bits[mmh3.hash(item, i) % self.size]
for i in range(self.hash_num)
)参数选择上,位数组大小和哈希函数个数共同决定误判率。经验公式是,在预期元素数量n和目标误判率p下,位数组大小约为n乘以1.44除以p的对数。以一亿条数据、百分之一误判率计算,内存占用大约一百多MB,比直接存指纹集合节省了一个数量级以上。
实际工程中更常见的架构是分层去重:第一层用URL指纹做布隆过滤器挡掉完全重复的抓取,第二层用SimHash处理正文近似重复,第三层对可疑的候选再做多特征比对。层与层之间各司其职,整体既保证了准确率,又保证了吞吐量。
五、方案选型与总结
选哪种方案,取决于数据规模和业务对精度的要求。数据量在百万以内、只需要处理完全重复,直接用哈希加集合就够;需要识别转载、抄袭这类近似重复,SimHash是性价比最高的选择;数据量过亿或者对内存敏感,就把布隆过滤器架在前面做初筛。另外MinHash也是近似去重的常用方案,它基于Jaccard相似度估算,在集合相似性场景下表现比SimHash更好,两者可以按数据形态灵活选用。
最后强调一点,去重效果的八成功夫在数据预处理上。规范化做得粗糙,再好的算法也救不回来;正文抽取干净、特征权重合理,哪怕用最简单的算法也能拿到不错的结果。建议在搭建去重系统时先建立评估集,人工标注一批重复对和非重复对,持续度量准确率和召回率,再逐步迭代优化,这比盲目标新算法靠谱得多。