🔥本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!!

全文目录:
-
- [🌟 开篇语](#🌟 开篇语)
- 上期回顾
- 为什么简单的URL去重不够用?
- 内容指纹去重的核心思路
- 技术选型:为什么选SimHash?
- 代码实战:完整内容指纹去重系统
- 实战案例演示
- 进阶技巧
- 常见问题与解决方案
- 性能优化建议
- 总结与最佳实践
- 技术深入:SimHash原理详解
- 实战优化:真实案例分享
- [下期预告 🔮](#下期预告 🔮)
- [练习作业 ✍️](#练习作业 ✍️)
- [🌟 文末](#🌟 文末)
-
- [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
- [✅ 互动征集](#✅ 互动征集)
🌟 开篇语
哈喽,各位小伙伴们你们好呀~我是【喵手】。
运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO
欢迎大家常来逛逛,一起学习,一起进步~🌟
我长期专注 Python 爬虫工程化实战 ,主理专栏 👉 《Python爬虫实战》:从采集策略 到反爬对抗 ,从数据清洗 到分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上。
📌 专栏食用指南(建议收藏)
- ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
- ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
- ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
- ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用
📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏《Python爬虫实战》
订阅后更新会优先推送,按目录学习更高效~
上期回顾
上一讲《多源聚合:3 个站点统一字段与口径(同字段不同写法)!》我们搞定了多源聚合,学会了把来自掘金、CSDN、博客园三个不同站点的数据统一成标准格式。字段对齐了,格式规范了,看起来一切都挺完美。
但现实又给你上了一课:你会发现聚合后的数据里,同一篇文章出现了好几次!
比如这些情况你一定遇到过:
python
# 同一篇文章的不同URL
"https://example.com/article/123"
"https://example.com/article/123?from=timeline"
"https://example.com/article/123?utm_source=weibo"
"https://m.example.com/article/123" # 移动版
# 转载情况
原文: "https://juejin.cn/post/7123456"
转载1: "https://csdn.net/article/details/98765"
转载2: "https://cnblogs.com/user/p/54321.html"
# 标题和正文完全一样,但URL不同
简单的URL去重根本搞不定这些场景。今天我要教你一个更智能的去重方案:内容指纹识别。
说白了就是:不管URL怎么变,只要内容一样,我就能认出你是同一篇文章💪
这一讲会比较硬核,涉及URL规范化、文本相似度算法(SimHash)、阈值调优等知识点。但别担心,我会用大量代码示例和详细解析带你吃透每个环节。
准备好了吗?咱们开始!
为什么简单的URL去重不够用?
先看几个真实场景,你就明白为什么必须做内容指纹去重。
场景1:URL参数变体
同一篇文章,因为来源不同,URL带了不同的追踪参数:
python
# 这些其实是同一篇文章
urls = [
"https://blog.example.com/article/123",
"https://blog.example.com/article/123?from=weibo",
"https://blog.example.com/article/123?utm_source=twitter&utm_medium=social",
"https://blog.example.com/article/123?spm=a2c6h.12873639.0.0.6d4e4d8aXyZ9Kq"
]
# 如果用URL直接做去重键,会被当成4篇不同文章
dedup_keys = [hashlib.md5(url.encode()).hexdigest() for url in urls]
# 结果:4个不同的hash值
场景2:移动版与PC版
python
# PC版
"https://www.zhihu.com/question/12345/answer/67890"
# 移动版
"https://m.zhihu.com/question/12345/answer/67890"
# 虽然域名不同,但内容完全一样
场景3:转载文章
python
# 原文
{
"url": "https://juejin.cn/post/7123456",
"title": "深入理解Python装饰器",
"content": "装饰器是Python中一个强大的特性..."
}
# 转载(URL不同,但标题和正文完全一样)
{
"url": "https://blog.csdn.net/someone/article/details/98765",
"title": "深入理解Python装饰器",
"content": "装饰器是Python中一个强大的特性..."
}
如果只用URL去重,这两条会被当成不同文章。但实际上,它们是同一内容的重复。
场景4:标题微调但内容一致
python
# 原文
"标题": "2024年最值得学习的10个Python库"
"正文": "本文介绍10个Python库..."
# 转载时改了标题
"标题": "2024最值得学习的Python库TOP10" # 标题略有差异
"正文": "本文介绍10个Python库..." # 正文完全一样
内容指纹去重的核心思路
要解决上面这些问题,我们需要一套两层去重策略:
第一层:URL规范化去重
对URL做"标准化"处理,去除无关参数:
python
# 原始URL
"https://example.com/article/123?from=weibo&ref=home"
# 规范化后
"https://example.com/article/123"
# 生成去重键
dedup_key_1 = md5("https://example.com/article/123")
能解决的问题:URL参数变体、协议差异(http vs https)
解决不了的问题:转载文章(URL完全不同)
第二层:内容指纹去重
提取正文内容,计算"指纹"(fingerprint):
python
# 提取正文
content = "装饰器是Python中一个强大的特性..."
# 计算SimHash指纹(64位整数)
fingerprint = simhash(content) # 比如:18364758544493064720
# 比较两篇文章的指纹
distance = hamming_distance(fingerprint_1, fingerprint_2)
if distance < threshold: # 比如阈值设为3
print("内容高度相似,判定为重复")
能解决的问题:转载文章、标题微调、内容轻微修改
原理:相似的文本会产生相近的指纹值(汉明距离小)
技术选型:为什么选SimHash?
做文本相似度检测,常见算法有好几种:
方案1:逐字符对比(不可行)
python
def is_duplicate(text1, text2):
return text1 == text2
问题:
- 多一个空格都会判定为不同
- 标点符号差异会导致误判
- 转载时可能会加"来源:XXX"这样的前缀
方案2:余弦相似度(性能差)
python
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
def cosine_sim(text1, text2):
vectorizer = TfidfVectorizer()
vectors = vectorizer.fit_transform([text1, text2])
return cosine_similarity(vectors[0], vectors[1])[0][0]
问题:
- 需要存储所有向量,内存占用大
- 比较时需要两两计算,O(n²)复杂度
- 不适合大规模数据(百万级文章)
方案3:MinHash(也可行)
python
from datasketch import MinHash
def minhash_sim(text1, text2):
m1, m2 = MinHash(), MinHash()
for word in text1.split():
m1.update(word.encode('utf8'))
for word in text2.split():
m2.update(word.encode('utf8'))
return m1.jaccard(m2)
优点 :适合集合相似度(Jaccard)
缺点:对文本顺序不敏感
方案4:SimHash(推荐)✅
python
from simhash import Simhash
def simhash_fingerprint(text):
return Simhash(text).value # 返回一个64位整数
为什么选SimHash?
- 速度快:指纹计算是O(n),比较只需一次异或运算
- 内存小:每篇文章只存一个64位整数(8字节)
- 局部敏感:相似文本的指纹汉明距离小(LSH特性)
- 工业验证:Google用它做网页去重
核心原理:
- 把文本分词,每个词计算hash值
- 根据hash值的每一位累加权重
- 最终得到一个64位二进制指纹
- 相似文本的指纹"汉明距离"很小
代码实战:完整内容指纹去重系统
模块1:URL规范化工具
这个模块负责把各种"变体URL"规范成标准形式。
python
# url_normalizer.py
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
import re
class URLNormalizer:
"""URL规范化工具"""
# 需要移除的常见追踪参数
TRACKING_PARAMS = {
'utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term',
'from', 'ref', 'referer', 'source', 'spm', 'share', 'sharesource',
'share_medium', 'share_source', 'share_tag', 'wx_header',
'clicktime', 'enterid', 'scene', 'subscene', 'sessionid', 'ascene',
'devicetype', 'version', 'pass_ticket', 'winzoom'
}
# 移动版和PC版域名映射
MOBILE_TO_PC = {
'm.zhihu.com': 'www.zhihu.com',
'm.weibo.cn': 'weibo.com',
'm.jianshu.com': 'www.jianshu.com',
'm.toutiao.com': 'www.toutiao.com'
}
@staticmethod
def normalize(url: str) -> str:
"""
URL规范化主函数
处理步骤:
1. 统一协议(http -> https)
2. 转换移动版域名到PC版
3. 移除追踪参数
4. 移除fragment(#后面的部分)
5. 移除默认端口号
6. 路径末尾的/统一处理
Args:
url: 原始URL字符串
Returns:
规范化后的URL字符串
"""
if not url:
return ""
# 补全协议(如果缺失)
if not url.startswith(('http://', 'https://')):
url = 'https://' + url
# 解析URL
parsed = urlparse(url)
# 1. 统一协议为https
scheme = 'https'
# 2. 转换移动版域名
netloc = parsed.netloc.lower()
netloc = URLNormalizer.MOBILE_TO_PC.get(netloc, netloc)
# 3. 移除追踪参数
params = parse_qs(parsed.query)
cleaned_params = {
k: v for k, v in params.items()
if k.lower() not in URLNormalizer.TRACKING_PARAMS
}
query = urlencode(cleaned_params, doseq=True)
# 4. 移除fragment(#anchor)
fragment = ''
# 5. 标准化路径
path = parsed.path
# 移除路径末尾的/(除非是根路径)
if path != '/' and path.endswith('/'):
path = path.rstrip('/')
# 重新组装URL
normalized = urlunparse((
scheme, # https
netloc, # www.example.com
path, # /article/123
'', # params (已废弃)
query, # key=value
fragment # 空
))
return normalized
@staticmethod
def extract_domain(url: str) -> str:
"""
提取域名
Args:
url: URL字符串
Returns:
域名字符串,如 "www.zhihu.com"
"""
parsed = urlparse(url)
return parsed.netloc.lower()
@staticmethod
def is_same_domain(url1: str, url2: str) -> bool:
"""
判断两个URL是否同域
Args:
url1, url2: 两个URL字符串
Returns:
True if 同域,否则False
"""
domain1 = URLNormalizer.extract_domain(url1)
domain2 = URLNormalizer.extract_domain(url2)
# 移除www前缀比较
domain1 = domain1.replace('www.', '')
domain2 = domain2.replace('www.', '')
return domain1 == domain2
# ===== 测试用例 =====
if __name__ == "__main__":
test_urls = [
"http://www.example.com/article/123?from=weibo&ref=home",
"https://example.com/article/123?utm_source=twitter",
"https://m.example.com/article/123/",
"www.example.com/article/123#section2",
]
print("URL规范化测试:")
print("="*70)
for url in test_urls:
normalized = URLNormalizer.normalize(url)
print(f"原始: {url}")
print(f"规范: {normalized}")
print("-"*70)
# 预期输出:所有URL都规范化为
# https://example.com/article/123
代码详解:
-
TRACKING_PARAMS集合:列举了30+个常见的追踪参数。这些参数对内容没有影响,必须移除。
-
MOBILE_TO_PC映射 :把移动版域名转成PC版。比如知乎的
m.zhihu.com→www.zhihu.com。 -
normalize函数的六个步骤:
- 补全协议(避免后续解析失败)
- 统一用https(http和https指向同一资源)
- 转换移动域名(移动版=PC版内容)
- 移除追踪参数(用
parse_qs解析后过滤) - 去掉fragment(
#section2这种锚点) - 标准化路径(去掉末尾的
/)
-
为什么用urlparse而不是正则?
urlparse是标准库,处理URL更可靠- 正则容易漏掉边界情况(如IPv6地址、国际化域名)
模块2:正文提取与清洗
在计算内容指纹前,需要先提取"纯净的正文",去掉HTML标签、广告、导航等噪音。
python
# content_extractor.py
import re
from bs4 import BeautifulSoup
from typing import Optional
class ContentExtractor:
"""正文提取器"""
# 噪音标签(这些内容不参与指纹计算)
NOISE_TAGS = ['script', 'style', 'nav', 'footer', 'header', 'aside', 'iframe']
# 噪音文本模式
NOISE_PATTERNS = [
r'相关推荐',
r'猜你喜欢',
r'热门文章',
r'版权声明',
r'转载请注明',
r'原文链接',
r'本文由.*发布',
r'扫码关注',
]
@staticmethod
def extract_from_html(html: str) -> str:
"""
从HTML中提取纯文本正文
处理流程:
1. 解析HTML
2. 移除噪音标签
3. 提取文本
4. 清洗文本(去空格、去噪音短语)
Args:
html: HTML字符串
Returns:
清洗后的纯文本
"""
if not html:
return ""
soup = BeautifulSoup(html, 'lxml')
# 移除噪音标签
for tag in ContentExtractor.NOISE_TAGS:
for element in soup.find_all(tag):
element.decompose()
# 提取文本
text = soup.get_text(separator=' ', strip=True)
# 清洗文本
text = ContentExtractor.clean_text(text)
return text
@staticmethod
def extract_from_dict(item: dict, content_fields: list = None) -> str:
"""
从字典中提取正文字段
Args:
item: 数据字典
content_fields: 要提取的字段列表,如 ['title', 'summary', 'content']
Returns:
拼接后的文本
"""
if content_fields is None:
content_fields = ['title', 'content']
parts = []
for field in content_fields:
value = item.get(field, '')
if value:
# 如果字段值是HTML,先提取纯文本
if '<' in str(value) and '>' in str(value):
value = ContentExtractor.extract_from_html(value)
parts.append(str(value))
text = ' '.join(parts)
return ContentExtractor.clean_text(text)
@staticmethod
def clean_text(text: str) -> str:
"""
文本清洗
处理步骤:
1. 移除噪音短语(如"相关推荐")
2. 规范化空白字符
3. 移除特殊符号
4. 转小写(可选,看需求)
Args:
text: 原始文本
Returns:
清洗后的文本
"""
if not text:
return ""
# 移除噪音短语
for pattern in ContentExtractor.NOISE_PATTERNS:
text = re.sub(pattern, '', text, flags=re.IGNORECASE)
# 规范化空白字符(多个空格/换行合并为一个空格)
text = re.sub(r'\s+', ' ', text)
# 移除首尾空白
text = text.strip()
# 可选:移除特殊字符(保留中英文、数字、常用标点)
# text = re.sub(r'[^\w\s\u4e00-\u9fa5,.!?;:,。!?;:]', '', text)
return text
@staticmethod
def get_content_length(text: str) -> int:
"""
获取有效内容长度(去除空格后的字符数)
Args:
text: 文本字符串
Returns:
有效字符数
"""
return len(text.replace(' ', ''))
# ===== 测试用例 =====
if __name__ == "__main__":
# 测试HTML提取
html_sample = """
<html>
<head><title>测试文章</title></head>
<body>
<nav>导航栏</nav>
<article>
<h1>Python装饰器详解</h1>
<p>装饰器是Python中一个强大的特性...</p>
<p>它允许我们在不修改原函数的情况下增强功能。</p>
</article>
<aside>相关推荐:推荐文章1、推荐文章2</aside>
<script>console.log('ads')</script>
</body>
</html>
"""
extracted = ContentExtractor.extract_from_html(html_sample)
print("提取的正文:")
print(extracted)
print(f"\n有效字符数:{ContentExtractor.get_content_length(extracted)}")
# 预期输出:
# Python装饰器详解 装饰器是Python中一个强大的特性... 它允许我们在不修改原函数的情况下增强功能。
# (注意:导航栏、相关推荐、script都被移除了)
代码详解:
-
NOISE_TAGS列表 :这些HTML标签通常是导航、广告、脚本,不是正文内容。用
decompose()从DOM树中移除。 -
NOISE_PATTERNS正则:转载文章常见的噪音文本,如"版权声明"、"转载请注明"等。用正则批量移除。
-
extract_from_html流程:
- 用BeautifulSoup解析(比正则靠谱)
- 删除噪音标签
- 提取纯文本(
get_text方法) - 清洗空白字符
-
extract_from_dict:从字典中提取多个字段(标题+正文),拼接成一段文本。这样标题也能参与相似度计算。
-
为什么要清洗文本?
- 多余的空格、换行会影响指纹计算
- "相关推荐"这种噪音会让不同文章的指纹变得相似
- 清洗后的文本更能代表"核心内容"
模块3:SimHash指纹计算
这是核心模块,负责把文本转成64位整数指纹。
python
# fingerprint_generator.py
from simhash import Simhash
import jieba # 中文分词
from typing import List
class FingerprintGenerator:
"""指纹生成器(基于SimHash)"""
def __init__(self, use_jieba: bool = True):
"""
初始化
Args:
use_jieba: 是否使用jieba分词(中文文本推荐True)
"""
self.use_jieba = use_jieba
def generate(self, text: str, features: List[str] = None) -> int:
"""
生成SimHash指纹
原理:
1. 文本分词(中文用jieba,英文用空格分割)
2. 每个词计算hash值(64位)
3. 根据hash值的每一位,累加权重向量
4. 权重向量>0的位设为1,否则为0
5. 得到64位二进制指纹
Args:
text: 输入文本
features: 自定义特征列表(如果提供,则不分词)
Returns:
64位整数指纹
"""
if not text:
return 0
# 如果没有提供特征,自动分词
if features is None:
features = self._tokenize(text)
# 使用simhash库计算
# Simhash对象内部会:
# 1. 对每个feature计算hash
# 2. 累加权重向量
# 3. 生成最终指纹
fingerprint = Simhash(features).value
return fingerprint
def _tokenize(self, text: str) -> List[str]:
"""
文本分词
Args:
text: 输入文本
Returns:
词语列表
"""
if self.use_jieba:
# 中文分词
words = jieba.lcut(text)
# 过滤单字和停用词
words = [w for w in words if len(w) > 1]
else:
# 英文按空格分割
words = text.split()
return words
@staticmethod
def hamming_distance(fp1: int, fp2: int) -> int:
"""
计算汉明距离(两个指纹有多少位不同)
原理:
1. 两个指纹异或(XOR)
2. 统计结果中1的个数
Args:
fp1, fp2: 两个64位整数指纹
Returns:
汉明距离(0-64之间的整数)
"""
# 异或:相同位为0,不同位为1
xor_result = fp1 ^ fp2
# 统计1的个数(bin()转二进制字符串,count统计'1')
distance = bin(xor_result).count('1')
return distance
@staticmethod
def is_similar(fp1: int, fp2: int, threshold: int = 3) -> bool:
"""
判断两个指纹是否相似
Args:
fp1, fp2: 两个指纹
threshold: 汉明距离阈值(默认3)
Returns:
True if 相似,否则False
"""
distance = FingerprintGenerator.hamming_distance(fp1, fp2)
return distance <= threshold
# ===== 测试用例 =====
if __name__ == "__main__":
generator = FingerprintGenerator()
# 原文
text1 = "Python装饰器是一个强大的特性,它允许我们在不修改原函数的情况下增强功能。"
# 轻微修改(多了几个字)
text2 = "Python装饰器是一个非常强大的特性,它允许我们在不修改原函数的情况下增强其功能。"
# 完全不同
text3 = "JavaScript闭包是一种函数式编程技巧,可以实现数据封装和私有变量。"
fp1 = generator.generate(text1)
fp2 = generator.generate(text2)
fp3 = generator.generate(text3)
print(f"指纹1: {fp1}")
print(f"指纹2: {fp2}")
print(f"指纹3: {fp3}")
print()
distance_12 = FingerprintGenerator.hamming_distance(fp1, fp2)
distance_13 = FingerprintGenerator.hamming_distance(fp1, fp3)
print(f"text1 vs text2 汉明距离: {distance_12} (相似文本)")
print(f"text1 vs text3 汉明距离: {distance_13} (不同文本)")
print()
print(f"text1 和 text2 是否相似?{FingerprintGenerator.is_similar(fp1, fp2, threshold=3)}")
print(f"text1 和 text3 是否相似?{FingerprintGenerator.is_similar(fp1, fp3, threshold=3)}")
# 预期输出:
# text1 vs text2 汉明距离: 2 (很小,说明高度相似)
# text1 vs text3 汉明距离: 30+ (很大,说明完全不同)
代码详解:
-
SimHash原理(简化版):
python# 假设只有3个词:["Python", "装饰器", "强大"] # 每个词算hash(简化为3位) Python: 101 → 权重向量: [+1, -1, +1] 装饰器: 110 → 权重向量: [+1, +1, -1] 强大: 011 → 权重向量: [-1, +1, +1] # 累加权重 总权重: [+1, +1, +1] # 大于0的位设为1 最终指纹: 111 -
汉明距离:
pythonfp1 = 0b1010 # 二进制:1010 fp2 = 0b1001 # 二进制:1001 xor = 0b0011 # 异或结果:0011(有2个1) distance = 2 # 汉明距离=2 -
阈值选择:
- 阈值=0:完全相同
- 阈值=3:高度相似(经验值,适合大部分场景)
- 阈值=6:中等相似
- 阈值>10:基本不同
-
为什么用jieba分词?
- 中文没有天然的词语分隔
- "Python装饰器" 如果不分词,整个会被当成一个feature
- 分词后 ["Python", "装饰器"] 更能体现语义
模块4:去重管理器
python
# dedup_manager.py
import json
from pathlib import Path
from typing import Dict, List, Set, Tuple, Optional
from collections import defaultdict
from url_normalizer import URLNormalizer
from content_extractor import ContentExtractor
from fingerprint_generator import FingerprintGenerator
class DedupManager:
"""内容去重管理器"""
def __init__(
self,
output_dir: str = "./dedup_output",
fingerprint_threshold: int = 3,
min_content_length: int = 100
):
"""
初始化去重管理器
Args:
output_dir: 输出目录
fingerprint_threshold: 指纹相似度阈值(汉明距离)
min_content_length: 最小内容长度(太短的内容不可靠)
"""
self.output_dir = Path(output_dir)
self.output_dir.mkdir(exist_ok=True)
self.fingerprint_threshold = fingerprint_threshold
self.min_content_length = min_content_length
# 初始化工具
self.url_normalizer = URLNormalizer()
self.content_extractor = ContentExtractor()
self.fingerprint_generator = FingerprintGenerator()
# 存储结构
self.url_index: Dict[str, str] = {} # normalized_url -> item_id
self.fingerprint_index: Dict[int, List[str]] = defaultdict(list) # fingerprint -> [item_ids]
self.items: Dict[str, dict] = {} # item_id -> item_data
# 统计信息
self.stats = {
'total_input': 0,
'duplicates_by_url': 0,
'duplicates_by_content': 0,
'unique_items': 0,
'skipped_too_short': 0
}
def add_item(self, item: dict) -> Tuple[bool, str]:
"""
添加一条数据(自动去重)
去重策略:
1. 第一层:URL规范化去重
2. 第二层:内容指纹去重
Args:
item: 数据字典,必须包含 'url' 字段
Returns:
(is_unique, reason)
- is_unique: True表示唯一,False表示重复
- reason: 说明信息(如"重复:URL相同")
"""
self.stats['total_input'] += 1
url = item.get('url', '')
if not url:
return False, "无效URL"
# 第一层:URL去重
normalized_url = self.url_normalizer.normalize(url)
if normalized_url in self.url_index:
self.stats['duplicates_by_url'] += 1
existing_id = self.url_index[normalized_url]
return False, f"重复:URL相同(已存在ID:{existing_id})"
# 提取内容
content = self.content_extractor.extract_from_dict(
item,
content_fields=['title', 'content', 'summary']
)
# 检查内容长度
content_length = self.content_extractor.get_content_length(content)
if content_length < self.min_content_length:
self.stats['skipped_too_short'] += 1
return False, f"内容太短({content_length}字符)"
# 第二层:内容指纹去重
fingerprint = self.fingerprint_generator.generate(content)
# 查找相似指纹
similar_id = self._find_similar_fingerprint(fingerprint)
if similar_id:
self.stats['duplicates_by_content'] += 1
return False, f"重复:内容相似(已存在ID:{similar_id})"
# 唯一数据,保存
item_id = self._generate_item_id(item)
self.url_index[normalized_url] = item_id
self.fingerprint_index[fingerprint].append(item_id)
self.items[item_id] = {
**item,
'_normalized_url': normalized_url,
'_fingerprint': fingerprint,
'_content_length': content_length
}
self.stats['unique_items'] += 1
return True, "唯一数据"
def _find_similar_fingerprint(self, fingerprint: int) -> Optional[str]:
"""
查找相似的指纹
优化策略:
- 不是遍历所有指纹(O(n)太慢)
- 而是只检查汉明距离<=threshold的可能候选
- 利用位操作快速筛选
Args:
fingerprint: 当前指纹
Returns:
相似项的item_id,如果没有则返回None
"""
# 方法1:暴力搜索(数据量小时可用)
for existing_fp, item_ids in self.fingerprint_index.items():
distance = self.fingerprint_generator.hamming_distance(
fingerprint, existing_fp
)
if distance <= self.fingerprint_threshold:
return item_ids[0] # 返回第一个匹配项的ID
return None
# 方法2:LSH优化(数据量大时推荐,需额外实现)
# 通过分桶(bucket)技术,只检查可能相似的指纹
# 这里暂不实现,感兴趣的可以研究datasketch库的LSH
def _generate_item_id(self, item: dict) -> str:
"""
生成唯一ID
策略:source + url的MD5
"""
import hashlib
key = f"{item.get('source', 'unknown')}:{item.get('url', '')}"
return hashlib.md5(key.encode()).hexdigest()[:16]
def batch_dedup(self, items: List[dict]) -> List[dict]:
"""
批量去重
Args:
items: 数据列表
Returns:
去重后的数据列表
"""
unique_items = []
for i, item in enumerate(items, 1):
is_unique, reason = self.add_item(item)
if is_unique:
unique_items.append(item)
if i % 100 == 0:
print(f" 已处理 {i}/{len(items)}, "
f"唯一: {len(unique_items)}, "
f"重复: {i - len(unique_items)}")
return unique_items
def save_results(self, filename: str = "dedup_items.jsonl"):
"""保存去重后的数据"""
output_path = self.output_dir / filename
with open(output_path, 'w', encoding='utf-8') as f:
for item in self.items.values():
# 移除内部字段(_开头的)
clean_item = {
k: v for k, v in item.items()
if not k.startswith('_')
}
f.write(json.dumps(clean_item, ensure_ascii=False) + '\n')
print(f"💾 已保存 {len(self.items)} 条唯一数据到 {output_path}")
def save_duplicate_report(self, filename: str = "duplicate_report.txt"):
"""保存重复数据报告"""
report_path = self.output_dir / filename
# 找出所有重复指纹组
duplicate_groups = [
(fp, ids) for fp, ids in self.fingerprint_index.items()
if len(ids) > 1
]
with open(report_path, 'w', encoding='utf-8') as f:
f.write("="*80 + "\n")
f.write("重复数据报告\n")
f.write("="*80 + "\n\n")
f.write(f"共发现 {len(duplicate_groups)} 组重复内容\n\n")
for i, (fp, ids) in enumerate(duplicate_groups, 1):
f.write(f"--- 重复组 {i} (指纹: {fp}) ---\n")
for item_id in ids:
item = self.items[item_id]
f.write(f" ID: {item_id}\n")
f.write(f" URL: {item.get('url', 'N/A')}\n")
f.write(f" 标题: {item.get('title', 'N/A')[:50]}\n")
f.write("\n")
print(f"📄 重复报告已保存到 {report_path}")
def print_statistics(self):
"""打印统计信息"""
total = self.stats['total_input']
unique = self.stats['unique_items']
dup_url = self.stats['duplicates_by_url']
dup_content = self.stats['duplicates_by_content']
skipped = self.stats['skipped_too_short']
print("\n" + "="*70)
print("📊 去重统计")
print("="*70)
print(f"输入总数: {total}")
print(f"唯一数据: {unique} ({unique/max(total,1)*100:.1f}%)")
print(f"URL重复: {dup_url} ({dup_url/max(total,1)*100:.1f}%)")
print(f"内容重复: {dup_content} ({dup_content/max(total,1)*100:.1f}%)")
print(f"内容太短跳过: {skipped}")
print(f"去重率: {(dup_url+dup_content)/max(total,1)*100:.1f}%")
print("="*70 + "\n")
# ===== 使用示例 =====
def demo_basic():
"""基础使用示例"""
manager = DedupManager(
output_dir="./dedup_demo",
fingerprint_threshold=3,
min_content_length=50
)
# 测试数据
test_items = [
{
"url": "https://example.com/article/123?from=weibo",
"title": "Python装饰器详解",
"content": "装饰器是Python中一个强大的特性,它允许我们在不修改原函数的情况下增强功能。"
},
{
"url": "https://example.com/article/123", # URL重复(参数不同)
"title": "Python装饰器详解",
"content": "装饰器是Python中一个强大的特性,它允许我们在不修改原函数的情况下增强功能。"
},
{
"url": "https://other-site.com/post/456", # URL不同
"title": "Python装饰器详解", # 但内容相同(转载)
"content": "装饰器是Python中一个强大的特性,它允许我们在不修改原函数的情况下增强功能。"
},
{
"url": "https://example.com/article/789",
"title": "JavaScript闭包原理", # 完全不同的文章
"content": "闭包是JavaScript中的重要概念,它允许函数访问外部作用域的变量。"
}
]
print("开始去重测试...\n")
for i, item in enumerate(test_items, 1):
is_unique, reason = manager.add_item(item)
print(f"[{i}] {item['url'][:50]}")
print(f" 结果: {'✅ 唯一' if is_unique else '❌ 重复'} - {reason}\n")
manager.print_statistics()
manager.save_results()
def demo_batch():
"""批量去重示例"""
manager = DedupManager(output_dir="./dedup_batch")
# 从文件加载数据
input_file = "./aggregated_data.jsonl"
if not Path(input_file).exists():
print(f"⚠️ 文件不存在: {input_file}")
return
print(f"📥 加载数据: {input_file}")
items = []
with open(input_file, 'r', encoding='utf-8') as f:
for line in f:
if line.strip():
items.append(json.loads(line))
print(f"📊 加载 {len(items)} 条数据\n")
print("🔍 开始去重...")
unique_items = manager.batch_dedup(items)
print(f"\n✅ 去重完成")
manager.print_statistics()
manager.save_results("unique_items.jsonl")
manager.save_duplicate_report()
if __name__ == "__main__":
print("选择运行模式:")
print("1 - 基础示例(测试数据)")
print("2 - 批量去重(真实数据)")
choice = input("输入选项: ").strip()
if choice == "1":
demo_basic()
elif choice == "2":
demo_batch()
else:
print("无效选项")
代码详解:
-
两层去重策略:
python# 第一层:URL规范化 if normalized_url in url_index: return False, "URL重复" # 第二层:内容指纹 if find_similar_fingerprint(fp): return False, "内容重复" -
存储结构设计:
pythonurl_index: { "https://example.com/article/123": "abc123def456" } fingerprint_index: { 18364758544493064720: ["abc123def456", "xyz789abc012"] } items: { "abc123def456": {...完整数据...} }url_index:O(1)查找URL是否存在fingerprint_index:按指纹分组,快速找相似内容items:存储完整数据
-
_find_similar_fingerprint的优化:
- 当前实现是O(n)暴力搜索
- 数据量大时(百万级)需要LSH(Locality-Sensitive Hashing)优化
- LSH原理:把相似指纹放到同一个桶里,只在桶内搜索
-
统计信息的作用:
duplicates_by_url:多少重复是因为URL相同duplicates_by_content:多少重复是因为内容相同- 可以分析数据来源质量
模块5:完整工作流程封装
把所有步骤整合成一个完整的pipeline。
python
# dedup_pipeline.py
from pathlib import Path
import json
from typing import List, Dict
from dedup_manager import DedupManager
class DedupPipeline:
"""内容去重Pipeline(端到端)"""
def __init__(
self,
input_files: List[str],
output_dir: str = "./pipeline_output",
fingerprint_threshold: int = 3,
min_content_length: int = 100
):
"""
初始化Pipeline
Args:
input_files: 输入文件列表(支持多个来源)
output_dir: 输出目录
fingerprint_threshold: 指纹阈值
min_content_length: 最小内容长度
"""
self.input_files = input_files
self.output_dir = Path(output_dir)
self.output_dir.mkdir(exist_ok=True)
self.manager = DedupManager(
output_dir=str(self.output_dir),
fingerprint_threshold=fingerprint_threshold,
min_content_length=min_content_length
)
def load_data(self) -> List[Dict]:
"""加载所有输入文件"""
all_items = []
for file_path in self.input_files:
path = Path(file_path)
if not path.exists():
print(f"⚠️ 文件不存在: {file_path}")
continue
print(f"📥 加载: {file_path}")
count = 0
with open(path, 'r', encoding='utf-8') as f:
for line in f:
if line.strip():
all_items.append(json.loads(line))
count += 1
print(f" ✅ 加载 {count} 条")
print(f"\n📊 总计: {len(all_items)} 条数据\n")
return all_items
def run(self):
"""运行完整去重流程"""
print("🚀 启动内容去重Pipeline\n")
# Step 1: 加载数据
items = self.load_data()
if not items:
print("❌ 无数据可处理")
return
# Step 2: 去重
print("🔍 开始去重...")
unique_items = self.manager.batch_dedup(items)
# Step 3: 保存结果
print("\n💾 保存结果...")
self.manager.save_results("unique_items.jsonl")
self.manager.save_duplicate_report("duplicate_report.txt")
# Step 4: 统计报告
self.manager.print_statistics()
# Step 5: 生成详细分析
self._generate_analysis(unique_items)
print("\n✅ Pipeline完成!")
def _generate_analysis(self, unique_items: List[Dict]):
"""生成详细分析报告"""
from collections import Counter
# 按来源统计
source_counts = Counter(item.get('source', 'unknown') for item in unique_items)
# 按内容长度分布
length_ranges = {
'0-500': 0,
'500-1000': 0,
'1000-2000': 0,
'2000+': 0
}
for item in unique_items:
length = item.get('_content_length', 0)
if length < 500:
length_ranges['0-500'] += 1
elif length < 1000:
length_ranges['500-1000'] += 1
elif length < 2000:
length_ranges['1000-2000'] += 1
else:
length_ranges['2000+'] += 1
# 写入报告
report_path = self.output_dir / "analysis_report.txt"
with open(report_path, 'w', encoding='utf-8') as f:
f.write("="*70 + "\n")
f.write("详细分析报告\n")
f.write("="*70 + "\n\n")
f.write("【按来源统计】\n")
for source, count in source_counts.most_common():
f.write(f" {source}: {count} 条\n")
f.write("\n【按内容长度分布】\n")
for range_name, count in sorted(length_ranges.items()):
f.write(f" {range_name} 字符: {count} 条\n")
print(f"📄 详细分析已保存到 {report_path}")
# ===== 主程序 =====
def main():
"""主程序入口"""
# 配置输入文件(可以是多个来源的聚合数据)
input_files = [
"./multi_source_output/aggregated_data.jsonl",
"./search_results/all_results.jsonl"
]
# 创建Pipeline
pipeline = DedupPipeline(
input_files=input_files,
output_dir="./final_dedup_output",
fingerprint_threshold=3, # 汉明距离<=3认为相似
min_content_length=100 # 最少100字符
)
# 运行
pipeline.run()
if __name__ == "__main__":
main()
实战案例演示
案例:技术文章去重
假设我们从掘金、CSDN、博客园采集了1000篇Python相关文章,现在要去重。
步骤1:准备数据
bash
# 已有聚合后的数据文件
./multi_source_output/aggregated_data.jsonl # 1000条
步骤2:运行去重
python
python dedup_pipeline.py
输出示例:
json
🚀 启动内容去重Pipeline
📥 加载: ./multi_source_output/aggregated_data.jsonl
✅ 加载 1000 条
📊 总计: 1000 条数据
🔍 开始去重...
已处理 100/1000, 唯一: 87, 重复: 13
已处理 200/1000, 唯一: 165, 重复: 35
已处理 300/1000, 唯一: 238, 重复: 62
...
已处理 1000/1000, 唯一: 756, 重复: 244
💾 保存结果...
💾 已保存 756 条唯一数据到 ./final_dedup_output/unique_items.jsonl
📄 重复报告已保存到 ./final_dedup_output/duplicate_report.txt
======================================================================
📊 去重统计
======================================================================
输入总数: 1000
唯一数据: 756 (75.6%)
URL重复: 150 (15.0%)
内容重复: 94 (9.4%)
内容太短跳过: 0
去重率: 24.4%
======================================================================
📄 详细分析已保存到 ./final_dedup_output/analysis_report.txt
✅ Pipeline完成!
分析:
- 总共1000条数据,去重后剩756条
- 150条是URL重复(带不同参数的同一链接)
- 94条是内容重复(转载文章)
- 去重率24.4%,说明有1/4的数据是重复的
进阶技巧
技巧1:调优指纹阈值
python
def find_optimal_threshold(items: List[Dict], test_thresholds: List[int]):
"""
测试不同阈值的效果
目标:找到既能去重转载,又不会误杀相似文章的阈值
"""
for threshold in test_thresholds:
manager = DedupManager(fingerprint_threshold=threshold)
unique = manager.batch_dedup(items)
print(f"阈值={threshold}: 去重后{len(unique)}条, "
f"去重率={(len(items)-len(unique))/len(items)*100:.1f}%")
# 使用
find_optimal_threshold(items, test_thresholds=[2, 3, 4, 5, 6])
# 输出示例:
# 阈值=2: 去重后810条, 去重率=19.0% # 太严格,可能漏掉转载
# 阈值=3: 去重后756条, 去重率=24.4% # ✅ 合适
# 阈值=4: 去重后720条, 去重率=28.0%
# 阈值=5: 去重后680条, 去重率=32.0% # 太宽松,可能误杀
技巧2:人工验证抽样
python
def manual_verification_sample(manager: DedupManager, sample_size: int = 20):
"""
抽样人工验证
从重复组中随机抽样,人工检查是否真的重复
"""
import random
duplicate_groups = [
(fp, ids) for fp, ids in manager.fingerprint_index.items()
if len(ids) > 1
]
samples = random.sample(duplicate_groups, min(sample_size, len(duplicate_groups)))
for i, (fp, ids) in enumerate(samples, 1):
print(f"\n=== 样本 {i} ===")
for item_id in ids:
item = manager.items[item_id]
print(f"URL: {item['url']}")
print(f"标题: {item['title']}")
print(f"内容前100字: {item.get('content', '')[:100]}")
print()
verdict = input("这组是否真的重复?(y/n): ")
# 记录人工验证结果,用于调优阈值
技巧3:增量去重
python
class IncrementalDedupManager(DedupManager):
"""增量去重管理器"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._load_existing_index()
def _load_existing_index(self):
"""加载已有的去重索引"""
index_file = self.output_dir / "dedup_index.json"
if index_file.exists():
with open(index_file, 'r') as f:
data = json.load(f)
self.url_index = data.get('url_index', {})
# fingerprint_index需要特殊处理(key是int)
fp_index_raw = data.get('fingerprint_index', {})
for fp_str, ids in fp_index_raw.items():
self.fingerprint_index[int(fp_str)] = ids
print(f"📥 加载已有索引: {len(self.url_index)} 个URL")
def save_index(self):
"""保存索引以便下次增量使用"""
index_file = self.output_dir / "dedup_index.json"
# 转换fingerprint_index的key为字符串(JSON不支持int key)
fp_index_serializable = {
str(fp): ids for fp, ids in self.fingerprint_index.items()
}
with open(index_file, 'w') as f:
json.dump({
'url_index': self.url_index,
'fingerprint_index': fp_index_serializable
}, f)
print(f"💾 索引已保存")
常见问题与解决方案
Q1:如何处理多语言内容?
A:根据语言选择分词器
python
class MultiLangFingerprintGenerator(FingerprintGenerator):
"""多语言指纹生成器"""
def _detect_language(self, text: str) -> str:
"""简单的语言检测"""
# 检测是否包含中文
if re.search(r'[\u4e00-\u9fff]', text):
return 'zh'
return 'en'
def generate(self, text: str) -> int:
lang = self._detect_language(text)
if lang == 'zh':
# 中文用jieba
self.use_jieba = True
else:
# 英文用空格分割
self.use_jieba = False
return super().generate(text)
Q2:SimHash计算太慢怎么办?
A:并行计算
python
from concurrent.futures import ProcessPoolExecutor
def batch_generate_fingerprints(texts: List[str], workers: int = 4) -> List[int]:
"""并行生成指纹"""
generator = FingerprintGenerator()
with ProcessPoolExecutor(max_workers=workers) as executor:
fingerprints = list(executor.map(generator.generate, texts))
return fingerprints
Q3:如何处理图片、视频等非文本内容?
A:提取特征hash(pHash)
python
from imagehash import phash
from PIL import Image
def image_fingerprint(image_url: str) -> str:
"""图片指纹(感知哈希)"""
try:
img = Image.open(requests.get(image_url, stream=True).raw)
return str(phash(img))
except:
return ""
# 在去重时结合图片指纹
def add_item_with_image(self, item: dict):
# 文本指纹
text_fp = self.fingerprint_generator.generate(item['content'])
# 图片指纹
image_fp = image_fingerprint(item.get('cover_image', ''))
# 组合去重
combined_key = f"{text_fp}:{image_fp}"
# ...
性能优化建议
优化1:LSH加速相似搜索
当数据量达到百万级,暴力搜索会很慢。使用LSH(Locality-Sensitive Hashing)优化:
python
from datasketch import MinHashLSH
class LSHDedupManager(DedupManager):
"""使用LSH优化的去重管理器"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# 初始化LSH索引
# threshold=0.8表示Jaccard相似度>0.8认为相似
self.lsh = MinHashLSH(threshold=0.8, num_perm=128)
def _find_similar_fingerprint(self, fingerprint: int) -> Optional[str]:
"""
使用LSH快速查找相似指纹
性能对比:
- 暴力搜索:O(n),百万数据需要几秒
- LSH搜索:O(log n),百万数据毫秒级
"""
# 将SimHash指纹转为MinHash(需要适配)
# 这里简化处理,实际可以直接用MinHash做内容指纹
# 查询相似项
similar_ids = self.lsh.query(fingerprint)
if similar_ids:
return similar_ids[0]
# 没找到,加入索引
item_id = f"fp_{fingerprint}"
self.lsh.insert(item_id, fingerprint)
return None
优化2:内存映射存储
数据量特别大时,内存可能不够用。使用mmap或SQLite存储索引:
python
import sqlite3
class SQLiteDedupManager(DedupManager):
"""使用SQLite存储的去重管理器"""
def __init__(self, *args, db_path: str = "dedup.db", **kwargs):
super().__init__(*args, **kwargs)
self.db_path = db_path
self._init_database()
def _init_database(self):
"""初始化数据库"""
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
# 创建表
cursor.execute("""
CREATE TABLE IF NOT EXISTS items (
item_id TEXT PRIMARY KEY,
normalized_url TEXT UNIQUE,
fingerprint INTEGER,
data TEXT
)
""")
# 创建索引
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_fingerprint
ON items(fingerprint)
""")
conn.commit()
conn.close()
def add_item(self, item: dict) -> Tuple[bool, str]:
"""添加项(存储到SQLite)"""
# URL去重
normalized_url = self.url_normalizer.normalize(item.get('url', ''))
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
# 检查URL是否存在
cursor.execute(
"SELECT item_id FROM items WHERE normalized_url=?",
(normalized_url,)
)
if cursor.fetchone():
conn.close()
return False, "URL重复"
# 计算指纹
content = self.content_extractor.extract_from_dict(item)
fingerprint = self.fingerprint_generator.generate(content)
# 查找相似指纹(范围查询,利用索引)
cursor.execute("""
SELECT item_id, fingerprint
FROM items
WHERE fingerprint BETWEEN ? AND ?
""", (fingerprint - 1000, fingerprint + 1000))
for row in cursor.fetchall():
existing_fp = row[1]
distance = self.fingerprint_generator.hamming_distance(
fingerprint, existing_fp
)
if distance <= self.fingerprint_threshold:
conn.close()
return False, f"内容重复(ID:{row[0]})"
# 插入新数据
item_id = self._generate_item_id(item)
cursor.execute("""
INSERT INTO items (item_id, normalized_url, fingerprint, data)
VALUES (?, ?, ?, ?)
""", (item_id, normalized_url, fingerprint, json.dumps(item)))
conn.commit()
conn.close()
return True, "唯一数据"
优化3:分片处理
数据量巨大时,分片并行处理:
python
def parallel_dedup(
input_file: str,
output_dir: str,
num_shards: int = 4
):
"""
并行去重处理
策略:
1. 按URL的hash值分片(相同URL必定在同一片)
2. 每片独立去重
3. 合并结果
"""
from concurrent.futures import ProcessPoolExecutor
# Step 1: 分片
print(f"📦 分成 {num_shards} 片...")
shards = [[] for _ in range(num_shards)]
with open(input_file, 'r') as f:
for line in f:
item = json.loads(line)
url = item.get('url', '')
shard_id = hash(url) % num_shards
shards[shard_id].append(item)
# Step 2: 并行去重
print(f"🔍 并行去重...")
def dedup_shard(shard_data, shard_id):
manager = DedupManager(output_dir=f"{output_dir}/shard_{shard_id}")
return manager.batch_dedup(shard_data)
with ProcessPoolExecutor(max_workers=num_shards) as executor:
futures = [
executor.submit(dedup_shard, shards[i], i)
for i in range(num_shards)
]
results = [f.result() for f in futures]
# Step 3: 合并
print(f"🔗 合并结果...")
all_unique = []
for result in results:
all_unique.extend(result)
# 跨片去重(处理不同片的相似内容)
final_manager = DedupManager(output_dir=output_dir)
final_unique = final_manager.batch_dedup(all_unique)
return final_unique
总结与最佳实践
核心思想 :内容去重 = URL规范化(粗粒度)+ 内容指纹(细粒度)
操作步骤:
- URL规范化:移除追踪参数、统一协议、转换移动版域名
- 正文提取:去掉HTML标签、导航、广告等噪音
- 指纹计算:用SimHash把文本转成64位整数
- 相似度判断:汉明距离<=3认为相似
- 统计分析:记录URL重复、内容重复的占比
注意事项:
- 阈值调优:不同场景最优阈值不同,建议抽样验证
- 原始数据留存:保存raw_data字段,便于调试
- 增量去重:定期跑增量,不要每次全量重跑
- 性能优化:数据量>10万考虑LSH,>100万考虑分片
什么时候用内容指纹去重?
- ✅ 多源聚合后的数据(转载问题严重)
- ✅ 同一网站的移动版和PC版数据
- ✅ 需要识别"换汤不换药"的重复内容
什么时候不适合?
- ❌ 数据源已经做了去重(重复率<1%)
- ❌ 内容高度模板化(如商品描述,差异很小)
- ❌ 实时性要求极高(指纹计算有开销)
技术深入:SimHash原理详解
很多同学可能好奇SimHash到底怎么工作的,这里深入讲解一下。
SimHash算法步骤
假设有这样一段文本:
"Python装饰器是一个强大的特性"
Step 1: 分词
python
words = ["Python", "装饰器", "一个", "强大", "特性"]
Step 2: 对每个词计算hash
python
# 假设hash函数返回3位二进制(实际是64位)
hash("Python") = 101
hash("装饰器") = 110
hash("一个") = 011
hash("强大") = 101
hash("特性") = 010
Step 3: 构建权重向量
初始化一个长度为3的权重向量:[0, 0, 0]
对每个hash,如果某位是1,该位+1;如果是0,该位-1:
python
# "Python" = 101
[+1, -1, +1]
# "装饰器" = 110
[+1, +1, -1]
# "一个" = 011
[-1, +1, +1]
# "强大" = 101
[+1, -1, +1]
# "特性" = 010
[-1, +1, -1]
# 累加
总权重 = [+1, +1, +1]
Step 4: 生成指纹
权重>0的位设为1,否则为0:
python
指纹 = 111 # 因为[+1, +1, +1]都>0
为什么相似文本的指纹接近?
假设有两篇文章:
json
文章A: "Python装饰器是一个强大的特性"
文章B: "Python装饰器是一个非常强大的特性" # 多了"非常"
分词后:
json
A: ["Python", "装饰器", "一个", "强大", "特性"]
B: ["Python", "装饰器", "一个", "非常", "强大", "特性"]
大部分词相同,所以权重向量的累加结果会很接近,最终指纹的汉明距离很小。
汉明距离计算
python
fp_A = 0b1010101010101010 # 示例指纹A
fp_B = 0b1010101010101011 # 示例指纹B(只有最后一位不同)
# 异或运算
xor = fp_A ^ fp_B
# 结果: 0b0000000000000001
# 统计1的个数
distance = bin(xor).count('1')
# 结果: 1(说明只有1位不同,非常相似)
实战优化:真实案例分享
案例:知乎回答去重
我曾经帮某个数据团队处理知乎的爬虫数据,遇到这样的问题:
问题:同一个回答,在不同问题下被引用(比Python"和"Python入门推荐"两个问题下,有人粘贴了同一个回答)
解决方案:
python
# 特殊处理:提取回答正文,忽略问题标题
def extract_content_for_zhihu(item: dict) -> str:
"""知乎专用内容提取"""
# 只提取回答正文,不要问题标题
content = item.get('answer_content', '')
# 去掉引用的其他回答(知乎特有的引用格式)
content = re.sub(r'<blockquote>.*?</blockquote>', '', content, flags=re.DOTALL)
return ContentExtractor.extract_from_html(content)
# 使用
manager = DedupManager()
manager.content_extractor.extract_from_dict = extract_content_for_zhihu
效果:去重率从12%提升到28%,节省了大量存储空间。
下期预告 🔮
下一讲我们要深入一个"偏理论但很重要"的主题:数据清洗与标准化。
采集到的原始数据往往是"脏"的:
- 字段值有多余的空格、特殊字符
- 时间格式五花八门("2024-01-15" vs "2024/1/15")
- 数值类型不统一(字符串 vs 数字)
- 缺失值处理(null vs 空字符串 vs "N/A")
我会教你:
- 字段值规范化(trim、大小写、特殊字符)
- 时间统一解析(支持30+种常见格式)
- 数值类型转换("1.2万" → 12000)
- 缺失值填充策略
- 数据质量评分体系
学会之后,你的数据就能"干干净净"地进入下游分析系统😎
练习作业 ✍️
- 必做:用本讲代码对自己的采集数据做去重,统计URL重复和内容重复的占比
- 必做:测试不同的指纹阈值(2、3、4、5),对比去重效果
- 选做:实现一个"人工验证工具",随机抽样20组重复数据,人工判断是否真的重复
验收标准:
- 去重后数据量明显减少(去重率>10%)
- 生成详细的去重报告(URL重复、内容重复分别多少)
- 能处理至少1000条数据
- 找到适合自己数据的最优阈值
小提示 :内容去重最容易踩的坑是"过度去重"------把相似但不同的文章也当成重复了。建议先设保守阈值(如2),观察效果后再放宽。宁可多保留,也别误杀💡
下一讲见,咱们搞定数据清洗与标准化!🚀
🌟 文末
好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
📌 专栏持续更新中|建议收藏 + 订阅
专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集
想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?
评论区留言告诉我你的需求,我会优先安排更新 ✅
⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)
免责声明:本文仅用于学习与技术研究,请在合法合规、遵守站点规则与 Robots 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。