Python爬虫零基础入门【第九章:实战项目教学·第17节】内容指纹去重:URL 变体/重复正文的识别!

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

全文目录:

🌟 开篇语

哈喽,各位小伙伴们你们好呀~我是【喵手】。

运营社区: 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?

  1. 速度快:指纹计算是O(n),比较只需一次异或运算
  2. 内存小:每篇文章只存一个64位整数(8字节)
  3. 局部敏感:相似文本的指纹汉明距离小(LSH特性)
  4. 工业验证: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

代码详解

  1. TRACKING_PARAMS集合:列举了30+个常见的追踪参数。这些参数对内容没有影响,必须移除。

  2. MOBILE_TO_PC映射 :把移动版域名转成PC版。比如知乎的 m.zhihu.comwww.zhihu.com

  3. normalize函数的六个步骤

    • 补全协议(避免后续解析失败)
    • 统一用https(http和https指向同一资源)
    • 转换移动域名(移动版=PC版内容)
    • 移除追踪参数(用parse_qs解析后过滤)
    • 去掉fragment(#section2这种锚点)
    • 标准化路径(去掉末尾的/
  4. 为什么用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都被移除了)

代码详解

  1. NOISE_TAGS列表 :这些HTML标签通常是导航、广告、脚本,不是正文内容。用decompose()从DOM树中移除。

  2. NOISE_PATTERNS正则:转载文章常见的噪音文本,如"版权声明"、"转载请注明"等。用正则批量移除。

  3. extract_from_html流程

    • 用BeautifulSoup解析(比正则靠谱)
    • 删除噪音标签
    • 提取纯文本(get_text方法)
    • 清洗空白字符
  4. extract_from_dict:从字典中提取多个字段(标题+正文),拼接成一段文本。这样标题也能参与相似度计算。

  5. 为什么要清洗文本

    • 多余的空格、换行会影响指纹计算
    • "相关推荐"这种噪音会让不同文章的指纹变得相似
    • 清洗后的文本更能代表"核心内容"

模块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+ (很大,说明完全不同)

代码详解

  1. 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
  2. 汉明距离

    python 复制代码
    fp1 = 0b1010  # 二进制:1010
    fp2 = 0b1001  # 二进制:1001
    xor = 0b0011  # 异或结果:0011(有2个1)
    distance = 2  # 汉明距离=2
  3. 阈值选择

    • 阈值=0:完全相同
    • 阈值=3:高度相似(经验值,适合大部分场景)
    • 阈值=6:中等相似
    • 阈值>10:基本不同
  4. 为什么用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("无效选项")

代码详解

  1. 两层去重策略

    python 复制代码
    # 第一层:URL规范化
    if normalized_url in url_index:
        return False, "URL重复"
    
    # 第二层:内容指纹
    if find_similar_fingerprint(fp):
        return False, "内容重复"
  2. 存储结构设计

    python 复制代码
    url_index: {
        "https://example.com/article/123": "abc123def456"
    }
    
    fingerprint_index: {
        18364758544493064720: ["abc123def456", "xyz789abc012"]
    }
    
    items: {
        "abc123def456": {...完整数据...}
    }
    • url_index:O(1)查找URL是否存在
    • fingerprint_index:按指纹分组,快速找相似内容
    • items:存储完整数据
  3. _find_similar_fingerprint的优化

    • 当前实现是O(n)暴力搜索
    • 数据量大时(百万级)需要LSH(Locality-Sensitive Hashing)优化
    • LSH原理:把相似指纹放到同一个桶里,只在桶内搜索
  4. 统计信息的作用

    • 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规范化(粗粒度)+ 内容指纹(细粒度)

操作步骤

  1. URL规范化:移除追踪参数、统一协议、转换移动版域名
  2. 正文提取:去掉HTML标签、导航、广告等噪音
  3. 指纹计算:用SimHash把文本转成64位整数
  4. 相似度判断:汉明距离<=3认为相似
  5. 统计分析:记录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)
  • 缺失值填充策略
  • 数据质量评分体系

学会之后,你的数据就能"干干净净"地进入下游分析系统😎

练习作业 ✍️

  1. 必做:用本讲代码对自己的采集数据做去重,统计URL重复和内容重复的占比
  2. 必做:测试不同的指纹阈值(2、3、4、5),对比去重效果
  3. 选做:实现一个"人工验证工具",随机抽样20组重复数据,人工判断是否真的重复

验收标准

  • 去重后数据量明显减少(去重率>10%)
  • 生成详细的去重报告(URL重复、内容重复分别多少)
  • 能处理至少1000条数据
  • 找到适合自己数据的最优阈值

小提示 :内容去重最容易踩的坑是"过度去重"------把相似但不同的文章也当成重复了。建议先设保守阈值(如2),观察效果后再放宽。宁可多保留,也别误杀💡

下一讲见,咱们搞定数据清洗与标准化!🚀

🌟 文末

好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!

小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥

📌 专栏持续更新中|建议收藏 + 订阅

专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:

✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)

📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集

想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?

评论区留言告诉我你的需求,我会优先安排更新 ✅


⭐️ 若喜欢我,就请关注我叭~(更新不迷路)

⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)

⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)


免责声明:本文仅用于学习与技术研究,请在合法合规、遵守站点规则与 Robots 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。

相关推荐
喵手2 小时前
Python爬虫零基础入门【第五章:数据保存与入库·第1节】先学最通用:CSV/JSONL 保存(可复现、可分享)!
爬虫·python·python爬虫实战·python爬虫工程化实战·python爬虫零基础入门·数据保存与入库·csv/jsonl
子夜江寒2 小时前
OpenCV 学习:图像拼接与答题卡识别的实现
python·opencv·学习·计算机视觉
bjxiaxueliang2 小时前
一文掌握Python Flask:HTTP微服务开发从入门到部署
python·http·flask
SunnyRivers3 小时前
Python 中的 HTTP 客户端:Requests、HTTPX 与 AIOHTTP 对比
python·httpx·requests·aiohttp·区别
u0109272713 小时前
持续集成/持续部署(CI/CD) for Python
jvm·数据库·python
lixin5565564 小时前
基于迁移学习的图像风格增强器
java·人工智能·pytorch·python·深度学习·语言模型
阡陌..4 小时前
浅谈SAR图像处理---形态学滤波
图像处理·人工智能·python
qq_229058014 小时前
python-Dgango项目收集静态文件、构建前端、安装依赖
开发语言·python
测试人社区—66794 小时前
2025区块链分层防御指南:AI驱动的安全测试实战策略
开发语言·驱动开发·python·appium·pytest