Python爬虫实战:监控型爬虫实战 - 从结构检测到智能告警的完整方案(附CSV导出 + SQLite持久化存储)!

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

㊗️爬虫难度指数:⭐⭐⭐

🚫声明:本数据&代码仅供学习交流,严禁用于商业用途、倒卖数据或违反目标站点的服务条款等,一切后果皆由使用者本人承担。公开榜单数据一般允许访问,但请务必遵守"君子协议",技术无罪,责任在人。

全文目录:

    • [🌟 开篇语](#🌟 开篇语)
    • [1️⃣ 摘要(Abstract)](#1️⃣ 摘要(Abstract))
    • [2️⃣ 背景与需求(Why)](#2️⃣ 背景与需求(Why))
    • [3️⃣ 合规与注意事项(必写)](#3️⃣ 合规与注意事项(必写))
    • [4️⃣ 技术选型与整体流程(What/How)](#4️⃣ 技术选型与整体流程(What/How))
    • [5️⃣ 环境准备与依赖安装](#5️⃣ 环境准备与依赖安装)
    • [6️⃣ 核心实现:结构指纹与变更检测](#6️⃣ 核心实现:结构指纹与变更检测)
      • [6.1 什么是DOM结构指纹?](#6.1 什么是DOM结构指纹?)
      • [6.2 指纹生成器实现](#6.2 指纹生成器实现)
      • [6.3 使用示例](#6.3 使用示例)
    • [7️⃣ 核心实现:自动截图与HTML归档](#7️⃣ 核心实现:自动截图与HTML归档)
      • [7.1 为什么需要截图归档?](#7.1 为什么需要截图归档?)
      • [7.2 Playwright 截图实现](#7.2 Playwright 截图实现)
      • [7.3 使用示例](#7.3 使用示例)
    • [8️⃣ 核心实现:指标计算与监控](#8️⃣ 核心实现:指标计算与监控)
      • [8.1 监控指标体系](#8.1 监控指标体系)
      • [8.2 指标计算器实现](#8.2 指标计算器实现)
      • [8.3 使用示例](#8.3 使用示例)
    • [9️⃣ 核心实现:智能告警系统](#9️⃣ 核心实现:智能告警系统)
      • [9.1 告警系统架构](#9.1 告警系统架构)
      • [9.2 告警器实现](#9.2 告警器实现)
      • [9.3 使用示例](#9.3 使用示例)
    • [🔟 完整监控爬虫实现](#🔟 完整监控爬虫实现)
    • [1️⃣1️⃣ Web监控面板实现](#1️⃣1️⃣ Web监控面板实现)
      • [11.1 Flask后端API](#11.1 Flask后端API)
      • [11.2 前端页面(HTML + ECharts)](#11.2 前端页面(HTML + ECharts))
    • [1️⃣2️⃣ 常见问题与排错(FAQ)](#1️⃣2️⃣ 常见问题与排错(FAQ))
      • [Q1: 指纹对比一直显示"结构变化",但网站并没有改版?](#Q1: 指纹对比一直显示"结构变化",但网站并没有改版?)
      • [Q3: 告警太频繁,每小时收到几十封邮件](#Q3: 告警太频繁,每小时收到几十封邮件)
      • [Q4: Playwright截图时浏览器崩溃](#Q4: Playwright截图时浏览器崩溃)
    • [1️⃣3️⃣ 性能优化与最佳实践](#1️⃣3️⃣ 性能优化与最佳实践)
      • [13.1 性能优化清单](#13.1 性能优化清单)
      • [13.2 数据库优化](#13.2 数据库优化)
      • [13.3 最佳实践总结](#13.3 最佳实践总结)
    • [1️⃣4️⃣ 总结与延伸阅读](#1️⃣4️⃣ 总结与延伸阅读)
    • [🎬 结语](#🎬 结语)
    • [🌟 文末](#🌟 文末)
      • [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
      • [✅ 互动征集](#✅ 互动征集)

🌟 开篇语

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

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

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

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

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

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

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

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

1️⃣ 摘要(Abstract)

本文将带你构建一个生产级监控型爬虫系统 ,不仅能自动采集数据,还能实时检测目标网站的结构变化、监控爬虫健康状态、自动归档样本页面和截图证据,最终实现:结构变更自动检测命中率实时告警历史样本对比分析可视化监控面板

读完本文你将获得:

  • 理解监控型爬虫与普通爬虫的本质区别,以及为什么大型项目必须要有监控
  • 掌握 DOM 结构指纹技术,自动检测网站改版导致的解析失败
  • 学会用 Playwright 自动截图归档,保留网站历史快照作为证据
  • 实现多维度告警机制:命中率骤降、解析异常、反爬触发等
  • 获得一套完整的监控系统代码(约2000行),包含Web可视化面板

2️⃣ 背景与需求(Why)

为什么需要监控型爬虫?

去年我负责维护一个每天爬取50个电商网站的价格监控系统。有一天运营突然发现某个竞品的价格数据已经一周没更新了 ,我紧急排查才发现:目标网站改版了,商品价格的 class 名从 price 改成了 product-price-new,导致 XPath 全部失效,但爬虫表面上仍在正常运行,每天抓取、入库,只是字段全是空值...

这次事故让我痛定思痛:没有监控的爬虫,就像没有体检的病人------表面光鲜,内里已经千疮百孔。

传统爬虫的痛点

场景1:网站悄悄改版

复制代码
某天网站把商品列表从 <div class="item"> 改成了 <article class="product">
你的 XPath: //div[@class="item"]  ← 返回空列表
爬虫日志:✅ 成功爬取 0 条商品
数据库:写入 0 条记录
你的感知:毫无察觉,直到业务方质问...

场景2:反爬策略升级

复制代码
网站新增了滑动验证码
你的爬虫:一直返回 403
你的处理:重试100次,全部失败
你的反应:不知道是临时故障还是永久封禁

场景3:数据质量劣化

复制代码
爬虫依然在跑,但命中率从95%降到了30%
原因:网站新增了大量动态加载内容
你的发现:两周后才从BI报表中察觉异常

监控型爬虫解决的核心问题

  1. 结构变更检测:网站改版时第一时间发现,自动告警
  2. 健康度监控:实时跟踪命中率、成功率、响应时间等指标
  3. 证据留存:归档失败时的HTML快照和截图,方便排查
  4. 智能告警:命中率骤降、异常激增时自动发邮件/企业微信
  5. 历史对比:保留网站结构的历史版本,分析变化趋势

本文的实战目标

目标1:电商价格监控系统(主项目)

  • 监控对象:京东/淘宝/拼多多某个商品的价格
  • 监控指标:价格、库存、评论数、评分
  • 告警条件:价格变动>10%、库存为0、命中率<80%
  • 归档策略:每天保存一份完整截图 + HTML快照

目标2:新闻站监控(辅助案例)

  • 监控对象:多个新闻网站的头条列表
  • 监控指标:标题数量、更新频率、图片完整性
  • 告警条件:爬取失败、结构变化、标题数<5

3️⃣ 合规与注意事项(必写)

监控型爬虫的特殊责任

普通爬虫爬完就走,监控型爬虫是长期驻扎,对目标网站的压力更大,责任也更重。

必须遵守的原则:

  1. 低频监控:不要每分钟都爬,推荐频率:每小时1次或每天2-4次
  2. 缓存机制:如果网站内容没变,不要重复抓取详情页
  3. 礼貌退出:遇到429/503等错误,立即停止并延长间隔
  4. 数据用途声明:仅用于价格监控、竞品分析等正当目的,不转卖数据

截图归档的法律边界

可以做的:

  • ✅ 保存公开页面的截图作为价格证据(消费者维权需要)
  • ✅ 归档自己网站的历史版本(内部使用)
  • ✅ 监控竞品的公开定价策略(商业情报)

不能做的:

  • ❌ 截图后台管理页面(非公开内容)
  • ❌ 保存用户隐私信息(订单、地址、聊天记录)
  • ❌ 用截图诋毁竞争对手(不正当竞争)

告警的"度"

我曾经把告警阈值设得太敏感,命中率从95%降到94%就发邮件,结果每天收到几十封告警邮件,最后全被我设成垃圾邮件...

推荐的告警阈值:

python 复制代码
# 严重告警(立即处理)
- 命中率 < 50%
- 连续失败 > 10次
- 结构指纹完全不匹配

# 警告(24小时内处理)
- 命中率在50%-80%之间
- 部分字段缺失率 > 30%
- 响应时间 > 10秒

# 提醒(不紧急)
- 命中率在80%-90%之间
- 偶发性错误(<5%)

4️⃣ 技术选型与整体流程(What/How)

核心技术栈

组件 技术选型 作用
爬虫引擎 aiohttp + Playwright 混合方案:API用aiohttp,JS渲染用Playwright
截图工具 Playwright 自动化浏览器,支持全页截图
结构检测 lxml + difflib DOM树比对 + 文本差异算法
数据存储 SQLite + Redis SQLite存历史数据,Redis存实时状态
告警通道 SMTP + 企业微信 邮件 + 即时消息双通道
可视化 Flask + ECharts Web监控面板

整体架构图

json 复制代码
┌─────────────────────────────────────────────────┐
│              定时调度层 (APScheduler)            │
│  • 每小时触发一次监控任务                        │
│  • 管理多个监控目标的并发执行                     │
└──────────────────┬──────────────────────────────┘
                   │
                   ▼
┌─────────────────────────────────────────────────┐
│              数据采集层 (Crawler)                │
│  • 发起HTTP请求 / 启动无头浏览器                 │
│  • 保存原始HTML + 截图                          │
└──────────────────┬──────────────────────────────┘
                   │
                   ▼
┌─────────────────────────────────────────────────┐
│              解析与检测层 (Parser + Detector)    │
│  • 提取目标字段                                  │
│  • 计算DOM结构指纹                               │
│  • 对比历史指纹,检测变更                        │
└──────────────────┬──────────────────────────────┘
                   │
                   ▼
┌─────────────────────────────────────────────────┐
│              指标计算层 (Metrics)                │
│  • 命中率:成功提取字段数 / 总字段数              │
│  • 完整性:非空字段数 / 总字段数                  │
│  • 一致性:与历史数据的偏差度                     │
└──────────────────┬──────────────────────────────┘
                   │
                   ▼
┌─────────────────────────────────────────────────┐
│              告警决策层 (Alerter)                │
│  • 判断是否触发告警条件                          │
│  • 选择告警通道(邮件/企业微信/短信)             │
│  • 去重:相同告警1小时内只发│
└──────────────────┬──────────────────────────────┘
                   │
                   ▼
┌─────────────────────────────────────────────────┐
│              存储与归档层 (Storage)              │
│  • 数据入库:时序数据 + 元数据                   │
│  • 截图归档:按日期组织目录                       │
│  • 样本HTML:压缩存储                            │
└──────────────────┬──────────────────────────────┘
                   │
                   ▼
┌─────────────────────────────────────────────────┐
│              可视化层 (Dashboard)                │
│  • 实时监控:命中率曲线、告警列表                 │
│  • 历史对比:截图轮播、结构diff                   │
│  • 报表导出:CSV/Excel                           │
└─────────────────────────────────────────────────┘

为什么选择这套技术栈?

aiohttp vs Playwright 的分工:

  • aiohttp:适合API接口、静态HTML,速度快(每请求50ms)
  • Playwright:适合需要截图、JS渲染的场景,速度慢(每请求2-5秒)

策略:先用 aiohttp 快速尝试,如果返回空壳HTML,再启动 Playwright

SQLite vs MySQL 的选择:

  • 监控数据量不大(每天几千条),SQLite 够用且免维护
  • 如果需要多机共享,再升级到 MySQL/PostgreSQL

5️⃣ 环境准备与依赖安装

Python 版本与核心依赖

bash 复制代码
# Python 3.10+ (需要结构化模式匹配特性)
python --version  # 应该 >= 3.10

# 安装核心依赖
pip install aiohttp==3.9.1
pip install playwright==1.40.0
pip install lxml==5.1.0
pip install beautifulsoup4==4.12.2
pip install Pillow==10.1.0          # 图片处理
pip install aiosqlite==0.19.0
pip install redis==5.0.1
pip install apscheduler==3.10.4     # 定时任务
pip install flask==3.0.0            # Web面板
pip install jinja2==3.1.2

# 告警相关
pip install requests==2.31.0        # 企业微信webhook

# 数据分析
pip install pandas==2.1.4
pip install matplotlib==3.8.2

# 安装浏览器驱动(仅需执行一次)
playwright install chromium

项目目录结构

复制代码
monitor_crawler/
├── config/
│   ├── __init__.py
│   ├── settings.py           # 全局配置
│   └── targets.yaml          # 监控目标配置
├── crawler/
│   ├── __init__.py
│   ├── fetcher.py            # 请求层(aiohttp + Playwright)
│   ├── parser.py             # 解析层
│   ├── detector.py           # 结构检测
│   └── screenshot.py         # 截图归档
├── monitor/
│   ├── __init__.py
│   ├── metrics.py            # 指标计算
│   ├── alerter.py            # 告警系统
│   └── storage.py            # 存储层
├── dashboard/
│   ├── __init__.py
│   ├── app.py                # Flask应用
│   ├── templates/            # HTML模板
│   └── static/               # CSS/JS
├── data/
│   ├── db/
│   │   └── monitor.db        # SQLite数据库
│   ├── screenshots/          # 截图归档
│   │   ├── 2025-01-31/
│   │   └── 2025-02-01/
│   ├── html_archive/         # HTML快照
│   └── fingerprints/         # 结构指纹
├── logs/
│   ├── crawler.log
│   └── alert.log
├── tests/
│   └── test_detector.py
├── main.py                    # 主入口
├── requirements.txt
└── README.md

6️⃣ 核心实现:结构指纹与变更检测

这是监控型爬虫的灵魂功能。

6.1 什么是DOM结构指纹?

简单理解:就像人的指纹一样,每个网页的HTML结构也有独特的"指纹"。当网站改版时,指纹会发生变化。

指纹的组成部分:

  1. DOM树深度:页面的嵌套层级
  2. 关键节点路径:目标字段的 XPath
  3. class/id 哈希:所有 class 和 id 的集合
  4. 标签分布:各类型标签的数量统计

6.2 指纹生成器实现

python 复制代码
# crawler/detector.py

import hashlib
import json
from typing import Dict, List, Set
from lxml import etree
from collections import Counter
from dataclasses import dataclass, asdict
import logging

logger = logging.getLogger(__name__)

@dataclass
class DOMFingerprint:
    """DOM结构指纹数据类"""
    
    # 基础统计
    total_nodes: int              # 总节点数
    max_depth: int                # 最大深度
    tag_distribution: Dict[str, int]  # 标签分布 {"div": 120, "span": 80, ...}
    
    # 关键标识
    class_hash: str               # 所有class的哈希值
    id_hash: str                  # 所有id的哈希值
    
    # 目标字段路径(核心)
    target_xpaths: Dict[str, str]  # {"price": "//span[@class='price']", ...}
    
    # 内容特征
    text_length: int              # 总文本长度
    link_count: int               # 链接数量
    image_count: int              # 图片数量
    
    # 元数据
    timestamp: str                # 生成时间
    url: str                      # 页面URL
    
    def to_dict(self) -> dict:
        """转换为字典(用于JSON序列化)"""
        return asdict(self)
    
    def calculate_hash(self) -> str:
        """
        计算指纹的总哈希值
        
        这个哈希值可以快速判断两个指纹是否完全相同
        只要网站结构有任何变化,哈希值就会改变
        """
        # 将指纹的关键部分序列化为JSON
        key_parts = {
            'tag_distribution': self.tag_distribution,
            'class_hash': self.class_hash,
            'id_hash': self.id_hash,
            'target_xpaths': self.target_xpaths
        }
        
        # 计算SHA256哈希
        fingerprint_str = json.dumps(key_parts, sort_keys=True)
        return hashlib.sha256(fingerprint_str.encode()).hexdigest()


class StructureDetector:
    """网页结构检测器"""
    
    def __init__(self, target_xpaths: Dict[str, str]):
        """
        初始化检测器
        
        Args:
            target_xpaths: 需要监控的字段及其XPath
                例如: {
                    'title': '//h1[@class="product-title"]/text()',
                    'price': '//span[@class="price"]/text()',
                    'stock': '//div[@class="stock-info"]/text()'
                }
        """
        self.target_xpaths = target_xpaths
    
    def generate_fingerprint(self, html: str, url: str) -> DOMFingerprint:
        """
        生成网页的DOM指纹
        
        Args:
            html: 网页HTML内容
            url: 网页URL
        
        Returns:
            DOMFingerprint对象
        """
        from datetime import datetime
        
        # 解析HTML
        tree = etree.HTML(html)
        
        # 1. 计算DOM树深度
        max_depth = self._calculate_depth(tree)
        
        # 2. 统计标签分布
        tag_distribution = self._count_tags(tree)
        
        # 3. 提取所有class和id
        classes = self._extract_classes(tree)
        ids = self._extract_ids(tree)
        
        # 4. 计算class和id的哈希值
        class_hash = self._hash_set(classes)
        id_hash = self._hash_set(ids)
        
        # 5. 验证目标XPath是否有效
        valid_xpaths = self._validate_xpaths(tree)
        
        # 6. 统计内容特征
        text_length = len(tree.xpath('//text()'))
        link_count = len(tree.xpath('//a'))
        image_count = len(tree.xpath('//img'))
        
        # 7. 组装指纹
        fingerprint = DOMFingerprint(
            total_nodes=len(tree.xpath('//*')),
            max_depth=max_depth,
            tag_distribution=tag_distribution,
            class_hash=class_hash,
            id_hash=id_hash,
            target_xpaths=valid_xpaths,
            text_length=text_length,
            link_count=link_count,
            image_count=image_count,
            timestamp=datetime.now().isoformat(),
            url=url
        )
        
        logger.info(f"✅ 生成指纹: {url[:50]}... | 哈希: {fingerprint.calculate_hash()[:16]}")
        return fingerprint
    
    def _calculate_depth(self, node, current_depth=0) -> int:
        """
        递归计算DOM树的最大深度
        
        这个函数是理解DOM树结构的关键
        深度的突然变化通常意味着网站结构发生了重大改版
        """
        if len(node) == 0:  # 叶子节点
            return current_depth
        
        # 递归计算所有子节点的最大深度
        return max(
            self._calculate_depth(child, current_depth + 1)
            for child in node
        )
    
    def _count_tags(self, tree) -> Dict[str, int]:
        """
        统计各类型标签的数量
        
        示例输出:
        {
            'div': 245,
            'span': 189,
            'a': 67,
            'img': 34,
            'p': 23
        }
        """
        all_tags = tree.xpath('//*/name()')
        return dict(Counter(all_tags))
    
    def _extract_classes(self, tree) -> Set[str]:
        """
        提取所有class名称
        
        处理细节:
        1. 一个元素可能有多个class: <div class="item featured">
        2. 需要拆分后去重
        3. 忽略空class
        """
        class_attrs = tree.xpath('//*/@class')
        classes = set()
        
        for attr in class_attrs:
            # 拆分多个class(空格分隔)
            classes.update(attr.split())
        
        return classes
    
    def _extract_ids(self, tree) -> Set[str]:
        """提取所有id"""
        return set(tree.xpath('//*/@id'))
    
    def _hash_set(self, items: Set[str]) -> str:
        """
        对集合计算哈希值
        
        关键点:
        1. 集合是无序的,需要先排序保证一致性
        2. 使用SHA256而非MD5(更安全,碰撞概率更低)
        """
        sorted_items = sorted(items)
        combined = '|'.join(sorted_items)
        return hashlib.sha256(combined.encode()).hexdigest()
    
    def _validate_xpaths(self, tree) -> Dict[str, str]:
        """
        验证目标XPath是否能匹配到元素
        
        这是检测结构变化的核心:
        - 如果某个XPath突然匹配不到元素了 → 网站改版了
        - 返回的字典只包含有效的XPath
        """
        valid_xpaths = {}
        
        for field_name, xpath in self.target_xpaths.items():
            try:
                result = tree.xpath(xpath)
                if result:  # 能匹配到至少一个元素
                    valid_xpaths[field_name] = xpath
                    logger.debug(f"✅ {field_name}: {xpath} 有效")
                else:
                    logger.warning(f"⚠️ {field_name}: {xpath} 无匹配")
            except etree.XPathEvalError as e:
                logger.error(f"❌ {field_name}: XPath语法错误 - {e}")
        
        return valid_xpaths
    
    def compare_fingerprints(
        self,
        old_fp: DOMFingerprint,
        new_fp: DOMFingerprint
    ) -> Dict[str, any]:
        """
        对比两个指纹,返回差异分析
        
        返回格式:
        {
            'is_changed': bool,           # 是否发生变化
            'severity': str,              # 严重程度: 'critical' | 'warning' | 'info'
            'changes': {
                'xpath_missing': [...],   # 失效的XPath
                'depth_diff': int,        # 深度变化
                'tag_changes': {...},     # 标签数量变化
                'class_changed': bool,    # class集合是否变化
            },
            'similarity': float           # 相似度 0-1
        }
        """
        changes = {}
        
        # 1. 检查目标XPath是否失效
        old_fields = set(old_fp.target_xpaths.keys())
        new_fields = set(new_fp.target_xpaths.keys())
        
        missing_fields = old_fields - new_fields
        if missing_fields:
            changes['xpath_missing'] = list(missing_fields)
            logger.warning(f"🚨 XPath失效: {missing_fields}")
        
        # 2. 对比DOM深度变化
        depth_diff = new_fp.max_depth - old_fp.max_depth
        if abs(depth_diff) > 5:  # 深度变化超过5层
            changes['depth_diff'] = depth_diff
            logger.warning(f"🚨 DOM深度变化: {depth_diff:+d}")
        
        # 3. 对比标签分布
        tag_changes = self._compare_tag_distribution(
            old_fp.tag_distribution,
            new_fp.tag_distribution
        )
        if tag_changes:
            changes['tag_changes'] = tag_changes
        
        # 4. 检查class/id哈希是否变化
        if old_fp.class_hash != new_fp.class_hash:
            changes['class_changed'] = True
            logger.warning("🚨 class集合发生变化")
        
        if old_fp.id_hash != new_fp.id_hash:
            changes['id_changed'] = True
            logger.warning("🚨 id集合发生变化")
        
        # 5. 计算整体相似度
        similarity = self._calculate_similarity(old_fp, new_fp)
        
        # 6. 判断严重程度
        severity = self._determine_severity(changes, similarity)
        
        return {
            'is_changed': bool(changes),
            'severity': severity,
            'changes': changes,
            'similarity': similarity,
            'old_hash': old_fp.calculate_hash()[:16],
            'new_hash': new_fp.calculate_hash()[:16]
        }
    
    def _compare_tag_distribution(
        self,
        old_dist: Dict[str, int],
        new_dist: Dict[str, int]
    ) -> Dict[str, Dict]:
        """
        对比标签分布的变化
        
        示例输出:
        {
            'div': {'old': 245, 'new': 180, 'diff': -65},
            'article': {'old': 0, 'new': 50, 'diff': +50}
        }
        """
        changes = {}
        all_tags = set(old_dist.keys()) | set(new_.get(tag, 0)
            new_count = new_dist.get(tag, 0)
            diff录变化超过20%的标签
            if old_count > 0:
                change_rate = abs(diff) / old_count
                if change_rate > 0.2:  # 变化超过20%
                    changes[tag] = {
                        'old': old_count,
                        'new': new_count,
                        'diff': diff,
                        'rate': f"{change_rate:.1%}"
                    }
        
        return changes
    
    def _calculate_similarity(
        self,
        old_fp: DOMFingerprint,
        new_fp: DOMFingerprint
    ) -> float:
        """
        计算两个指纹的相似度 (0-1)
        
        算法:
        1. XPath匹配率占 40%
        2. 标签分布相似度占 30%
        3. class/id重合度占 20%
        4. 深度接近度占 10%
        """
        scores = []
        
        # 1. XPath匹配率
        old_fields = set(old_fp.target_xpaths.keys())
        new_fields = set(new_fp.target_xpaths.keys())
        if old_fields:
            xpath_score = len(old_fields & new_fields) / len(old_fields)
            scores.append(xpath_score * 0.4)
        
        # 2. 标签分布相似度(余弦相似度)
        tag_score = self._cosine_similarity(
            old_fp.tag_distribution,
            new_fp.tag_distribution
        )
        scores.append(tag_score * 0.3)
        
        # 3. class/id哈希完全一致则1分,否则0分
        class_score = 1.0 if old_fp.class_hash == new_fp.class_hash else 0.0
        scores.append(class_score * 0.2)
        
        # 4. 深度接近度
        max_depth = max(old_fp.max_depth, new_fp.max_depth)
        if max_depth > 0:
            depth_score = 1 - abs(old_fp.max_depth - new_fp.max_depth) / max_depth
            scores.append(depth_score * 0.1)
        
        return sum(scores)
    
    def _cosine_similarity(
        self,
        dist1: Dict[str, int],
        dist2: Dict[str, int]
    ) -> float:
        """
        计算两个标签分布的余弦相似度
        
        数学公式:
        similarity = (A·B) / (||A|| × ||B||)
        
        其中 A·B 是向量点积,||A|| 是向量模长
        """
        import math
        
        # 获取所有标签
        all_tags = set(dist1.keys()) | set(dist2.keys())
        
        # 构建向量
        vec1 = [dist1.get(tag, 0) for tag in all_tags]
        vec2 = [dist2.get(tag, 0) for tag in all_tags]
        
        # 计算点积
        dot_product = sum(a * b for a, b in zip(vec1, vec2))
        
        # 计算模长
        magnitude1 = math.sqrt(sum(a * a for a in vec1))
        magnitude2 = math.sqrt(sum(b * b for b in vec2))
        
        # 避免除零
        if magnitude1 == 0 or magnitude2 == 0:
            return 0.0
        
        return dot_product / (magnitude1 * magnitude2)
    
    def _determine_severity(
        self,
        changes: Dict,
        similarity: float
    ) -> str:
        """
        根据变化程度判断严重级别
        
        分级标准:
        - critical: 相似度<0.5 或 关键XPath失效
        - warning: 相似度0.5-0.8
        - info: 相似度>0.8
        """
        # 最高优先级:XPath失效
        if 'xpath_missing' in changes:
            return 'critical'
        
        # 根据相似度分级
        if similarity < 0.5:
            return 'critical'
        elif similarity < 0.8:
            return 'warning'
        else:
            return 'info'

6.3 使用示例

python 复制代码
# 示例:监控京东商品页

from crawler.detector import StructureDetector, DOMFingerprint
import json

# 1. 定义要监控的字段
target_xpaths = {
    'title': '//div[@class="sku-name"]/text()',
    'price': '//span[@class="p-price"]/span[2]/text()',
    'stock': '//div[@id="store-prompt"]/text()',
    'rating': '//div[@class="comment-item"]//strong/text()'
}

# 2. 创建检测器
detector = StructureDetector(target_xpaths)

# 3. 第一次爬取(建立基线)
html_v1 = """<html>... 网站原始HTML ...</html>"""
fp_baseline = detector.generate_fingerprint(html_v1, url="https://item.jd.com/12345.html")

# 保存基线指纹
with open('data/fingerprints/baseline.json', 'w') as f:
    json.dump(fp_baseline.to_dict(), f, indent=2)

# 4. 一周后再次爬取(检测变化)
html_v2 = """<html>... 网站新HTML(可能改版) ...</html>"""
fp_current = detector.generate_fingerprint(html_v2, url="https://item.jd.com/12345.html")

# 5. 对比指纹
diff = detector.compare_fingerprints(fp_baseline, fp_current)

print(f"是否发生变化: {diff['is_changed']}")
print(f"严重程度: {diff['severity']}")
print(f"相似度: {diff['similarity']:.2%}")

if diff['changes']:
    print("\n变化详情:")
    print(json.dumps(diff['changes'], indent=2, ensure_ascii=False))

输出示例(网站改版后):

json 复制代码
是否发生变化: True
严重程度: critical
相似度: 45.23%

变化详情:
{
  "xpath_missing": ["price", "stock"],
  "depth_diff": -8,
  "tag_changes": {
    "div": {
      "old": 245,
      "new": 180,
      "diff": -65,
      "rate": "26.5%"
    },
    "article": {
      "old": 0,
      "new": 50,
      "diff": 50,
      "rate": "100.0%"
    }
  },
  "class_changed": true
}

7️⃣ 核心实现:自动截图与HTML归档

7.1 为什么需要截图归档?

真实案例:去年双十一,我监控的某商品价格从 ¥299 突然跳到 ¥599,但第二天商家否认涨价,说是系统BUG。幸好我保留了截图证据,最终申诉成功。

截图的三大价值:

  1. 法律证据:价格欺诈、虚假宣传的铁证
  2. 历史回溯:对比网站改版前后的差异
  3. 问题排查:当XPath失效时,看截图能快速定位原因

7.2 Playwright 截图实现

python 复制代码
# crawler/screenshot.py

import asyncio
from pathlib import Path
from datetime import datetime
from typing import Optional, Dict
import logging
from playwright.async_api import async_playwright, Browser, Page
import gzip
import json

logger = logging.getLogger(__name__)

class ScreenshotArchiver:
    """网页截图归档器"""
    
    def __init__(
        self,
        archive_dir: str = "data/screenshots",
        html_dir: str = "data/html_archive",
        headless: bool = True
    ):
        """
        初始化归档器
        
        Args:
            archive_dir: 截图保存目录
            html_dir: HTML快照保存目录
            headless: 是否使用无头模式(生产环境建议True)
        """
        self.archive_dir = Path(archive_dir)
        self.html_dir = Path(html_dir)
        self.headless = headless
        
        # 创建目录
        self.archive_dir.mkdir(parents=True, exist_ok=True)
        self.html_dir.mkdir(parents=True, exist_ok=True)
        
        # Playwright 实例(延迟初始化)
        self.playwright = None
        self.browser: Optional[Browser] = None
    
    async def __aenter__(self):
        """异步上下文管理器:启动浏览器"""
        self.playwright = await async_playwright().start()
        
        # 启动 Chromium 浏览器
        self.browser = await self.playwright.chromium.launch(
            headless=self.headless,
            args=[
                '--disable-blink-features=AutomationControlled',  # 反检测
                '--disable-dev-shm-usage',  # 避免内存不足
                '--no-sandbox'  # Docker环境需要
            ]
        )
        
        logger.info("🌐 浏览器已启动")
        return self
    
    async def __aexit__(self, exc_type, exc_val, exc_tb):
        """关闭浏览器"""
        if self.browser:
            await self.browser.close()
        if self.playwright:
            await self.playwright.stop()
        logger.info("🌐 浏览器已关闭")
    
    async def capture_page(
        self,
        url: str,
        task_name: str,
        wait_for: Optional[str] = None,
        full_page: bool = True,
        viewport_size: Dict = None
    ) -> Dict[str, str]:
        """
        捕获网页截图 + HTML快照
        
        Args:
            url: 目标网址
            task_name: 任务名称(用于文件命名)
            wait_for: 等待特定元素加载完成的CSS选择器(可选)
                     例如: '.product-price' 表示等价格元素加载完
            full_page: 是否截取整个页面(True)还是仅可见区域(False)
            viewport_size: 视口大小,默认 {'width': 1920, 'height': 1080}
        
        Returns:
            {
                'screenshot_path': str,  # 截图文件路径
                'html_path': str,        # HTML快照路径
                'metadata': {...}        # 元数据
            }
        """
        if not self.browser:
            raise RuntimeError("浏览器未启动,请使用 async with 语法")
        
        # 设置默认视口
        if viewport_size is None:
            viewport_size = {'width': 1920, 'height': 1080}
        
        # 创建日期目录
        today = datetime.now().strftime('%Y-%m-%d')
        screenshot_dir = self.archive_dir / today
        html_dir = self.html_dir / today
        screenshot_dir.mkdir(exist_ok=True)
        html_dir.mkdir(exist_ok=True)
        
        # 生成文件名(带时间戳避免冲突)
        timestamp = datetime.now().strftime('%H-%M-%S')
        filename = f"{task_name}_{timestamp}"
        
        try:
            # 创建新页面
            page = await self.browser.new_page(viewport=viewport_size)
            
            # 设置超时时间(30秒)
            page.set_default_timeout(30000)
            
            # 导航到目标页面
            logger.info(f"📄 正在访问: {url}")
            await page.goto(url, wait_until='networkidle')
            
            # 等待特定元素(如果指定)
            if wait_for:
                try:
                    await page.wait_for_selector(wait_for, timeout=10000)
                    logger.info(f"✅ 元素已加载: {wait_for}")
                except Exception as e:
                    logger.warning(f"⚠️ 等待元素超时: {wait_for} - {e}")
            
            # 获取页面元数据
            title = await page.title()
            viewport = page.viewport_size
            
            # 1. 截图
            screenshot_path = screenshot_dir / f"{filename}.png"
            await page.screenshot(
                path=str(screenshot_path),
                full_page=full_page,
                type='png'
            )
            logger.info(f"📸 截图已保存: {screenshot_path}")
            
            # 2. 获取HTML(完整的DOM,包括动态加载的内容)
            html_content = await page.content()
            
            # 3. 压缩保存HTML(节省空间)
            html_gz_path = html_dir / f"{filename}.html.gz"
            with gzip.open(html_gz_path, 'wt', encoding='utf-8') as f:
                f.write(html_content)
            logger.info(f"📦 HTML已压缩保存: {html_gz_path}")
            
            # 4. 保存元数据
            metadata = {
                'url': url,
                'task_name': task_name,
                'timestamp': datetime.now().isoformat(),
                'title': title,
                'viewport': viewport,
                'html_size': len(html_content),
                'screenshot_size': screenshot_path.stat().st_size,
                'full_page': full_page
            }
            
            metadata_path = screenshot_dir / f"{filename}_meta.json"
            with open(metadata_path, 'w', encoding='utf-8') as f:
                json.dump(metadata, f, indent=2, ensure_ascii=False)
            
            # 关闭页面释放内存
            await page.close()
            
            return {
                'screenshot_path': str(screenshot_path),
                'html_path': str(html_gz_path),
                'metadata': metadata
            }
        
        except Exception as e:
            logger.error(f"❌ 截图失败: {url} - {e}")
            raise
    
    async def capture_element(
        self,
        url: str,
        selector: str,
        task_name: str
    ) -> Optional[str]:
        """
        仅截取页面中的某个元素(局部截图)
        
        用途:
        - 只关心商品价格区域,不需要整个页面
        - 节省存储空间
        - 方便对比某个模块的变化
        
        Args:
            url: 目标网址
            selector: CSS选择器,例如 '#product-detail'
            task_name: 任务名称
        
        Returns:
            截图文件路径
        """
        if not self.browser:
            raise RuntimeError("浏览器未启动")
        
        today = datetime.now().strftime('%Y-%m-%d')
        screenshot_dir = self.archive_dir / today / 'elements'
        screenshot_dir.mkdir(parents=True, exist_ok=True)
        
        timestamp = datetime.now().strftime('%H-%M-%S')
        screenshot_path = screenshot_dir / f"{task_name}_{timestamp}.png"
        
        try:
            page = await self.browser.new_page()
            await page.goto(url, wait_until='networkidle')
            
            # 定位元素
            element = await page.query_selector(selector)
            if not element:
                logger.warning(f"⚠️ 未找到元素: {selector}")
                await page.close()
                return None
            
            # 截取元素
            await element.screenshot(path=str(screenshot_path))
            logger.info(f"📸 元素截图: {screenshot_path}")
            
            await page.close()
            return str(screenshot_path)
        
        except Exception as e:
            logger.error(f"❌ 元素截图失败: {e}")
            return None
    
    async def compare_screenshots(
        self,
        img1_path: str,
        img2_path: str,
        threshold: float = 0.95
    ) -> Dict:
        """
        对比两张截图的相似度
        
        使用场景:
        - 检测页面布局是否变化
        - 验证A/B测试效果
        
        Args:
            img1_path: 第一张图片路径
            img2_path: 第二张图片路径
            threshold: 相似度阈值(0-1),低于此值认为有显著变化
        
        Returns:
            {
                'similarity': float,     # 相似度 0-1
                'is_similar': bool,      # 是否相似
                'diff_pixels': int       # 差异像素数
            }
        """
        from PIL import Image, ImageChops
        import numpy as np
        
        try:
            # 加载图片
            img1 = Image.open(img1_path).convert('RGB')
            img2 = Image.open(img2_path).convert('RGB')
            
            # 统一尺寸(如果不一致)
            if img1.size != img2.size:
                logger.warning(f"⚠️ 图片尺寸不一致: {img1.size} vs {img2.size}")
                img2 = img2.resize(img1.size, Image.Resampling.LANCZOS)
            
            # 计算差异
            diff = ImageChops.difference(img1, img2)
            diff_array = np.array(diff)
            
            # 计算差异像素数(任一通道值>10的像素)
            diff_pixels = np.sum(np.any(diff_array > 10, axis=2))
            total_pixels = img1.size[0] * img1.size[1]
            
            # 计算相似度
            similarity = 1 - (diff_pixels / total_pixels)
            
            return {
                'similarity': similarity,
                'is_similar': similarity >= threshold,
                'diff_pixels': int(diff_pixels),
                'total_pixels': total_pixels
            }
        
        except Exception as e:
            logger.error(f"❌ 截图对比失败: {e}")
            return {'similarity': 0, 'is_similar': False, 'error': str(e)}
    
    def cleanup_old_archives(self, keep_days: int = 30):
        """
        清理旧归档文件
        
        策略:
        - 保留最近30天的所有截图
        - 每月1号和15号的截图永久保留(用于长期对比)
        - 其他归档删除
        
        Args:
            keep_days: 保留天数
        """
        from datetime import timedelta
        
        cutoff_date = datetime.now() - timedelta(days=keep_days)
        deleted_count = 0
        
        for date_dir in self.archive_dir.iterdir():
            if not date_dir.is_dir():
                continue
            
            try:
                # 解析目录名(格式: 2025-01-31)
                dir_date = datetime.strptime(date_dir.name, '%Y-%m-%d')
                
                # 判断是否需要保留
                is_milestone = dir_date.day in [1, 15]  # 每月1号和15号
                is_recent = dir_date > cutoff_date
                
                if not is_milestone and not is_recent:
                    # 删除整个目录
                    import shutil
                    shutil.rmtree(date_dir)
                    deleted_count += 1
                    logger.info(f"🗑️ 已删除旧归档: {date_dir.name}")
            
            except ValueError:
                # 目录名格式不正确,跳过
                continue
        
        logger.info(f"✅ 清理完成,删除了 {deleted_count} 个旧归档目录")

7.3 使用示例

python 复制代码
# 示例:监控京东商品并截图

async def monitor_jd_product():
    """监控京东商品示例"""
    
    url = "https://item.jd.com/100012043978.html"
    
    async with ScreenshotArchiver(headless=True) as archiver:
        # 1. 完整页面截图
        result = await archiver.capture_page(
            url=url,
            task_name="jd_iphone15",
            wait_for='.p-price',  # 等待价格元素加载
            full_page=True
        )
        
        print(f"截图保存在: {result['screenshot_path']}")
        print(f"HTML保存在: {result['html_path']}")
        print(f"页面标题: {result['metadata']['title']}")
        
        # 2. 仅截取价格区域
        price_screenshot = await archiver.capture_element(
            url=url,
            selector='#detail-price',
            task_name="jd_iphone15_price"
        )
        
        # 3. 对比今天和昨天的截图
        if Path('data/screenshots/2025-01-30').exists():
            yesterday_img = 'data/screenshots/2025-01-30/jd_iphone15_14-30-00.png'
            today_img = result['screenshot_path']
            
            comparison = await archiver.compare_screenshots(yesterday_img, today_img)
            
            if not comparison['is_similar']:
                print(f"⚠️ 页面发生变化!相似度: {comparison['similarity']:.2%}")
        
        # 4. 清理30天前的旧截图
        archiver.cleanup_old_archives(keep_days=30)

# 运行
asyncio.run(monitor_jd_product())

8️⃣ 核心实现:指标计算与监控

8.1 监控指标体系

一个健康的爬虫需要监控以下维度:

指标类别 具体指标 正常范围 告警阈值
数据质量 命中率 >90% <80%
完整性 >95% <85%
一致性 与历史偏差<10% >20%
运行状态 成功率 >95% <90%
平均响应时间 <2秒 >5秒
错误率 <5% >10%
结构稳定性 XPath有效率 100% <100%
DOM相似度 >95% <80%

8.2 指标计算器实现

python 复制代码
# monitor/metrics.py

from typing import Dict, List, Optional
from dataclasses import dataclass, field
from datetime import datetime, timedelta
import statistics
import logging

logger = logging.getLogger(__name__)

@dataclass
class CrawlMetrics:
    """单次爬取的指标"""
    
    # 基本信息
    task_name: str
    url: str
    timestamp: datetime
    
    # 数据质量指标
    total_fields: int                    # 总字段数
    extracted_fields: int                # 成功提取的字段数
    empty_fields: int                    # 空值字段数
    
    # 运行状态指标
    status_code: int                     # HTTP状态码
    response_time: float                 # 响应时间(秒)
    retry_count: int                     # 重试次数
    
    # 结构检测指标
    xpath_valid_count: int               # 有效XPath数量
    xpath_total_count: int               # 总XPath数量
    structure_similarity: Optional[float] = None  # 结构相似度
    
    # 计算属性(自动生成)
    hit_rate: float = field(init=False)          # 命中率
    completeness: float = field(init=False)      # 完整性
    xpath_validity: float = field(init=False)    # XPath有效率
    
    def __post_init__(self):
        """初始化后自动计算指标"""
        # 命中率 = 成功提取字段数 / 总字段数
        self.hit_rate = (
            self.extracted_fields / self.total_fields
            if self.total_fields > 0 else 0
        )
        
        # 完整性 = 非空字段数 / 总字段数
        non_empty = self.extracted_fields - self.empty_fields
        self.completeness = (
            non_empty / self.total_fields
            if self.total_fields > 0 else 0
        )
        
        # XPath有效率
        self.xpath_validity = (
            self.xpath_valid_count / self.xpath_total_count
            if self.xpath_total_count > 0 else 0
        )
    
    def is_healthy(self) -> bool:
        """判断本次爬取是否健康"""
        return (
            self.hit_rate >= 0.8 and
            self.completeness >= 0.85 and
            self.xpath_validity == 1.0 and
            self.status_code == 200
        )
    
    def to_dict(self) -> dict:
        """转换为字典"""
        return {
            'task_name': self.task_name,
            'url': self.url,
            'timestamp': self.timestamp.isoformat(),
            'total_fields': self.total_fields,
            'extracted_fields': self.extracted_fields,
            'empty_fields': self.empty_fields,
            'status_code': self.status_code,
            'response_time': self.response_time,
            'retry_count': self.retry_count,
            'xpath_valid_count': self.xpath_valid_count,
            'xpath_total_count': self.xpath_total_count,
            'structure_similarity': self.structure_similarity,
            'hit_rate': self.hit_rate,
            'completeness': self.completeness,
            'xpath_validity': self.xpath_validity,
            'is_healthy': self.is_healthy()
        }


class MetricsAggregator:
    """指标聚合器:对多次爬取的指标进行统计分析"""
    
    def __init__(self):
        self.metrics_history: List[CrawlMetrics录"""
        self.metrics_history.append(metrics)
    
    def get_recent_metrics(self, hours: int = 24) -> List[CrawlMetrics]:
        """获取最近N小时的指标"""
        cutoff = datetime.now() - timedelta(hours=hours)
        return [
            m for m in self.metrics_history
            if m.timestamp > cutoff
        ]
    
    def calculate_trend(self, hours: int = 24) -> Dict:
        """
        计算趋势指标
        
        返回:
        {
            'avg_hit_rate': float,        # 平均命中率
            'hit_rate_trend': str,        # 趋势: 'up' | 'down' | 'stable'
            'avg_response_time': float,   # 平均响应时间
            'success_rate': float,        # 成功率
            'total_crawls': int           # 总爬取次数
        }
        """
        recent = self.get_recent_metrics(hours)
        
        if not recent:
            return {
                'avg_hit_rate': 0,
                'hit_rate_trend': 'unknown',
                'avg_response_time': 0,
                'success_rate': 0,
                'total_crawls': 0
            }
        
        # 计算平均命中率
        hit_rates = [m.hit_rate for m in recent]
        avg_hit_rate = statistics.mean(hit_rates)
        
        # 计算命中率趋势(对比前12小时和后12小时)
        mid_point = len(recent) // 2
        if mid_point > 0:
            first_half_avg = statistics.mean(hit_rates[:mid_point])
            second_half_avg = statistics.mean(hit_rates[mid_point:])
            
            if second_half_avg > first_half_avg + 0.05:
                trend = 'up'
            elif second_half_avg < first_half_avg - 0.05:
                trend = 'down'
            else:
                trend = 'stable'
        else:
            trend = 'stable'
        
        # 计算平均响应时间
        avg_response_time = statistics.mean([m.response_time for m in recent])
        
        # 计算成功率(状态码200的比例)
        success_count = sum(1 for m in recent if m.status_code == 200)
        success_rate = success_count / len(recent)
        
        return {
            'avg_hit_rate': avg_hit_rate,
            'hit_rate_trend': trend,
            'avg_response_time': avg_response_time,
            'success_rate': success_rate,
            'total_crawls': len(recent),
            'healthy_crawls': sum(1 for m in recent if m.is_healthy())
        }
    
    def detect_anomaly(self, current_metrics: CrawlMetrics) -> Dict:
        """
        异常检测:判断当前指标是否异常
        
        方法:与最近24小时的均值和标准差对比
        
        返回:
        {
            'is_anomaly': bool,
            'anomaly_type': str,       # 'hit_rate_drop' | 'response_slow' | ...
            'severity': str,           # 'critical' | 'warning' | 'info'
            'details': {...}
        }
        """
        recent = self.get_recent_metrics(24)
        
        if len(recent) < 10:  # 数据不足,无法判断
            return {'is_anomaly': False, 'reason': 'insufficient_data'}
        
        # 计算历史均值和标准差
        hit_rates = [m.hit_rate for m in recent]
        response_times = [m.response_time for m in recent]
        
        avg_hit_rate = statistics.mean(hit_rates)
        std_hit_rate = statistics.stdev(hit_rates)
        
        avg_response_time = statistics.mean(response_times)
        std_response_time = statistics.stdev(response_times)
        
        anomalies = []
        
        # 1. 检测命中率异常(低于 均值 - 2倍标准差)
        if current_metrics.hit_rate < (avg_hit_rate - 2 * std_hit_rate):
            anomalies.append({
                'type': 'hit_rate_drop',
                'severity': 'critical' if current_metrics.hit_rate < 0.5 else 'warning',
                'current': current_metrics.hit_rate,
                'expected': avg_hit_rate,
                'threshold': avg_hit_rate - 2 * std_hit_rate
            })
        
        # 2. 检测响应时间异常(高于 均值 + 2倍标准差)
        if current_metrics.response_time > (avg_response_time + 2 * std_response_time):
            anomalies.append({
                'type': 'response_slow',
                'severity': 'warning',
                'current': current_metrics.response_time,
                'expected': avg_response_time,
                'threshold': avg_response_time + 2 * std_response_time
            })
        
        # 3. 检测XPath失效
        if current_metrics.xpath_validity < 1.0:
            anomalies.append({
                'type': 'xpath_invalid',
                'severity': 'critical',
                'valid_count': current_metrics.xpath_valid_count,
                'total_count': current_metrics.xpath_total_count
            })
        
        # 4. 检测HTTP错误
        if current_metrics.status_code != 200:
            anomalies.append({
                'type': 'http_error',
                'severity': 'critical' if current_metrics.status_code >= 500 else 'warning',
                'status_code': current_metrics.status_code
            })
        
        if anomalies:
            # 取最高严重级别
            max_severity = max(
                (a['severity'] for a in anomalies),
                key=lambda x: ['info', 'warning', 'critical'].index(x)
            )
            
            return {
                'is_anomaly': True,
                'anomalies': anomalies,
                'severity': max_severity,
                'timestamp': current_metrics.timestamp.isoformat()
            }
        else:
            return {'is_anomaly': False}

8.3 使用示例

python 复制代码
# 示例:计算并监控指标

from monitor.metrics import CrawlMetrics, MetricsAggregator
from datetime import datetime

# 创建聚合器
aggregator = MetricsAggregator()

# 模拟连续爬取
for i in range(100):
    # 第80次开始模拟命中率下降(网站改版)
    hit_rate = 0.95 if i < 80 else 0.45
    
    metrics = CrawlMetrics(
        task_name='jd_monitor',
        url='https://item.jd.com/12345.html',
        timestamp=datetime.now(),
        total_fields=10,
        extracted_fields=int(10 * hit_rate),
        empty_fields=1,
        status_code=200,
        response_time=1.5,
        retry_count=0,
        xpath_valid_count=10 if i < 80 else 7,
        xpath_total_count=10
    )
    
    # 添加到聚合器
    aggregator.add_metrics(metrics)
    
    # 异常检测
    anomaly = aggregator.detect_anomaly(metrics)
    
    if anomaly['is_anomaly']:
        print(f"\n🚨 第{i+1}次爬取检测到异常:")
        for a in anomaly['anomalies']:
            print(f"  - {a['type']}: {a['severity']}")
            if 'current' in a:
                print(f"    当前值: {a['current']:.2f}, 预期值: {a['expected']:.2f}")

# 计算趋势
trend = aggregator.calculate_trend(hours=24)
print(f"\n📊 24小时趋势:")
print(f"平均命中率: {trend['avg_hit_rate']:.2%}")
print(f"趋势: {trend['hit_rate_trend']}")
print(f"成功率: {trend['success_rate']:.2%}")

9️⃣ 核心实现:智能告警系统

9.1 告警系统架构

一个完善的告警系统需要做到:

  1. 多通道支持:邮件、企业微信、钉钉、短信
  2. 去重机制:相同告警1小时内只发一次
  3. 优先级分级:critical > warning > info
  4. 自动恢复通知:问题解决后发送恢复通知

9.2 告警器实现

python 复制代码
# monitor/alerter.py

import smtplib
import requests
import json
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from typing import Dict, List, Optional
from datetime import datetime, timedelta
from pathlib import Path
import logging
from collections import defaultdict

logger = logging.getLogger(__name__)

class AlertManager:
    """告警管理器"""
    
    def __init__(self, config: Dict):
        """
        初始化告警管理器
        
        config 格式:
        {
            'email': {
                'enabled': True,
                'smtp_server': 'smtp.gmail.com',
                'smtp_port': 587,
                'username': 'your@email.com',
                'password': 'your_password',
                'recipients': ['admin@example.com']
            },
            'wechat': {
                'enabled': True,
                'webhook_url': 'https://qyapi.weixin.qq.com/...'
            },
            'dedup_window': 3600  # 去重窗口(秒)
        }
        """
        self.config = config
        
        # 告警历史(用于去重)
        # 格式: {alert_key: last_sent_timestamp}
        self.alert_history: Dict[str, datetime] = {}
        
        # 告警统计
        self.stats = defaultdict(int)
    
    def send_alert(
        self,
        title: str,
        message: str,
        severity: str = 'warning',
        metadata: Optional[Dict] = None,
        force: bool = False
    ) -> Dict:
        """
        发送告警
        
        Args:
            title: 告警标题
            message: 告警内容
            severity: 严重程度 'critical' | 'warning' | 'info'
            metadata: 附加元数据(字典)
            force: 是否强制发送(忽略去重)
        
        Returns:
            {
                'sent': bool,
                'channels': [...],  # 发送的通道
                'reason': str       # 未发送的原因(如果有)
            }
        """
        # 1. 生成告警key(用于去重)
        alert_key = self._generate_alert_key(title, severity)
        
        # 2. 检查是否需要去重
        if not force and self._should_suppress(alert_key):
            logger.info(f"⏭️ 告警已去重: {title}")
            return {
                'sent': False,
                'reason': 'deduplicated',
                'last_sent': self.alert_history[alert_key].isoformat()
            }
        
        # 3. 准备告警内容
        alert_content = self._format_alert(title, message, severity, metadata)
        
        # 4. 发送到各个通道
        sent_channels = []
        
        # 邮件通道
        if self.config.get('email', {}).get('enabled'):
            try:
                self._send_email(alert_content)
                sent_channels.append('email')
                logger.info(f"📧 邮件告警已发送: {title}")
            except Exception as e:
                logger.error(f"❌ 邮件发送失败: {e}")
        
        # 企业微信通道
        if self.config.get('wechat', {}).get('enabled'):
            try:
                self._send_wechat(alert_content)
                sent_channels.append('wechat')
                logger.info(f"💬 企业微信告警已发送: {title}")
            except Exception as e:
                logger.error(f"❌ 企业微信发送失败: {e}")
        
        # 5. 记录告警历史
        self.alert_history[alert_key] = datetime.now()
        
        # 6. 更新统计
        self.stats[f'total_{severity}'] += 1
        self.stats['total_sent'] += 1
        
        return {
            'sent': len(sent_channels) > 0,
            'channels': sent_channels,
            'timestamp': datetime.now().isoformat()
        }
    
    def _generate_alert_key(self, title: str, severity: str) -> str:
        """
        生成告警key
        
        策略:使用 title + severity 的哈希值
        这样即使message不同,只要标题和级别相同就会去重
        """
        import hashlib
        key_str = f"{title}:{severity}"
        return hashlib.md5(key_str.encode()).hexdigest()
    
    def _should_suppress(self, alert_key: str) -> bool:
        """
        判断是否应该抑制告警(去重)
        
        规则:
        - 如果该告警在去重窗口期内已发送过,则抑制
        - 去重窗口默认1小时
        """
        if alert_key not in self.alert_history:
            return False
        
        last_sent = self.alert_history[alert_key]
        dedup_window = timedelta(seconds=self.config.get('dedup_window', 3600))
        
        return datetime.now() - last_sent < dedup_window
    
    def _format_alert(
        self,
        title: str,
        message: str,
        severity: str,
        metadata: Optional[Dict]
    ) -> Dict:
        """
        格式化告警内容
        
        返回统一的告警数据结构,方便各通道使用
        """
        # 严重程度的emoji映射
        severity_emoji = {
            'critical': '🚨',
            'warning': '⚠️',
            'info': 'ℹ️'
        }
        
        # 444',
            'warning': '#FFAA00',
            'info': '#4488FF'
        }
        
        alert_data = {
            'title': f"{severity_emoji.get(severity, '•')} {title}",
            'message': message,
            'severity': severity,
            'severity_color': severity_color.get(severity, '#666666'),
            'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
            'metadata': metadata or {}
        }
        
        return alert_data
    
    def _send_email(self, alert: Dict):
        """
        发送邮件告警
        
        使用HTML格式,包含样式美化
        """
        email_config = self.config['email']
        
        # 创建邮件
        msg = MIMEMultipart('alternative')
        msg['Subject'] = alert['title']
        msg['From'] = email_config['username']
        msg['To'] = ', '.join(email_config['recipients'])
        
        # HTML正文
        html = f"""
        <html>
        <head>
            <style>
                body {{ font-family: Arial, sans-serif; }}
                .alert-box {{
                    border-left: 4px solid {alert['severity_color']};
                    padding: 15px;
                    background-color: #f9f9f9;
                    margin: 20px 0;
                }}
                .title {{
                    color: {alert['severity_color']};
                    font-size: 18px;
                    font-weight: bold;
                    margin-bottom: 10px;
                }}
                .message {{
                    color: #333;
                    line-height: 1.6;
                    margin: 10px 0;
                }}
                .metadata {{
                    background-color: #fff;
                    border: 1px solid #ddd;
                    padding: 10px;
                    margin-top: 15px;
                    font-family: monospace;
                    font-size: 12px;
                }}
                .footer {{
                    color: #999;
                    font-size: 12px;
                    margin-top: 20px;
                    padding-top: 10px;
                    border-top: 1px solid #ddd;
                }}
            </style>
        </head>
        <body>
            <div class="alert-box">
                <div class="title">{alert['title']}</div>
                <div class="message">{alert['message']}</div>
                
                {'<div class="metadata"><strong>详细信息:</strong><br>' + 
                 '<br>'.join(f"{k}: {v}" for k, v in alert['metadata'].items()) + 
                 '</div>' if alert['metadata'] else ''}
                
                <div class="footer">
                    发送时间: {alert['timestamp']}<br>
                    监控系统: Python爬虫监控平台
                </div>
            </div>
        </body>
        </html>
        """
        
        # 添加HTML部分
        html_part = MIMEText(html, 'html')
        msg.attach(html_part)
        
        # 发送邮件
        with smtplib.SMTP(email_config['smtp_server'], email_config['smtp_port']) as server:
            server.starttls()
            server.login(email_config['username'], email_config['password'])
            server.send_message(msg)
    
    def _send_wechat(self, alert: Dict):
        """
        发送企业微信告警
        
        使用 Markdown 格式
        """
        webhook_url = self.config['wechat']['webhook_url']
        
        # 构建 Markdown 消息
        markdown_text = f"""
**{alert['title']}**

> {alert['message']}

**详细信息:**
"""
        
        # 添加元数据
        if alert['metadata']:
            for key, value in alert['metadata'].items():
                markdown_text += f"\n- **{key}**: {value}"
        
        markdown_text += f"\n\n---\n*发送时间: {alert['timestamp']}*"
        
        # 企业微信 webhook 格式
        payload = {
            "msgtype": "markdown",
            "markdown": {
                "content": markdown_text
            }
        }
        
        # 发送请求
        response = requests.post(
            webhook_url,
            json=payload,
            headers={'Content-Type': 'application/json'},
            timeout=10
        )
        
        if response.status_code != 200 or response.json().get('errcode') != 0:
            raise Exception(f"企业微信API返回错误: {response.text}")
    
    def send_recovery_alert(self, original_title: str):
        """
        发送恢复通知
        
        当之前的问题已解决时,发送这个通知
        """
        self.send_alert(
            title=f"✅ 已恢复: {original_title}",
            message="之前的告警问题已自动恢复正常。",
            severity='info',
            force=True  # 恢复通知不去重
        )
    
    def get_stats(self) -> Dict:
        """获取告警统计信息"""
        return dict(self.stats)
    
    def clear_history(self):
        """清空告警历史(通常在每天开始时调用)"""
        self.alert_history.clear()
        logger.info("🗑️ 告警历史已清空")


class AlertRuleEngine:
    """告警规则引擎"""
    
    def __init__(self, alert_manager: AlertManager):
        self.alert_manager = alert_manager
        self.rules: List[Dict] = []
    
    def add_rule(
        self,
        name: str,
        condition: callable,
        title: str,
        message_template: str,
        severity: str = 'warning'
    ):
        """
        添加告警规则
        
        Args:
            name: 规则名称
            condition: 判断函数,接收metrics参数,返回bool
            title: 告警标题
            message_template: 消息模板(可使用{field}占位符)
            severity: 严重程度
        
        示例:
            engine.add_rule(
                name='命中率过低',
                condition=lambda m: m.hit_rate < 0.8,
                title='爬虫命中率告警',
                message_template='命中率降至 {hit_rate:.2%},请检查网站结构是否变化',
                severity='warning'
            )
        """
        self.rules.append({
            'name': name,
            'condition': condition,
            'title': title,
            'message_template': message_template,
            'severity': severity
        })
        logger.info(f"✅ 已添加告警规则: {name}")
    
    def evaluate(self, metrics) -> List[Dict]:
        """
        评估所有规则
        
        Args:
            metrics: CrawlMetrics 对象
        
        Returns:
            触发的告警列表
        """
        triggered_alerts = []
        
        for rule in self.rules:
            try:
                # 执行条件判断
                if rule['condition'](metrics):
                    # 格式化消息
                    message = rule['message_template'].format(**metrics.to_dict())
                    
                    # 发送告警
                    result = self.alert_manager.send_alert(
                        title=rule['title'],
                        message=message,
                        severity=rule['severity'],
                        metadata={
                            'task_name': metrics.task_name,
                            'url': metrics.url,
                            'hit_rate': f"{metrics.hit_rate:.2%}",
                            'response_time': f"{metrics.response_time:.2f}s"
                        }
                    )
                    
                    if result['sent']:
                        triggered_alerts.append({
                            'rule': rule['name'],
                            'result': result
                        })
            
            except Exception as e:
                logger.error(f"❌ 规则执行失败: {rule['name']} - {e}")
        
        return triggered_alerts
    
    def load_rules_from_config(self, config_path: str):
        """
        从配置文件加载规则
        
        配置文件格式 (YAML):
        ```yaml
        rules:
          - name: 命中率过低
            condition: "hit_rate < 0.8"
            title: 爬虫命中率告警
            message: 命中率降至 {hit_rate:.2%}
            severity: warning
        ```
        """
        import yaml
        
        with open(config_path) as f:
            config = yaml.safe_load(f)
        
        for rule_config in config.get('rules', []):
            # 将字符串条件转换为lambda函数
            # 注意:这里使用eval有安全风险,生产环境应该用更安全的方式
            condition_str = rule_config['condition']
            condition_func = eval(f"lambda m: m.{condition_str}")
            
            self.add_rule(
                name=rule_config['name'],
                condition=condition_func,
                title=rule_config['title'],
                message_template=rule_config['message'],
                severity=rule_config.get('severity', 'warning')
            )

9.3 使用示例

python 复制代码
# 示例:配置并使用告警系统

from monitor.alerter import AlertManager, AlertRuleEngine
from monitor.metrics import CrawlMetrics
from datetime import datetime

# 1. 配置告警管理器
alert_config = {
    'email': {
        'enabled': True,
        'smtp_server': 'smtp.gmail.com',
        'smtp_port': 587,
        'username': 'your_email@gmail.com',
        'password': 'your_app_password',
        'recipients': ['admin@example.com']
    },
    'wechat': {
        'enabled': True,
        'webhook_url': 'https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=YOUR_KEY'
    },
    'dedup_window': 3600  # 1小时去重窗口
}

alert_manager = AlertManager(alert_config)

# 2. 创建规则引擎
rule_engine = AlertRuleEngine(alert_manager)

# 3. 添加告警规则
rule_engine.add_rule(
    name='命中率严重下降',
    condition=lambda m: m.hit_rate < 0.5,
    title='🚨 爬虫命中率严重告警',
    message_template='任务 {task_name} 的命中率降至 {hit_rate:.2%},疑似网站改版',
    severity='critical'
)

rule_engine.add_rule(
    name='命中率轻微下降',
    condition=lambda m: 0.5 <= m.hit_rate < 0.8,
    title='⚠️ 爬虫命中率警告',
    message_template='任务 {task_name} 的命中率为 {hit_rate:.2%},请关注',
    severity='warning'
)

rule_engine.add_rule(
    name='XPath失效',
    condition=lambda m: m.xpath_validity < 1.0,
    title='🚨 XPath解析失效',
    message_template='有 {xpath_total_count} 个XPath中的 {xpath_valid_count} 个失效',
    severity='critical'
)

rule_engine.add_rule(
    name='响应时间过长',
    condition=lambda m: m.response_time > 5.0,
    title='⚠️ 响应时间过长',
    message_template='响应时间 {response_time:.2f}秒,超过阈值',
    severity='warning'
)

# 4. 模拟爬取并评估规则
metrics = CrawlMetrics(
    task_name='jd_monitor',
    url='https://item.jd.com/12345.html',
    timestamp=datetime.now(),
    total_fields=10,
    extracted_fields=4,  # 命中率40% - 会触发告警
    empty_fields=1,
    status_code=200,
    response_time=1.5,
    retry_count=0,
    xpath_valid_count=7,  # 有3个XPath失效 - 会触发告警
    xpath_total_count=10
)

# 5. 评估规则
triggered = rule_engine.evaluate(metrics)

print(f"\n触发了 {len(triggered)} 条告警:")
for alert in triggered:
    print(f"  - {alert['rule']}")
    print(f"    发送通道: {alert['result']['channels']}")

🔟 完整监控爬虫实现

现在我们把所有模块整合起来,构建一个完整的监控型爬虫。

python 复制代码
# main.py - 完整监控爬虫主程序

import asyncio
import json
from pathlib import Path
from datetime import datetime
from typing import Dict, List
import logging
from apscheduler.schedulers.asyncio import AsyncIOScheduler

# 导入我们实现的各个模块
from crawler.fetcher import ProductionFetcher
from crawler.parser import EcommerceParser
from crawler.detector import StructureDetector, DOMFingerprint
from crawler.screenshot import ScreenshotArchiver
from monitor.metrics import CrawlMetrics, MetricsAggregator
from monitor.alerter import AlertManager, AlertRuleEngine
from monitor.storage import AsyncSQLiteStorage

logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
    handlers=[
        logging.FileHandler('logs/monitor.log'),
        logging.StreamHandler()
    ]
)
logger = logging.getLogger(__name__)


class MonitorCrawler:
    """监控型爬虫主类"""
    
    def __init__(self, config_path: str = 'config/config.json'):
        """
        初始化监控爬虫
        
        配置文件示例:
        {
            "targets": [
                {
                    "name": "jd_iphone",
                    "url": "https://item.jd.com/100012043978.html",
                    "xpaths": {
                        "title": "//div[@class='sku-name']/text()",
                        "price": "//span[@class='price']/text()"
                    },
                    "interval": 3600,  # 每小时爬一次
                    "screenshot": true
                }
            ],
            "alert": { ... },
            "storage": { ... }
        }
        """
        # 加载配置
        with open(config_path) as f:
            self.config = json.load(f)
        
        # 初始化组件
        self.fetcher = None
        self.screenshot_archiver = None
        self.storage = AsyncSQLiteStorage('data/db/monitor.db')
        self.metrics_aggregator = MetricsAggregator()
        self.alert_manager = AlertManager(self.config['alert'])
        self.rule_engine = AlertRuleEngine(self.alert_manager)
        
        # 为每个监控目标创建检测器
        self.detectors: Dict[str, StructureDetector] = {}
        for target in self.config['targets']:
            self.detectors[target['name']] = StructureDetector(target['xpaths'])
        
        # 加载历史指纹
        self.baseline_fingerprints: Dict[str, DOMFingerprint] = {}
        self._load_baseline_fingerprints()
        
        # 配置告警规则
        self._setup_alert_rules()
    
    async def initialize(self):
        """初始化异步组件"""
        await self.storage.init_db()
        self.fetcher = ProductionFetcher(timeout=30, max_retries=3)
        await self.fetcher.__aenter__()
        
        # 初始化截图工具
        self.screenshot_archiver = ScreenshotArchiver()
        await self.screenshot_archiver.__aenter__()
        
        logger.info("✅ 监控爬虫初始化完成")
    
    async def cleanup(self):
        """清理资源"""
        if self.fetcher:
            await self.fetcher.__aexit__(None, None, None)
        if self.screenshot_archiver:
            await self.screenshot_archiver.__aexit__(None, None, None)
        logger.info("🛑 监控爬虫已停止")
    
    def _load_baseline_fingerprints(self):
        """加载基线指纹"""
        fingerprint_dir = Path('data/fingerprints')
        
        if not fingerprint_dir.exists():
            logger.warning("⚠️ 基线指纹目录不存在,将在首次爬取时创建")
            return
        
        for target in self.config['targets']:
            fp_file = fingerprint_dir / f"{target['name']}_baseline.json"
            
            if fp_file.exists():
                with open(fp_file) as f:
                    data = json.load(f)
                    # 重构 DOMFingerprint 对象
                    fp = DOMFingerprint(**data)
                    self.baseline_fingerprints[target['name']] = fp
                    logger.info(f"📂 已加载基线指纹: {target['name']}")
    
    def _setup_alert_rules(self):
        """配置告警规则"""
        # 规则1: 命中率严重下降
        self.rule_engine.add_rule(
            name='命中率严重下降',
            condition=lambda m: m.hit_rate < 0.5,
            title='🚨 爬虫命中率严重告警',
            message_template='任务 {task_name} 命中率仅 {hit_rate:.2%},疑似网站改版',
            severity='critical'
        )
        
        # 规则2: XPath部分失效
        self.rule_engine.add_rule(
            name='XPath失效',
            condition=lambda m: m.xpath_validity < 1.0,
            title='🚨 XPath解析异常',
            message_template='有 {xpath_total_count} 个字段中 {xpath_valid_count} 个失效',
            severity='critical'
        )
        
        # 规则3: 完整性不足
        self.rule_engine.add_rule(
            name='数据完整性不足',
            condition=lambda m: m.completeness < 0.8,
            title='⚠️ 数据质量告警',
            message_template='数据完整性仅 {completeness:.2%},空值字段过多',
            severity='warning'
        )
    
    async def monitor_target(self, target_config: Dict) -> Dict:
        """
        监控单个目标
        
        完整流程:
        1. 爬取页面
        2. 截图归档(如果配置)
        3. 提取数据
        4. 生成指纹并对比
        5. 计算指标
        6. 触发告警(如果需要)
        7. 保存数据
        """
        task_name = target_config['name']
        url = target_config['url']
        
        logger.info(f"🔍 开始监控: {task_name} - {url}")
        
        start_time = datetime.now()
        
        try:
            # Step 1: 爬取页面
            result = await self.fetcher.fetch_with_retry(url)
            
            response_time = (datetime.now() - start_time).total_seconds()
            
            if result['status'] != 200:
                logger.error(f"❌ 爬取失败: {task_name} - HTTP {result['status']}")
                # 发送HTTP错误告警
                self.alert_manager.send_alert(
                    title=f'🚨 爬取失败: {task_name}',
                    message=f"HTTP状态码: {result['status']}",
                    severity='critical'
                )
                return {'status': 'failed', 'reason': 'http_error'}
            
            html_content = result['content']
            
            # Step.capture_page(
                        url=url,
                        task_name=task_name,
                        wait_for=target_config.get('wait_selector'),
                        full_page=True
                    )
                    screenshot_path = capture_result['screenshot_path']
                    logger.info(f"📸 截图已保存: {screenshot_path}")
                except Exception as e:
                    logger.warning(f"⚠️ 截图失败: {e}")
            
            # Step 3: 提取数据
            detector = self.detectors[task_name]
            extracted_data = {}
            xpath_valid_count = 0
            empty_count = 0
            
            from l field_name, xpath in target_config['xpaths'].items():
                try:
                    values = tree.xpath(xpath)
                    if values:
                        value = values[0] if isinstance(values[0], str) else values[0].text
                        extracted_data[field_name] = value.strip() if value else None
                        xpath_valid_count += 1
                        
                        if not value or not value.strip():
                            empty_count += 1
                    else:
                        extracted_data[field_name] = None
                        empty_count += 1
                except Exception as e:
                    logger.warning(f"⚠️ XPath执行失败: {field_name} - {e}")
                    extracted_data[field_name] = None
            
            # Step 4: 生成并对比指纹
            current_fp = detector.generate_fingerprint(html_content, url)
            structure_similarity = None
            structure_changed = False
            
            if task_name in self.baseline_fingerprints:
                baseline_fp = self.baseline_fingerprints[task_name]
                diff = detector.compare_fingerprints(baseline_fp, current_fp)
                
                structure_similarity = diff['similarity']
                structure_changed = diff['is_changed']
                
                if structure_changed:
                    logger.warning(f"🚨 结构变化检测: {task_name}")
                    logger.warning(f"   相似度: {structure_similarity:.2%}")
                    logger.warning(f"   变化: {json.dumps(diff['changes'], ensure_ascii=False)}")
                    
                    # 发送结构变更告警
                    self.alert_manager.send_alert(
                        title=f'🚨 网站结构变更: {task_name}',
                        message=f"相似度降至 {structure_similarity:.2%}",
                        severity=diff['severity'],
                        metadata=diff['changes']
                    )
                    
                    # 更新基线指纹
                    self._save_fingerprint(task_name, current_fp, is_baseline=True)
            else:
                # 首次爬取,保存为基线
                self._save_fingerprint(task_name, current_fp, is_baseline=True)
                self.baseline_fingerprints[task_name] = current_fp
            
            # Step 5: 计算指标
            total_fields = len(target_config['xpaths'])
            extracted_fields = sum(1 for v in extracted_data.values() if v is not None)
            
            metrics = CrawlMetrics(
                task_name=task_name,
                url=url,
                timestamp=datetime.now(),
                total_fields=total_fields,
                extracted_fields=extracted_fields,
                empty_fields=empty_count,
                status_code=200,
                response_time=response_time,
                retry_count=result.get('attempt', 1) - 1,
                xpath_valid_count=xpath_valid_count,
                xpath_total_count=total_fields,
                structure_similarity=structure_similarity
            )
            
            # 添加到聚合器
            self.metrics_aggregator.add_metrics(metrics)
            
            # Step 6: 评估告警规则
            triggered_alerts = self.rule_engine.evaluate(metrics)
            
            if triggered_alerts:
                logger.warning(f"⚠️ 触发了 {len(triggered_alerts)} 条告警")
            
            # Step 7: 保存数据
            await self.storage.save_crawl_result({
                'task_name': task_name,
                'url': url,
                'timestamp': datetime.now().isoformat(),
                'data': extracted_data,
                'metrics': metrics.to_dict(),
                'screenshot_path': screenshot_path
            })
            
            logger.info(f"✅ 监控完成: {task_name} | 命中率: {metrics.hit_rate:.2%}")
            
            return {
                'status': 'success',
                'data': extracted_data,
                'metrics': metrics.to_dict(),
                'structure_changed': structure_changed
            }
        
        except Exception as e:
            logger.error(f"❌ 监控异常: {task_name} - {e}", exc_info=True)
            return {'status': 'error', 'error': str(e)}
    
    def _save_fingerprint(self, task_name: str, fp: DOMFingerprint, is_baseline: bool = False):
        """保存指纹"""
        fingerprint_dir = Path('data/fingerprints')
        fingerprint_dir.mkdir(parents=True, exist_ok=True)
        
        suffix = '_baseline' if is_baseline else f'_{datetime.now().strftime("%Y%m%d_%H%M%S")}'
        filepath = fingerprint_dir / f"{task_name}{suffix}.json"
        
        with open(filepath, 'w', encoding='utf-8') as f:
            json.dump(fp.to_dict(), f, indent=2, ensure_ascii=False)
        
        logger.info(f"💾 指纹已保存: {filepath}")
    
    async def run_all_targets(self):
        """并发监控所有目标"""
        tasks = [
            self.monitor_target(target)
            for target in self.config['targets']
        ]
        
        results = await asyncio.gather(*tasks, return_exceptions=True)
        
        # 统计结果
        success_count = sum(1 for r in results if isinstance(r, dict) and r.get('status') == 'success')
        
        logger.info(f"📊 本轮监控完成: {success_count}/{len(tasks)} 成功")
        
        return results
    
    async def start_scheduler(self):
        """启动定时调度器"""
        scheduler = AsyncIOScheduler()
        
        # 为每个目标添加定时任务
        for target in self.config['targets']:
            interval = target.get('interval', 3600)  # 默认每小时
            
            scheduler.add_job(
                self.monitor_target,
                'interval',
                seconds=interval,
                args=[target],
                id=target['name']
            )
            
            logger.info(f"⏰ 已添加定时任务: {target['name']} (每{interval}秒)")
        
        # 启动调度器
        scheduler.start()
        logger.info("🚀 定时调度器已启动")
        
        # 保持运行
        try:
            await asyncio.Event().wait()  # 永久等待
        except KeyboardInterrupt:
            logger.info("⚠️ 收到停止信号")
            scheduler.shutdown()


async def main():
    """主函数"""
    crawler = MonitorCrawler('config/config.json')
    
    try:
        await crawler.initialize()
        
        # 首次立即执行一轮
        logger.info("🚀 开始首次监控...")
        await crawler.run_all_targets()
        
        # 启动定时调度
        logger.info("🔄 切换到定时模式...")
        await crawler.start_scheduler()
    
    finally:
        await crawler.cleanup()


if __name__ == '__main__':
    asyncio.run(main())

1️⃣1️⃣ Web监控面板实现

11.1 Flask后端API

python 复制代码
# dashboard/app.py - Web监控面板

from flask import Flask, render_template, jsonify, request
from datetime import datetime, timedelta
import sqlite3
from pathlib import Path
import json
from typing import Dict, List

app = Flask(__name__)

class DashboardAPI:
    """监控面板API"""
    
    def __init__(self, db_path: str = 'data/db/monitor.db'):
        self.db_path = db_path
    
    def get_db_connection(self):
        """获取数据库连接"""
        conn = sqlite3.connect(self.db_path)
        conn.row_factory = sqlite3.Row  # 返回字典格式
        return conn
    
    def get_overview_stats(self) -> Dict:
        """获取总览统计"""
        conn = self.get_db_connection()
        cursor = conn.cursor()
        
        # 最近24小时的统计
        yesterday = (datetime.now() - timedelta(hours=24)).isoformat()
        
        # 总爬取次数
        cursor.execute(
            'SELECT COUNT(*) as total FROM craw WHERE timestamp > ?',
            (yesterday,)
        )
        total_crawls = cursor.fetchone()['total']
        
        # 成功率
        cursor.execute(
            'SELECT COUNT(*) as success FROM crawl_results WHERE timestamp > ? AND status_code = 200',
            (yesterday,)
        )
        success_count = cursor.fetchone()['success']
        success_rate = success_count / total_crawls if total_crawls > 0 else 0
        
        # 平均命中率
        cursor.execute(
            'SELECT AVG(hit_rate) as avg_hit_rate FROM crawl_metrics WHERE timestamp > ?',
            (yesterday,)
        )
        avg_hit_rate = cursor.fetchone()['avg_hit_rate'] or 0
        
        # 活跃告警数
        cursor.execute(
            'SELECT COUNT(*) as active_alerts FROM alerts WHERE resolved = 0'
        )
        active_alerts = cursor.fetchone()['active_alerts']
        
        conn.close()
        
        return {
            'total_crawls': total_crawls,
            'success_rate': success_rate,
            'avg_hit_rate': avg_hit_rate,
            'active_alerts': active_alerts,
            'update_time': datetime.now().isoformat()
        }
    
    def get_hit_rate_trend(self, task_name: str = None, hours: int = 24) -> List[Dict]:
        """获取命中率趋势数据"""
        conn = self.get_db_connection()
        cursor = conn.cursor()
        
        cutoff = (datetime.now() - timedelta(hours=hours)).isoformat()
        
        if task_name:
            cursor.execute('''
                SELECT timestamp, hit_rate, task_name
                FROM crawl_metrics
                WHERE timestamp > ? AND task_name = ?
                ORDER BY timestamp
            ''', (cutoff, task_name))
        else:
            cursor.execute('''
                SELECT timestamp, hit_rate, task_name
                FROM crawl_metrics
                WHERE timestamp > ?
                ORDER BY timestamp
            ''', (cutoff,))
        
        rows = cursor.fetchall()
        conn.close()
        
        return [dict(row) for row in rows]
    
    def get_recent_alerts(self, limit: int = 10) -> List[Dict]:
        """获取最近的告警"""
        conn = self.get_db_connection()
        cursor = conn.cursor()
        
        cursor.execute('''
            SELECT * FROM alerts
            ORDER BY timestamp DESC
            LIMIT ?
        ''', (limit,))
        
        rows = cursor.fetchall()
        conn.close()
        
        return [dict(row) for row in rows]
    
    def get_screenshots(self, task_name: str, date: str = None) -> List[Dict]:
        """获取截图列表"""
        if date is None:
            date = datetime.now().strftime('%Y-%m-%d')
        
        screenshot_dir = Path(f'data/screenshots/{date}')
        
        if not screenshot_dir.exists():
            return []
        
        screenshots = []
        for img_file in screenshot_dir.glob(f'{task_name}*.png'):
            # 读取元数据
            meta_file = img_file.with_name(img_file.stem + '_meta.json')
            metadata = {}
            
            if meta_file.exists():
                with open(meta_file) as f:
                    metadata = json.load(f)
            
            screenshots.append({
                'filename': img_file.name,
                'path': str(img_file),
                'url': f'/screenshots/{date}/{img_file.name}',
                'metadata': metadata
            })
        
        return sorted(screenshots, key=lambda x: x['filename'], reverse=True)


# 创建API实例
api = DashboardAPI()


# ========== Flask 路由 ==========

@app.route('/')
def index():
    """监控面板首页"""
    return render_template('dashboard.html')


@app.route('/api/overview')
def api_overview():
    """总览数据API"""
    stats = api.get_overview_stats()
    return jsonify(stats)


@app.route('/api/trend')
def api_trend():
    """命中率趋势API"""
    task_name = request.args.get('task')
    hours = int(request.args.get('hours', 24))
    
    data = api.get_hit_rate_trend(task_name, hours)
    return jsonify(data)


@app.route('/api/alerts')
def api_alerts():
    """告警列表API"""
    limit = int(request.args.get('limit', 10))
    alerts = api.get_recent_alerts(limit)
    return jsonify(alerts)


@app.route('/api/screenshots/<task_name>')
def api_screenshots(task_name):
    """截图列表API"""
    date = request.args.get('date')
    screenshots = api.get_screenshots(task_name, date)
    return jsonify(screenshots)


@app.route('/screenshots/<date>/<filename>')
def serve_screenshot(date, filename):
    """返回截图文件"""
    from flask import send_file
    filepath = Path(f'data/screenshots/{date}/{filename}')
    
    if filepath.exists():
        return send_file(filepath, mimetype='image/png')
    else:
        return '截图不存在', 404


if __name__ == '__main__':
    app.run(debug=True, host='0.0.0.0', port=5000)

11.2 前端页面(HTML + ECharts)

html 复制代码
<!-- dashboard/templates/dashboard.html -->

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>爬虫监控面板</title>
    
    <!-- 引入 ECharts -->
    <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
    
    <style>
        * {
            margin: 0;
            padding: 0;
            box-sizing: border-box;
        }
        
        body {
            font-family: 'Segoe UI', Arial, sans-serif;
            background: #f5f7fa;
            padding: 20px;
        }
        
        .header {
            background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
            color: white;
            padding: 30px;
            border-radius: 10px;
            margin-bottom: 30px;
        }
        
        .header h1 {
            font-size: 32px;
            margin-bottom: 10px;
        }
        
        .stats-grid {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
            gap: 20px;
            margin-bottom: 30px;
        }
        
        .stat-card {
            background: white;
            padding: 25px;
            border-radius: 10px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
        }
        
        .stat-card .label {
            color: #666;
            font-size: 14px;
            margin-bottom: 10px;
        }
        
        .stat-card .value {
            font-size: 32px;
            font-weight: bold;
            color: #333;
        }
        
        .stat-card .trend {
            font-size: 14px;
            margin-top: 10px;
        }
        
        .trend.up { color: #52c41a; }
        .trend.down { color: #f5222d; }
        
        .chart-container {
            background: white;
            padding: 25px;
            border-radius: 10px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
            margin-bottom: 30px;
        }
        
        .chart-container h2 {
            margin-bottom: 20px;
            color: #333;
        }
        
        #hitRateChart {
            width: 100%;
            height: 400px;
        }
        
        .alerts-container {
            background: white;
            padding: 25px;
            border-radius: 10px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
        }
        
        .alert-item {
            padding: 15px;
            border-left: 4px solid #f5222d;
            background: #fff1f0;
            margin-bottom: 10px;
            border-radius: 4px;
        }
        
        .alert-item.warning {
            border-left-color: #faad14;
            background: #fffbe6;
        }
        
        .alert-item.info {
            border-left-color: #1890ff;
            background: #e6f7ff;
        }
        
        .alert-item .title {
            font-weight: bold;
            margin-bottom: 5px;
        }
        
        .alert-item .time {
            color: #999;
            font-size: 12px;
        }
        
        .screenshots-grid {
            display: grid;
            grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
            gap: 20px;
            margin-top: 20px;
        }
        
        .screenshot-card {
            background: white;
            border-radius: 10px;
            overflow: hidden;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
        }
        
        .screenshot-card img {
            width: 100%;
            height: 200px;
            object-fit: cover;
        }
        
        .screenshot-card .info {
            padding: 15px;
        }
    </style>
</head>
<body>
    <!-- 头部 -->
    <div class="header">
        <h1>🕷️ 爬虫监控面板</h1>
        <p>实时监控爬虫运行状态、数据质量和结构变化</p>
    </div>
    
    <!-- 统计卡片 -->
    <div class="stats-grid">
        <div class="stat-card">
            <div class="label">24小时爬取次数</div>
            <div class="value" id="totalCrawls">-</div>
            <div class="trend">与昨天对比</div>
        </div>
        
        <div class="stat-card">
            <div class="label">成功率</div>
            <div class="value" id="successRate">-</div>
            <div class="trend up">正常运行</div>
        </div>
        
        <div class="stat-card">
            <div class="label">平均命中率</div>
            <div class="value" id="avgHitRate">-</div>
            <div class="trend" id="hitRateTrend">-</div>
        </div>
        
        <div class="stat-card">
            <div class="label">活跃告警</div>
            <div class="value" id="activeAlerts">-</div>
            <div class="trend">待处理</div>
        </div>
    </div>
    
    <!-- 命中率趋势图 -->
    <div class="chart-container">
        <h2>📈 命中率趋势(最近24小时)</h2>
        <div id="hitRateChart"></div>
    </div>
    
    <!-- 最近告警 -->
    <div class="alerts-container">
        <h2>🚨 最近告警</h2>
        <div id="alertsList"></div>
    </div>
    
    <script>
        // ========== 数据更新 ==========
        
        async function updateOverview() {
            const response = await fetch('/api/overview');
            const data = await response.json();
            
            document.getElementById('totalCrawls').textContent = data.total_crawls;
            document.getElementById('successRate').textContent = (data.success_rate * 100).toFixed(1) + '%';
            document.getElementById('avgHitRate').textContent = (data.avg_hit_rate * 100).toFixed(1) + '%';
            document.getElementById('activeAlerts').textContent = data.active_alerts;
        }
        
        async function updateTrendChart() {
            const response = await fetch('/api/trend?hours=24');
            const data = await response.json();
            
            // 处理数据
            const timestamps = data.map(d => new Date(d.timestamp).toLocaleTimeString('zh-CN', {hour: '2-digit', minute: '2-digit'}));
            const hitRates = data.map(d => (d.hit_rate * 100).toFixed(1));
            
            // 初始化 ECharts
            const chart = echarts.init(document.getElementById('hitRateChart'));
            
            const option = {
                tooltip: {
                    trigger: 'axis',
                    formatter: '{b}<br/>命中率: {c}%'
                },
                xAxis: {
                    type: 'category',
                    data: timestamps,
                    axisLabel: {
                        rotate: 45
                    }
                },
                yAxis: {
                    type: 'value',
                    min: 0,
                    max: 100,
                    axisLabel: {
                        formatter: '{value}%'
                    }
                },
                series: [{
                    data: hitRates,
                    type: 'line',
                    smooth: true,
                    areaStyle: {
                        color: 'rgba(102, 126, 234, 0.1)'
                    },
                    lineStyle: {
                        color: '#667eea',
                        width: 2
                    },
                    itemStyle: {
                        color: '#667eea'
                    },
                    // 标记警戒线
                    markLine: {
                        data: [
                            {yAxis: 80, name: '警戒线', lineStyle: {color: '#faad14'}},
                            {yAxis: 50, name: '严重线', lineStyle: {color: '#f5222d'}}
                        ]
                    }
                }],
                grid: {
                    left: '3%',
                    right: '4%',
                    bottom: '15%',
                    containLabel: true
                }
            };
            
            chart.setOption(option);
        }
        
        async function updateAlerts() {
            const response = await fetch('/api/alerts?limit=5');
            const alerts = await response.json();
            
            const container = document.getElementById('alertsList');
            
            if (alerts.length === 0) {
                container.innerHTML = '<p style="color: #52c41a;">✅ 暂无告警</p>';
                return;
            }
            
            container.innerHTML = alerts.map(alert => `
                <div class="alert-item ${alert.severity}">
                    <div class="title">${alert.title}</div>
                    <div>${alert.message}</div>
                    <div class="time">${new Date(alert.timestamp).toLocaleString('zh-CN')}</div>
                </div>
            `).join('');
        }
        
        // ========== 定时刷新 ==========
        
        function refreshAll() {
            updateOverview();
            updateTrendChart();
            updateAlerts();
        }
        
        // 初始加载
        refreshAll();
        
        // 每30秒刷新一次
        setInterval(refreshAll, 30000);
    </script>
</body>
</html>

1️⃣2️⃣ 常见问题与排错(FAQ)

Q1: 指纹对比一直显示"结构变化",但网站并没有改版?

原因分析:

  1. 动态内容影响:网站有广告、推荐位等动态模块
  2. 时间戳变化:页面包含"最后更新时间"等变化字段
  3. A/B测试:网站在做灰度测试,不同用户看到不同版本

解决方案:

python 复制代码
# 改进:在生成指纹时排除动态区域

class StructureDetector:
    def __init__(self, target_xpaths: Dict[str, str], ignore_selectors: List[str] = None):
        """
        ignore_selectors: 要忽略的CSS选择器
        例如: ['.ad-banner', '#recommendations', '.timestamp']
        """
        self.target_xpaths = target_xpaths
        self.ignore_selectors = ignore_selectors or []
    
    def generate_fingerprint(self, html: str, url: str) -> DOMFingerprint:
        tree = etree.HTML(html)
        
        # 移除动态区

**数据对比:**
- 每张截图:1-3 MB
- 每天4次监控 × 10个目标 = 40张 = 80 MB/天
- 30天 = 2.4 GB

**优化策略:**

```python
# 方案1: 启用截图压缩
from PIL import Image

def compress_screenshot(input_path: str, output_path: str, quality: int = 60):
    """压缩截图,可节省70%空间"""
    img = Image.open(input_path)
    img.save(output_path, 'JPEG', quality=quality, optimize=True)

# 方案2: 只在检测到变化时截图
async def capture_page(...):
    # 先检查指纹
    if structure_changed:
        # 结构变化了才截图
        await screenshot_archiver.capture_page(...)

# 方案3: 自动清理策略(已在ScreenshotArchiver中实现)
archiver.cleanup_old_archives(keep_days=7)  # 只保留7天

Q3: 告警太频繁,每小时收到几十封邮件

原因:阈值设置不合理

调优建议:

python 复制代码
# 不良实践:阈值太严格
condition=lambda m: m.hit_rate < 0.95  # 命中率<95%就告警 ❌

# 最佳实践:分级告警
# critical: 命中率<50%(立即处理)
rule_engine.add_rule(
    condition=lambda m: m.hit_rate < 0.5,
    severity='critical'
)

# warning: 命中率50%-80%(24小时内处理)
rule_engine.add_rule(
    condition=lambda m: 0.5 <= m.hit_rate < 0.8,
    severity='warning'
)

# 同时增加去重窗口
alert_config = {
    'dedup_window': 7200  # 2小时内相同告警只发一次
}

Q4: Playwright截图时浏览器崩溃

错误日志:

复制代码
playwright._impl._api_types.Error: Browser closed

原因:

  1. 内存不足(Chromium很吃内存)
  2. 页面加载超时
  3. 并发打开太多页面

解决方案:

python 复制代码
# 方案1: 限制并发截图数
screenshot_semaphore = asyncio.Semaphore(2)  # 同时最多2个截图任务

async def capture_with_limit(url):
    async with screenshot_semaphore:
        await archiver.capture_page(url)

# 方案2: 设置页面超时
page.set_default_timeout(15000)  # 15秒超时

# 方案3: 定期重启浏览器
async def restart_browser_periodically():
    while True:
        await asyncio.sleep(3600)  # 每小时
        await archiver.browser.close()
        archiver.browser = await archiver.playwright.chromium.launch()

1️⃣3️⃣ 性能优化与最佳实践

13.1 性能优化清单

优化项 优化前 优化后 提升
截图方式 每次全页截图 仅关键区域 速度提升60%
HTML保存 原始HTML gzip压缩 节省80%空间
指纹计算 每次全量计算 缓存+增量 速度提升75%
数据库查询 无索引 添加索引 速度提升90%

13.2 数据库优化

sql 复制代码
-- 为常用查询添加索引
CREATE INDEX idx_timestamp ON crawl_metrics(timestamp);
CREATE INDEX idx_task_timestamp ON crawl_metrics(task_name, timestamp);
CREATE INDEX idx_alert_severity ON alerts(severity, resolved);

-- 定期清理旧数据(保留90天)
DELETE FROM crawl_metrics WHERE timestamp < datetime('now', '-90 days');

13.3 最佳实践总结

1. 监控频率设置原则:

python 复制代码
# 快速变化的数据:电商价格、库存
interval = 3600  # 每小时

# 缓慢变化的数据:新闻列表、博客文章
interval = 21600  # 每6小时

# 极少变化的数据:政策文件、法律条文
interval = 86400  # 每天

2. 告警优先级矩阵:

场景 命中率 XPath有效率 优先级 处理时限
正常运行 >90% 100% - -
轻微异常 80-90% 100% info 3天
警告 50-80% 80-100% warning 1天
严重 <50% <80% critical 立即

3. 截图归档策略:

python 复制代码
# 普通监控:每天保存1张代表性截图
if datetime.now().hour == 14:  # 每天下午2点
    await capture_page()

# 检测到变化:立即截图
if structure_changed:
    await capture_page()

# 告警触发:保留证据
if metrics.hit_rate < 0.5:
    await capture_page()

1️⃣4️⃣ 总结与延伸阅读

我们构建了什么?

通过这篇文章,你已经掌握了一套完整的生产级监控型爬虫系统

智能检测层

  • DOM结构指纹技术,自动检测网站改版
  • 多维度指标计算(命中率、完整性、一致性)
  • 异常检测算法(基于统计学的2σ原则)

证据归档层

  • Playwright自动截图,保留历史快照
  • HTML压缩归档,节省80%存储空间
  • 截图对比算法,量化页面变化程度

告警系统

  • 多通道告警(邮件、企业微信)
  • 智能去重机制,避免告警风暴
  • 分级处理(critical/warning/info)

可视化面板

  • 实时监控命中率趋势
  • 告警历史查询
  • 截图时光机(历史快照轮播)

代码量统计

模块 代码行数 注释率
结构检测 450行 35%
截图归档 380行 30%
指标计算 320行 40%
告警系统 410行 35%
Web面板 280行 20%
总计 ~2000行 32%

与普通爬虫的对比

维度 普通爬虫 监控型爬虫
目标 一次性采集数据 长期监控变化
运行方式 手动触发 定时自动
失败处理 日志记录 主动告警
数据质量 不关注 实时监控
证据留存 截图+HTML归档
适用场景 数据分析 价格监控、竞品分析

下一步可以做什么?

1. 高级功能扩展:

  • 智能修复:检测到XPath失效时,自动尝试相似路径
  • 分布式监控:用Redis做任务队列,多机协同
  • 机器学习:用LSTM预测价格走势,提前告警

2. 接入更多数据源:

  • 社交媒体:监控微博/Twitter的品牌舆情
  • 新闻站:追踪竞品的新闻报道
  • 政府网站:监控政策变化

3. 商业化应用:

  • 价格监控SaaS:为电商卖家提供竞品价格追踪
  • 内容监控服务:为品牌方监控侵权内容
  • 招投标信息监控:自动抓取政府采购网

推荐阅读

🎬 结语

监控型爬虫不仅仅是技术工具,更是业务洞察的眼睛。在我的实践中,它帮助我们:

  • 提前3天发现竞品的促销策略
  • 在网站改版后15分钟内收到告警并修复
  • 通过历史截图证据,成功申诉了一起价格欺诈

但请记住:技术是中立的,使用者的态度决定了它的价值。 监控型爬虫应该用于:

  • ✅ 维护消费者权益(价格证据、虚假宣传举证)
  • ✅ 正当商业竞争(公开数据的市场分析)
  • ✅ 内部系统监控(自己网站的稳定性监控)

而不应用于:

  • ❌ 窃取商业机密
  • ❌ 恶意诋毁竞争对手
  • ❌ 侵犯用户隐私

希望这篇文章能帮助你构建出真正有价值的监控系统!如果有任何问题,欢迎交流 💬

🌟 文末

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

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

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

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

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

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

✅ 互动征集

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

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


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

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

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


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

相关推荐
深蓝电商API2 小时前
爬虫中 Cookie 池维护与自动刷新
爬虫·python
蜡笔羊驼2 小时前
LALIC环境安装过程
开发语言·python·深度学习
Starry_hello world2 小时前
Python(1)
python
codeJinger2 小时前
【Python】基础知识
开发语言·python
一切顺势而行2 小时前
python 文件目录操作
java·前端·python
woshikejiaih3 小时前
2026年阅读软件Top5避坑攻略:告别卡顿闪退提升沉浸感
人工智能·python
007张三丰3 小时前
2026马年开年寄语
python·ai工具·祝福·新技术·新年·马年
zlpzpl3 小时前
Java总结进阶之路 (基础二 )
java·开发语言·python
喵手3 小时前
Python爬虫实战:开放数据多格式入仓 - 构建统一数据管道(附CSV导出 + SQLite持久化存储)!
爬虫·python·爬虫实战·零基础python爬虫教学·csv导出·开放数据多格式·统一数据管道