Python爬虫零基础入门【第九章:实战项目教学·第4节】质量报告自动生成:缺失率/重复率/异常值 TopN!

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

全文目录:

🌟 开篇语

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

运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO

欢迎大家常来逛逛,一起学习,一起进步~🌟

我长期专注 Python 爬虫工程化实战 ,主理专栏 👉 《Python爬虫实战》:从采集策略反爬对抗 ,从数据清洗分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上

📌 专栏食用指南(建议收藏)

  • ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
  • ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
  • ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
  • ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用

📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏《Python爬虫实战》

订阅后更新会优先推送,按目录学习更高效~

上期回顾

上一节《Python爬虫零基础入门【第九章:实战项目教学·第3节】通用清洗工具包:日期/金额/单位/空值(可复用)!》我们搞定了通用清洗工具包,把日期、金额、单位这些"脏数据"都规范化了。你会发现清洗过程中总会有些数据"不听话"------要么字段压根没抓到,要么同一条新闻反复出现,要么某个字段的值明显离谱。

这时候光靠肉眼盯着几千条数据看?累死也找不全问题。今天咱们就来做一个自动质量检测系统,让程序替你把这些问题全揪出来,还能生成一份像模像样的报告。

说白了,这节课就是给你的爬虫装个"体检仪"🩺,每次跑完自动告诉你哪里有毛病。

为什么需要质量报告?

真实场景的痛点

我之前帮朋友做过一个新闻采集项目,信心满满跑了一周,采了2万多条数据。结果老板让我分析"各类新闻的发布时间分布",我一看傻眼了------30%的数据时间字段是空的!

更坑的是,有500多条完全重复的新闻,因为那个网站列表页会重复展示热门内容,我当时只按URL去重,没想到它URL后面带了时间戳参数...

如果当时有个质量报告,第一天就能发现问题,也不至于白跑一周。

质量报告能帮你解决什么?

  1. 缺失率统计:哪些字段经常抓不到?是解析规则有问题,还是源站本来就没有?
  2. 重复率检测:数据膨胀是因为真的有这么多,还是去重逻辑漏了?
  3. 异常值识别:发布时间是2099年?浏览量-1?这种明显不合理的值得标出来
  4. 数据分布:Top 来源/Top 作者,帮你快速了解数据构成

有了这些,你不仅能及时发现bug,交付给别人时也更专业------"本次采集10000条,标题缺失率0.5%,正文完整率99.2%",这比干巴巴甩个文件强太多了。

质量报告要包含哪些内容?

咱们设计一个实用派报告,不搞花里胡哨的,就抓核心指标:

1. 基础统计

  • 总条数
  • 采集时间范围
  • 数据源分布(如果多源)

2. 字段缺失率

json 复制代码
标题(title):        ████████████████████ 100.0% (0/10000缺失)
发布时间(pub_time): ███████████████░░░░░  75.2% (2480/10000缺失)
正文(content):      ███████████████████░  98.5% (150/10000缺失)

3. 重复率检测

  • 按URL去重后剩余条数
  • 按内容指纹去重后剩余条数(标题+正文hash)
  • 重复最多的Top 10条目

4. 异常值检测

  • 时间异常(未来时间/过于久远)
  • 数值异常(负数/超大值)
  • 长度异常(标题超300字/正文少于10字)

5. 抽样展示

随机抽20条可供人工复核,看看实际数据长啥样

实现思路拆解

整体架构

json 复制代码
原始数据(items.jsonl)
    ↓
QualityAnalyzer
    ├─ 读取数据
    ├─ 计算各项指标
    ├─ 生成报告对象
    └─ 渲染输出(Markdown/HTML)
    ↓
quality_report.md

核心模块设计

  1. DataLoader:读取JSONL/CSV/数据库
  2. MetricsCalculator:计算缺失率、重复率等
  3. AnomalyDetector:规则引擎检测异常
  4. ReportRenderer:渲染成Markdown/HTML

技术选型

  • 数据处理: pandas(方便统计) 或纯Python字典列表(更轻量)
  • 去重: hashlib计算指纹
  • 报告渲染: jinja2模板(可选) 或字符串拼接

我这里用纯Python+简单模板,不依赖pandas,新手更好理解。

完整代码实现

项目结构

json 复制代码
quality_checker/
├── analyzer.py          # 核心分析器
├── detectors.py         # 异常检测规则
├── renderers.py         # 报告渲染
├── run_check.py         # 运行入口
└── templates/
    └── report.md.j2     # Markdown模板(可选)

1. 核心分析器 (analyzer.py)

python 复制代码
import json
import hashlib
from collections import Counter, defaultdict
from datetime import datetime
from typing import List, Dict, Any, Optional
import random


class QualityAnalyzer:
    """数据质量分析器 - 你的爬虫体检仪"""
    
    def __init__(self, data: List[Dict[str, Any]]):
        """
        初始化分析器
        Args:
            data: 字典列表,每个字典是一条采集的数据
        """
        self.data = data
        self.total = len(data)
        self.metrics = {}  # 存放各项指标结果
        
    def analyze_all(self) -> Dict[str, Any]:
        """一键分析所有指标"""
        if self.total == 0:
            return {"error": "数据为空,无法生成报告"}
        
        print(f"📊 开始分析 {self.total} 条数据...")
        
        self.metrics['basic'] = self._basic_stats()
        self.metrics['missing'] = self._missing_rate()
        self.metrics['duplicate'] = self._duplicate_check()
        self.metrics['anomaly'] = self._anomaly_detection()
        self.metrics['samples'] = self._random_samples(n=20)
        
        print("✅ 分析完成!")
        return self.metrics
    
    def _basic_stats(self) -> Dict:
        """基础统计"""
        stats = {
            'total_count': self.total,
            'analyze_time': datetime.now().strftime('%Y-%m-%d %H:%M:%S')
        }
        
        # 如果有时间字段,统计时间范围
        time_fields = ['pub_time', 'publish_time', 'created_at']
        for field in time_fields:
            times = [item.get(field) for item in self.data if item.get(field)]
            if times:
                stats['time_range'] = {
                    'field': field,
                    'earliest': min(times),
                    'latest': max(times)
                }
                break
        
        # 数据源分布(如果有source字段)
        sources = [item.get('source', 'unknown') for item in self.data]
        stats['source_distribution'] = dict(Counter(sources).most_common(10))
        
        return stats
    
    def _missing_rate(self) -> Dict:
        """计算各字段缺失率"""
        if not self.data:
            return {}
        
        # 获取所有字段
        all_fields = set()
        for item in self.data:
            all_fields.update(item.keys())
        
        missing_stats = {}
        for field in all_fields:
            missing_count = sum(1 for item in self.data 
                              if not item.get(field) or item.get(field) == '')
            missing_rate = missing_count / self.total
            missing_stats[field] = {
                'missing_count': missing_count,
                'valid_count': self.total - missing_count,
                'missing_rate': missing_rate,
                'complete_rate': 1 - missing_rate
            }
        
        # 按缺失率排序
        return dict(sorted(missing_stats.items(), 
                          key=lambda x: x[1]['missing_rate'], 
                          reverse=True))
    
    def _duplicate_check(self) -> Dict:
        """重复检测 - URL去重 + 内容指纹去重"""
        result = {
            'url_duplicates': {},
            'content_duplicates': {},
            'duplicate_details': []
        }
        
        # 1. URL去重检测
        url_counter = Counter(item.get('url', '') for item in self.data if item.get('url'))
        url_dups = {url: count for url, count in url_counter.items() if count > 1}
        result['url_duplicates'] = {
            'duplicate_count': len(url_dups),
            'duplicate_urls': dict(sorted(url_dups.items(), key=lambda x: x[1], reverse=True)[:10])
        }
        
        # 2. 内容指纹去重(标题+正文hash)
        fingerprints = defaultdict(list)
        for idx, item in enumerate(self.data):
            title = item.get('title', '')
            content = item.get('content', '')
            fingerprint = hashlib.md5(f"{title}{content}".encode('utf-8')).hexdigest()
            fingerprints[fingerprint].append({
                'index': idx,
                'url': item.get('url', 'N/A'),
                'title': title[:50] + '...' if len(title) > 50 else title
            })
        
        content_dups = {fp: items for fp, items in fingerprints.items() if len(items) > 1}
        result['content_duplicates'] = {
            'duplicate_groups': len(content_dups),
            'examples': list(content_dups.values())[:5]  # 只展示前5组
        }
        
        # 3. 去重后剩余
        unique_urls = len(set(item.get('url', '') for item in self.data if item.get('url')))
        unique_content = len(fingerprints)
        result['unique_stats'] = {
            'by_url': unique_urls,
            'by_content': unique_content,
            'reduction_rate': 1 - unique_content / self.total if self.total > 0 else 0
        }
        
        return result
    
    def _anomaly_detection(self) -> Dict:
        """异常值检测"""
        anomalies = {
            'time_anomalies': [],
            'length_anomalies': [],
            'value_anomalies': []
        }
        
        now = datetime.now()
        
        for idx, item in enumerate(self.data):
            item_id = f"第{idx+1}条"
            
            # 1. 时间异常检测
            for time_field in ['pub_time', 'publish_time']:
                time_str = item.get(time_field)
                if time_str:
                    try:
                        # 简单判断:未来时间 或 10年前
                        if '2099' in str(time_str) or '2030' in str(time_str):
                            anomalies['time_anomalies'].append({
                                'id': item_id,
                                'field': time_field,
                                'value': time_str,
                                'reason': '疑似未来时间'
                            })
                        elif '1990' in str(time_str) or '2000' in str(time_str):
                            anomalies['time_anomalies'].append({
                                'id': item_id,
                                'field': time_field,
                                'value': time_str,
                                'reason': '疑似过于久远'
                            })
                    except:
                        pass
            
            # 2. 长度异常
            title = item.get('title', '')
            content = item.get('content', '')
            
            if len(title) > 200:
                anomalies['length_anomalies'].append({
                    'id': item_id,
                    'field': 'title',
                    'length': len(title),
                    'reason': '标题过长(>200字)'
                })
            
            if 0 < len(content) < 20:
                anomalies['length_anomalies'].append({
                    'id': item_id,
                    'field': 'content',
                    'length': len(content),
                    'reason': '正文过短(<20字)'
                })
            
            # 3. 数值异常
            for num_field in ['view_count', 'like_count', 'comment_count']:
                value = item.get(num_field)
                if value is not None:
                    try:
                        num_value = int(value)
                        if num_value < 0:
                            anomalies['value_anomalies'].append({
                                'id': item_id,
                                'field': num_field,
                                'value': num_value,
                                'reason': '数值为负'
                            })
                        elif num_value > 10_000_000:  # 1000万
                            anomalies['value_anomalies'].append({
                                'id': item_id,
                                'field': num_field,
                                'value': num_value,
                                'reason': '数值异常大'
                            })
                    except ValueError:
                        pass
        
        # 限制每类异常只显示前10条
        for key in anomalies:
            anomalies[key] = anomalies[key][:10]
        
        return anomalies
    
    def _random_samples(self, n=20) -> List[Dict]:
        """随机抽样供人工复核"""
        sample_size = min(n, self.total)
        samples = random.sample(self.data, sample_size)
        
        # 只保留关键字段,避免报告太长
        key_fields = ['title', 'url', 'pub_time', 'source']
        simplified = []
        for item in samples:
            simplified.append({
                k: str(v)[:100] + '...' if isinstance(v, str) and len(str(v)) > 100 else v
                for k, v in item.items()
                if k in key_fields
            })
        
        return simplified

2. 报告渲染器 (renderers.py)

python 复制代码
from typing import Dict, Any


class MarkdownRenderer:
    """将分析结果渲染成Markdown报告"""
    
    def __init__(self, metrics: Dict[str, Any]):
        self.metrics = metrics
    
    def render(self) -> str:
        """生成完整的Markdown报告"""
        sections = [
            self._render_header(),
            self._render_basic_stats(),
            self._render_missing_rate(),
            self._render_duplicates(),
            self._render_anomalies(),
            self._render_samples(),
            self._render_footer()
        ]
        
        return '\n\n'.join(sections)
    
    def _render_header(self) -> str:
        return """
# 📊 数据质量报告

> 自动生成于爬虫工程化实战质量检测系统  
> 发现问题?请检查采集规则、清洗逻辑或源站变化

---"""
    
    def _render_basic_stats(self) -> str:
        basic = self.metrics.get('basic', {})
        
        content = "## 📋 基础统计\n\n"
        content += f"- **总条数**: {basic.get('total_count', 0):,}\n"
        content += f"- **分析时间**: {basic.get('analyze_time', 'N/A')}\n"
        
        if 'time_range' in basic:
            tr = basic['time_range']
            content += f"- **时间范围**: {tr['earliest']} ~ {tr['latest']}\n"
        
        if 'source_distribution' in basic:
            content += "\n**数据源分布** (Top 10):\n\n"
            for source, count in basic['source_distribution'].items():
                percentage = count / basic['total_count'] * 100
                content += f"- {source}: {count} ({percentage:.1f}%)\n"
        
        return content
    
    def _render_missing_rate(self) -> str:
        missing = self.metrics.get('missing', {})
        
        content = "## 🔍 字段缺失率分析\n\n"
        content += "> 完整率低于95%的字段需要关注\n\n"
        content += "| 字段名 | 完整率 | 缺失数 | 有效数 | 进度条 |\n"
        content += "|--------|--------|--------|--------|--------|\n"
        
        for field, stats in missing.items():
            complete_rate = stats['complete_rate']
            bar = self._generate_bar(complete_rate, width=20)
            emoji = "✅" if complete_rate >= 0.95 else "⚠️" if complete_rate >= 0.7 else "❌"
            
            content += f"| {field} | {emoji} {complete_rate*100:.1f}% | {stats['missing_count']} | {stats['valid_count']} | {bar} |\n"
        
        return content
    
    def _render_duplicates(self) -> str:
        dup = self.metrics.get('duplicate', {})
        
        content = "## 🔁 重复数据检测\n\n"
        
        # URL重复
        url_dups = dup.get('url_duplicates', {})
        content += f"### URL重复检测\n\n"
        content += f"- 发现 **{url_dups.get('duplicate_count', 0)}** 个重复URL\n\n"
        
        if url_dups.get('duplicate_urls'):
            content += "重urls'].items())[:10]:
                content += f"- `{url}`: {count}次\n"
        
        # 内容重复
        content_dups = dup.get('content_duplicates', {})
        content += f"\n### 内容指纹重复检测\n\n"
        content += f"- 发现 **{content_dups.get('duplicate_groups', 0)}** 组内容重复\n\n"
        
        if content_dups.get('examples'):
            content += "示例 (前5组):\n\n"
            for idx, group in enumerate(content_dups['examples'][:5], 1):
                content += f"**组{idx}** ({len(group)}条重复):\n"
                for item in group[:3]:  # 每组最多显示3条
                    content += f"  - {item['title']} (`{item['url']}`)\n"
                content += "\n"
        
        # 去重效果
        unique = dup.get('unique_stats', {})
        content += f"\n### 去重统计\n\n"
        content += f"- 按URL去重后: **{unique.get('by_url', 0)}** 条\n"
        content += f"- 按内容去重后: **{unique.get('by_content', 0)}** 条\n"
        content += f"- 数据压缩率: **{unique.get('reduction_rate', 0)*100:.1f}%**\n"
        
        return content
    
    def _render_anomalies(self) -> str:
        anomaly = self.metrics.get('anomaly', {})
        
        content = "## ⚠️ 异常值检测\n\n"
        
        # 时间异常
        time_anomalies = anomaly.get('time_anomalies', [])
        content += f"### 时间异常 ({len(time_anomalies)}条)\n\n"
        if time_anomalies:
            for item in time_anomalies[:10]:
                content += f"- {item['id']}: `{item['field']}` = {item['value']} ({item['reason']})\n"
        else:
            content += "✅ 未发现时间异常\n"
        
        # 长度异常
        length_anomalies = anomaly.get('length_anomalies', [])
        content += f"\n### 长度异常 ({len(length_anomalies)}条)\n\n"
        if length_anomalies:
            for item in length_anomalies[:10]:
                content += f"- {item['id']}: `{item['field']}` 长度={item['length']} ({item['reason']})\n"
        else:
            content += "✅ 未发现长度异常\n"
        
        # 数值异常
        value_anomalies = anomaly.get('value_anomalies', [])
        content += f"\n### 数值异常 ({len(value_anomalies)}条)\n\n"
        if value_anomalies:
            for item in value_anomalies[:10]:
                content += f"- {item['id']}: `{item['field']}` = {item['value']} ({item['reason']})\n"
        else:
            content += "✅ 未发现数值异常\n"
        
        return content
    
    def _render_samples(self) -> str:
        samples = self.metrics.get('samples', [])
        
        content = "## 🎲 随机抽样 (人工复核)\n\n"
        content += f"> 随机抽取 {len(samples)} 条数据供人工检查\n\n"
        
        for idx, item in enumerate(samples, 1):
            content += f"### 样本 {idx}\n\n"
            content += "```json\n"
            content += json.dumps(item, ensure_ascii=False, indent=2)
            content += "\n```\n\n"
        
        return content
    
    def _render_footer(self) -> str:
        return """

## 📝 改进建议

1. **缺失率高的字段**:检查选择器是否准确,或源站是否确实缺失
2. **重复数据**:优化去重键(URL规范化/内容指纹),避免重复入库
3. **异常值**:加强数据清洗规则,或在采集时增加校验

> 💡 Tip: 每次采集后都生成质量报告,养成好习惯!"""
    
    def _generate_bar(self, rate: float, width: int = 20) -> str:
        """生成进度条"""
        filled = int(rate * width)
        empty = width - filled
        return '█' * filled + '░' * empty
    
    def save(self, filepath: str):
        """保存报告到文件"""
        with open(filepath, 'w', encoding='utf-8') as f:
            f.write(self.render())
        print(f"✅ 报告已保存到: {filepath}")


# 需要导入json模块
import json

3. 运行入口 (run_check.py)

python 复制代码
#!/usr/bin/env python3
"""
质量检测运行脚本
用法: python run_check.py items.jsonl
"""

import sys
import json
from pathlib import Path
from analyzer import QualityAnalyzer
from renderers import MarkdownRenderer


def load_jsonl(filepath: str):
    """加载JSONL文件"""
    data = []
    with open(filepath, 'r', encoding='utf-8') as f:
        for line in f:
            line = line.strip()
            if line:
                data.append(json.loads(line))
    return data


def main():
    if len(sys.argv) < 2:
        print("❌ 用法: python run_check.py <数据文件.jsonl>")
        sys.exit(1)
    
    input_file = sys.argv[1]
    
    if not Path(input_file).exists():
        print(f"❌ 文件不存在: {input_file}")
        sys.exit(1)
    
    print(f"📂 加载数据: {input_file}")
    data = load_jsonl(input_file)
    print(f"✅ 加载完成,共 {len(data)} 条数据\n")
    
    # 分析
    analyzer = QualityAnalyzer(data)
    metrics = analyzer.analyze_all()
    
    # 生成报告
    print("\n📝 生成质量报告...")
    renderer = MarkdownRenderer(metrics)
    
    output_file = input_file.replace('.jsonl', '_quality_report.md')
    renderer.save(output_file)
    
    print(f"\n🎉 完成!请查看: {output_file}")


if __name__ == '__main__':
    main()

使用示例

准备测试数据

先造点"有问题"的数据来测试:

python 复制代码
# generate_test_data.py
import json
from datetime import datetime, timedelta

# 模拟采集的数据(故意加入各种问题)
test_data = []

# 1. 正常数据
for i in range(100):
    test_data.append({
        'url': f'https://example.com/news/{i}',
        'title': f'正常新闻标题{i}',
        'content': '这是一段正常的新闻内容' * 20,
        'pub_time': (datetime.now() - timedelta(days=i)).strftime('%Y-%m-%d'),
        'source': 'example.com',
        'view_count': 1000 + i * 10
    })

# 2. 缺失字段
for i in range(20):
    test_data.append({
        'url': f'https://example.com/incomplete/{i}',
        'title': f'缺失内容的新闻{i}',
        # 故意不加content
        'pub_time': datetime.now().strftime('%Y-%m-%d'),
        'source': 'example.com'
    })

# 3. 重复数据
for i in range(10):
    test_data.append({
        'url': 'https://example.com/news/duplicate',  # 相同URL
        'title': '这是一条重复的新闻',
        'content': '重复内容' * 10,
        'pub_time': datetime.now().strftime('%Y-%m-%d'),
        'source': 'duplicate.com'
    })

# 4. 异常值
test_data.extend([
    {'url': 'https://example.com/future', 'title': '未来新闻', 'content': '内容', 
     'pub_time': '2099-12-31', 'source': 'weird.com'},
    {'url': 'https://example.com/old', 'title': '古老新闻', 'content': '内容', 
     'pub_time': '1990-01-01', 'source': 'weird.com'},
    {'url': 'https://example.com/short', 'title': '标题超级超级超级超级长' * 10, 
     'content': '短', 'source': 'weird.com'},
    {'url': 'https://example.com/negative', 'title': '负数', 'content': '内容', 
     'view_count': -100, 'source': 'weird.com'},
])

# 保存
with open('test_items.jsonl', 'w', encoding='utf-8') as f:
    for item in test_data:
        f.write(json.dumps(item, ensure_ascii=False) + '\n')

print(f"✅ 生成 {len(test_data)} 条测试数据 → test_items.jsonl")

运行检测

bash 复制代码
# 1. 生成测试数据
python generate_test_data.py

# 2. 运行质量检测
python run_check.py test_items.jsonl

# 3. 查看报告
cat test_items_quality_report.md

输出示例

报告会类似这样:

markdown 复制代码
# 📊 数据质量报告

## 📋 基础统计

- **总条数**: 133
- **分析时间**: 2026-01-24 15:30:00
- **时间范围**: 1990-01-01 ~ 2099-12-31

**数据源分布** (Top 10):
- example.com: 120 (90.2%)
- duplicate.com: 10 (7.5%)
- weird.com: 3 (2.3%)

## 🔍 字段缺失率分析

| 字段名 | 完整率 | 缺失数 | 有效数 | 进度条 |
|--------|--------|--------|--------|--------|
| content | ⚠️ 84.2% | 21 | 112 | ████████████████░░░░ |
| pub_time | ✅ 98.5% | 2 | 131 | ███████████████████░ |
| title | ✅ 100.0% | 0 | 133 | ████████████████████ |
| url | ✅ 100.0% | 0 | 133 | ████████████████████ |
...

代码实现逻辑说明

整体流程

json 复制代码
1. 数据加载 (load_jsonl)
   ↓
2. 质量分析 (QualityAnalyzer)
   ├─ 基础统计: 总数、时间范围、来源分布
   ├─ 缺失率计算: 遍历所有字段统计空值
   ├─ 重复检测: URL去重 + MD5内容指纹
   ├─ 异常检测: 时间/长度/数值规则引擎
   └─ 随机抽样: 供人工复核
   ↓
3. 报告渲染 (MarkdownRenderer)
   ├─ 组装各部分内容
   ├─ 生成进度条/表格
   └─ 输出Markdown文件

核心设计思路

1. 分析器设计 (QualityAnalyzer)

为什么用字典存储指标?

python 复制代码
self.metrics = {
    'basic': {...},      # 基础统计
    'missing': {...},    # 缺失率
    'duplicate': {...},  # 重复检测
    'anomaly': {...},    # 异常值
    'samples': [...]     # 抽样数据
}

这样设计的好处:

  • 结构清晰,各模块独立
  • 方便后续扩展(加新指标只需加新key)
  • 便于序列化(可保存为JSON供其他工具读取)
2. 缺失率计算
python 复制代码
def _missing_rate(self):
    all_fields = set()
    for item in self.data:
        all_fields.update(item.keys())  # 收集所有出现过的字段
    
    for field in all_fields:
        missing_count = sum(1 for item in self.data 
                          if not item.get(field) or item.get(field) == '')

踩坑点:

  • item.get(field) 会在字段不存在时返回None,这就算缺失
  • 但空字符串''也要算缺失,所以要双重判断
  • 有的数据字段是0False,不能简单用if not value判断
3. 重复检测的两种策略

URL去重:

python 复制代码
url_counter = Counter(item.get('url') for item in self.data if item.get('url'))
url_dups = {url: count for url, count in url_counter.items() if count > 1}

这个简单粗暴,但URL带时间戳参数时会失效。

内容指纹去重:

python 复制代码
fingerprint = hashlib.md5(f"{title}{content}".encode('utf-8')).hexdigest()

把标题+正文拼起来算MD5,相同内容必然hash相同。这招能抓到"URL不同但内容一样"的重复。

为什么不用标题去重?

标题可能被改写(加了"【独家】"这种前缀),但正文一般不会变,所以标题+正文更准确。

4. 异常检测规则引擎
python 复制代码
# 时间异常: 简单字符串匹配
if '2099' in str(time_str) or '2030' in str(time_str):
    # 未来时间
    
# 长度异常: 硬编码阈值
if len(title) > 200:
    # 标题过长
    
# 数值异常: 范围检查
if num_value < 0 or num_value > 10_000_000:
    # 负数或异常大

这些规则是"经验阈值",你可以根据实际业务调整:

  • 新闻标题一般不超200字,但如果是论文标题可能要放宽到300
  • 浏览量1000万可能不算异常,热门视频能上亿

可配置化思路:

python 复制代码
# 未来可以这样做
ANOMALY_RULES = {
    'title_max_len': 200,
    'content_min_len': 20,
    'view_count_max': 10_000_000,
    'future_year_threshold': 2030
}
5. 进度条生成
python 复制代码
def _generate_bar(self, rate: float, width: int = 20) -> str:
    filled = int(rate * width)
    empty = width - filled
    return '█' * filled + '░' * empty

这个小技巧很实用! rate=0.75, width=20 就会生成:

json 复制代码
███████████████░░░░░  (15个实心 + 5个空心)

Markdown渲染后特别直观👍

验收标准

跑一遍测试数据,你的报告应该包含:

  1. 基础统计

    • ✅ 总条数133
    • ✅ 时间范围跨度正常
    • ✅ 来源分布清晰
  2. 缺失率分析

    • content字段缺失率15.8%(21条缺失)
    • ✅ 其他字段完整率>95%
    • ✅ 进度条正常显示
  3. 重复检测

    • ✅ 发现10个URL完全重复
    • ✅ 内容指纹去重识别出重复组
    • ✅ 展示重复详情(URL+标题)
  4. 异常检测

    • ✅ 识别出1条未来时间(2099)
    • ✅ 识别出1条过去时间(1990)
    • ✅ 识别出1条标题过长
    • ✅ 识别出1条正文过短
    • ✅ 识别出1条负数浏览量
  5. 抽样展示

    • ✅ 随机20条格式化JSON
    • ✅ 长字段截断(>100字加...)

进阶扩展思路

1. HTML版报告(带图表)

python 复制代码
class HTMLRenderer(MarkdownRenderer):
    def render(self):
        # 用 plotly 或 echarts 生成交互式图表
        # 缺失率柱状图、来源饼图、时间分布折线图
        pass

2. 对比报告(本次 vs 上次)

python 复制代码
# 保存每次的 metrics.json
# 下次生成报告时对比:
{
    "本次缺失率": 15.8%,
    "上次缺失率": 12.3%,
    "变化": "↑ 3.5%" (标红提醒)
}

3. 自动告警

python 复制代码
# 缺失率突增 > 10% 发邮件
if current_missing_rate - last_missing_rate > 0.1:
    send_alert_email("字段缺失率异常升高!")

4. 质量评分

python 复制代码
def calculate_score(metrics):
    score = 100
    score -= metrics['missing']['title']['missing_rate'] * 50  # 标题缺失扣50分
    score -= metrics['duplicate']['unique_stats']['reduction_rate'] * 30  # 重复率扣分
    return max(0, score)

实战建议

1. 养成"每次采集后必看报告"的习惯

别等采了一周才发现字段全是空的! 第一次跑完立刻生成报告,有问题马上改。

2. 报告要版本化

bash 复制代码
quality_reports/
├── 2026-01-24_quality_report.md
├── 2026-01-25_quality_report.md
└── latest.md (软链接)

这样能追溯质量变化趋势,看看是越来越好还是越来越差。

3. 抽样部分一定要人工看

程序检测不出的问题,人眼能看出来:

  • 正文被截断(只抓到前100字)
  • 时间格式虽然对,但全是同一天(可能网站有bug)
  • 作者字段其实是"编辑: xxx"(需要清洗)

随机抽20条,每天花5分钟扫一眼,能避免80%的低级错误。

4. 把报告发给需求方

如果你是给别人采数据,报告就是你的"专业证明":

json 复制代码
亲,数据采好了📦
- 总计10000条
- 标题完整率99.8%
- 已去重,无异常值
质量报告见附件,有问题随时说~

这比干巴巴甩个CSV文件专业太多了!

常见问题 FAQ

Q1: 数据量很大(百万级),分析会不会很慢?

A: 会慢,但有解决办法:

python 复制代码
# 方案1: 抽样分析(分析前1万条代表全量)
data_sample = data[:10000]
analyzer = QualityAnalyzer(data_sample)

# 方案2: 分块处理
for chunk in pd.read_json('big_file.jsonl', lines=True, chunksize=10000):
    # 每块单独分析,最后合并指标

Q2: 我的数据在数据库里,怎么办?

A: 改一下数据加载部分:

python 复制代码
import sqlite3

def load_from_db(db_path, table_name):
    conn = sqlite3.connect(db_path)
    cursor = conn.execute(f"SELECT * FROM {table_name}")
    columns = [desc[0] for desc in cursor.description]
    
    data = []
    for row in cursor:
        data.append(dict(zip(columns, row)))
    
    conn.close()
    return data

Q3: 异常规则太死板,能智能点吗?

A: 可以用统计方法:

python 复制代码
# 3-sigma 原则检测异常值
import numpy as np

values = [item.get('view_count', 0) for item in data]
mean = np.mean(values)
std = np.std(values)

for item in data:
    if abs(item['view_count'] - mean) > 3 * std:
        # 这就是异常值!

Q4: 能不能自动修复问题?

A: 报告是"诊断",修复要慎重:

  • 缺失字段: 要回源检查是源站没有,还是选择器错了
  • 重复数据: 确认去重键后可自动去重入库
  • 异常值: 可以标记或过滤,但别轻易删除(可能是真实数据)

原则: 报告发现问题,人工确认后再决定怎么处理。

下期预告

今天咱们做了一个"体检仪",能自动发现采集数据的各种问题。但光发现问题还不够,下一节我们要解决一个更实际的问题:

如何把数据稳定地存到数据库里?

具体来说:

  • SQLite零门槛入库(新手首选)
  • 建表、索引、唯一键设计
  • Upsert思想(插入或更新,幂等写入)
  • 批量写入性能优化

数据采集的最后一环就是"落地",咱们下期见!

完整源码打包

bash 复制代码
quality_checker/
├── analyzer.py          # 核心分析器(320行)
├── renderers.py         # Markdown渲染器(180行)
├── run_check.py         # 运行入口(30行)
├── generate_test_data.py # 测试数据生成器(50行)
├── requirements.txt     # 依赖(可选,本项目无额外依赖)
└── README.md            # 使用说明

# 运行方式
python generate_test_data.py  # 生成测试数据
python run_check.py test_items.jsonl  # 生成质量报告

核心依赖: 纯Python标准库,无需安装第三方包!

总结

这节课我们实现了一个生产级的质量检测系统,不到600行代码就能:

✅ 自动统计字段缺失率(哪些字段经常抓不到)

✅ 智能检测重复数据(URL + 内容双重去重)

✅ 规则引擎识别异常值(时间/长度/数值三大类)

✅ 生成专业的Markdown报告(带进度条、Top榜单)

✅ 随机抽样供人工复核(程序+人眼双保险)

最重要的收获: 养成"数据质量意识"!

采集不是终点,能用的数据才有价值。每次跑完爬虫花2分钟看看报告,能帮你:

  • 及时发现bug(选择器挂了、网站改版了)
  • 优化采集策略(哪些字段不稳定,要加备用方案)
  • 向需求方证明专业度(不是随便抓点数据交差)

代码已测试可运行,有问题欢迎交流! 下期见~ 👋

🌟 文末

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

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

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

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

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

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

✅ 互动征集

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

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


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

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

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


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

相关推荐
b2077212 小时前
Flutter for OpenHarmony 身体健康状况记录App实战 - 个人中心实现
android·java·python·flutter·harmonyos
喵手2 小时前
Python爬虫零基础入门【第九章:实战项目教学·第7节】增量采集:last_time / last_id 两种策略各做一遍!
爬虫·python·爬虫实战·python爬虫工程化实战·零基础python爬虫教学·增量采集·策略采集
子午2 小时前
【2026计算机毕设】水果识别分类系统~python+深度学习+人工智能+算法模型+TensorFlow
人工智能·python·深度学习
No0d1es2 小时前
2023年NOC大赛创客智慧编程赛项Python复赛模拟题(二)
python·青少年编程·noc·复赛·模拟题
SmartRadio2 小时前
ESP32-S3实现KVM远控+云玩功能 完整方案
运维·python·计算机外设·esp32·kvm·云玩
治愈系科普2 小时前
数字化种植牙企业
大数据·人工智能·python
AI数据皮皮侠2 小时前
中国植被生物量分布数据集(2001-2020)
大数据·人工智能·python·深度学习·机器学习
a程序小傲2 小时前
京东Java面试被问:基于Gossip协议的最终一致性实现和收敛时间
java·开发语言·前端·数据库·python·面试·状态模式
小二·2 小时前
Python Web 开发进阶实战:AI 原生应用商店 —— 在 Flask + Vue 中构建模型即服务(MaaS)与智能体分发平台
前端·人工智能·python