Python爬虫实战:网页截图归档完全指南 - 构建生产级页面存证与历史回溯系统!

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

㊙️本期爬虫难度指数:⭐⭐⭐

🉐福利: 一次订阅后,专栏内的所有文章可永久免费看,持续更新中,保底1000+(篇)硬核实战内容。

全文目录:

    • [🌟 开篇语](#🌟 开篇语)
    • [📌 摘要(Abstract)](#📌 摘要(Abstract))
    • [🎯 背景与需求(Why)](#🎯 背景与需求(Why))
    • [🔧 核心实现:HTML 快照归档系统](#🔧 核心实现:HTML 快照归档系统)
      • [1. HTML 归档器(core/html_archiver.py)](#1. HTML 归档器(core/html_archiver.py))
    • [🔄 版本控制与差异对比系统](#🔄 版本控制与差异对比系统)
      • [3. Git式版本管理器(core/version_manager.py)](#3. Git式版本管理器(core/version_manager.py))
      • [4. HTML差异对比器(core/html_diff.py)](#4. HTML差异对比器(core/html_diff.py))
    • [🕰️ 时光机Web界面实现](#🕰️ 时光机Web界面实现)
      • [5. Flask时光机应用(web/timemachine_app.py)](#5. Flask时光机应用(web/timemachine_app.py))
      • [6. 前端时间轴页面(web/templates/timeline.html)](#6. 前端时间轴页面(web/templates/timeline.html))
    • [💾 存储优化与扩展性](#💾 存储优化与扩展性)
      • [7. 对象存储集成(storage/s3_storage.py)](#7. 对象存储集成(storage/s3_storage.py))
    • [⚖️ 合规存证与法律证据链](#⚖️ 合规存证与法律证据链)
      • [8. 数字签名与时间戳(compliance/evidence_chain.py)](#8. 数字签名与时间戳(compliance/evidence_chain.py))
    • [📊 完整实战示例](#📊 完整实战示例)
      • [9. 自动化归档调度器(examples/auto_archiver.py)](#9. 自动化归档调度器(examples/auto_archiver.py))
    • [📚 总结与最佳实践](#📚 总结与最佳实践)
    • [🌟 文末](#🌟 文末)
      • [✅ 专栏持续更新中|建议收藏 + 订阅](#✅ 专栏持续更新中|建议收藏 + 订阅)
      • [✅ 互动征集](#✅ 互动征集)
      • [✅ 免责声明](#✅ 免责声明)

🌟 开篇语

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

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

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

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

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

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

📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅专栏👉《Python爬虫实战》👈,一次订阅后,专栏内的所有文章可永久免费阅读,持续更新中。

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

📌 摘要(Abstract)

本文构建一套完整的网页归档系统,实现对目标网页的自动化快照保存、多格式截图、版本管理、差异对比、时光机回溯等核心功能。通过 HTML 源码归档、DOM 结构化存储、全页面/元素级截图、图像指纹对比、Git 式版本管理等技术手段,打造一个可追溯、可审计、可复现的页面历史档案库。从法律存证到开发调试,从数据分析到用户体验优化,全方位覆盖网页归档的应用场景。

读完本文你将获得:

  • 深入理解网页归档的核心技术栈(Playwright、BeautifulSoup、ImageHash)
  • 掌握多格式快照保存(HTML、MHTML、PDF、PNG、WebP)
  • 学会实现 Git 式版本控制与差异对比
  • 构建高效的存储架构(文件系统 + 对象存储 + 数据库)
  • 实现智能去重与增量归档
  • 打造可视化时光机界面
  • 掌握合规存证与法律证据链构建

🎯 背景与需求(Why)

为什么需要网页归档?

场景1:法律存证与合规审计

json 复制代码
电商平台需要保存商品页面快照
↓
消费者投诉商品描述不符
↓
调取归档快照,证明商品页面在某时刻的状态
↓
用于司法诉讼或监管审查

场景2:内容变更追踪

json 复制代码
竞品网站的产品定价、功能描述
↓
每日自动归档,跟踪价格波动
↓
生成竞品分析报告
↓
辅助商业决策

场景3:数据采集验证

json 复制代码
爬虫抽取到异常数据
↓
需要查看当时的页面原貌
↓
调取归档的 HTML 快照
↓
对比规则与实际页面,定位问题

场景4:网页设计演进分析

json 复制代码
保存网站每次改版的截图
↓
生成时间轴视图
↓
分析 UI/UX 设计的演变历程
↓
用于产品复盘或设计参考

场景5:舆情监控与应急响应

json 复制代码
新闻网站突然删除某篇报道
↓
归档系统已自动保存快照
↓
可还原被删除的内容
↓
用于舆情分析或新闻溯源

核心功能需求

功能模块 具体需求 技术挑战
HTML 快照 保存完整源码,支持压缩 大文件存储、去重
截图归档 全页面/元素级/多视口 动态内容渲染、性能优化
版本管理 Git 式提交、分支、回滚 差异算法、存储效率
差异对比 文本级/DOM级/视觉级 算法精度、性能优化
时光机 日历视图、时间轴浏览 大数据量查询、UI 交互
智能去重 内容指纹、相似度检测 准确率与召回率平衡
合规存证 时间戳、数字签名、证据链 法律效力保障

技术选型对比

方案 优势 劣势 适用场景
Wayback Machine API 现成服务、数据丰富 无法自定义、有限制 公开网页归档
Archive.today 简单易用 无 API、不可控 临时存证
Selenium + 文件存储 实现简单 性能差、无版本控制 小规模归档
Playwright + Git + S3 高性能、可扩展 实现复杂 生产级系统(本文选择)
专业工具(Heritrix) 功能全面 学习成本高、配置复杂 大规模网站爬取

目标架构图

json 复制代码
┌─────────────────────────────────────────────────────────────────┐
│                     网页归档系统架构                             │
│                                                                   │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐          │
│  │ 采集调度层    │  │ 存储处理层    │  │ 查询服务层    │          │
│  │              │  │              │  │              │          │
│  │ - 定时任务   │─>│ - HTML 归档  │─>│ - 时光机API  │          │
│  │ - URL 队列   │  │ - 截图归档   │  │ - 差异对比   │          │
│  │ - 优先级调度 │  │ - 版本管理   │  │ - 全文搜索   │          │
│  └──────────────┘  └──────────────┘  └──────────────┘          │
│         │                  │                  │                  │
│         ▼                  ▼                  ▼                  │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐          │
│  │ 渲染引擎层    │  │ 存储架构层    │  │ 前端界面层    │          │
│  │              │  │              │  │              │          │
│  │ - Playwright │  │ - 文件系统   │  │ - 时间轴视图 │          │
│  │ - 多浏览器   │  │ - 对象存储   │  │ - 差异高亮   │          │
│  │ - 代理池     │  │ - MongoDB    │  │ - 截图对比   │          │
│  └──────────────┘  └──────────────┘  └──────────────┘          │
└─────────────────────────────────────────────────────────────────┘

🔧 核心实现:HTML 快照归档系统

1. HTML 归档器(core/html_archiver.py)

python 复制代码
"""
HTML 快照归档器
核心功能:HTML 源码保存、压缩存储、元数据管理、版本控制

存储格式选择:
1. 原始 HTML:便于查看,但占用空间大
2. Gzip 压缩:节省空间 70-90%,解压速度快
3. MHTML:包含资源文件,但兼容性差
4. PDF:视觉保真,但无法提取 DOM
"""
import os
import gzip
import hashlib
import mimetypes
from pathlib import Path
from datetime import datetime
from typing import Dict, Optional, List, Tuple
from urllib.parse import urlparse
import json
from bs4 import BeautifulSoup
from utils.logger import logger


class HTMLArchiver:
    """
    HTML 归档器
    
    功能:
    1. 保存 HTML 源码(支持压缩)
    2. 提取并保存静态资源(CSS、JS、图片)
    3. 生成元数据(时间、URL、哈希值等)
    4. 支持版本化存储(Git 式提交)
    5. 智能去重(基于内容哈希)
    """
    
    def __init__(
        self,
        base_dir: str = "data/archives",
        compress: bool = True,
        save_resources: bool = False
    ):
        """
        初始化归档器
        
        Args:
            base_dir: 归档根目录
            compress: 是否压缩存储(推荐开启)
            save_resources: 是否同时保存静态资源(CSS/JS/图片)
        """
        self.base_dir = Path(base_dir)
        self.compress = compress
        self.save_resources = save_resources
        
        # 创建目录结构
        self._init_directories()
    
    def _init_directories(self):
        """
        初始化目录结构
        
        目录组织方式:
        data/archives/
        ├── html/              # HTML 文件存储
        │   ├── 2026/
        │   │   ├── 02/
        │   │   │   ├── 04/
        │   │   │   │   ├── example.com_abc123.html.gz
        │   │   │   │   └── example.com_abc123.meta.json
        ├── resources/         # 静态资源存储
        │   ├── css/
        │   ├── js/
        │   └── images/
        └── metadata/          # 全局元数据索引
            └── index.db
        """
        subdirs = ['html', 'resources/css', 'resources/js', 'resources/images', 'metadata']
        for subdir in subdirs:
            (self.base_dir / subdir).mkdir(parents=True, exist_ok=True)
    
    def archive(
        self,
        url: str,
        html_content: str,
        metadata: Optional[Dict] = None,
        force_save: bool = False
    ) -> Dict:
        """
        归档 HTML 页面
        
        Args:
            url: 页面 URL
            html_content: HTML 内容
            metadata: 额外的元数据(可选)
            force_save: 是否强制保存(即使内容未变化)
        
        Returns:
            归档信息:{
                'archive_id': 'abc123',
                'filepath': '/path/to/file.html.gz',
                'hash': 'sha256_hash',
                'size': 12345,
                'compressed_size': 3456,
                'is_duplicate': False,
                'timestamp': datetime
            }
        """
        # 1. 计算内容哈希(用于去重和版本识别)
        content_hash = self._calculate_hash(html_content)
        
        # 2. 检查是否重复(基于内容哈希)
        if not force_save:
            existing = self._find_by_hash(url, content_hash)
            if existing:
                logger.info(f"检测到重复内容,跳过保存: {url}")
                return {
                    **existing,
                    'is_duplicate': True,
                    'timestamp': datetime.now()
                }
        
        # 3. 生成文件路径(按日期组织)
        now = datetime.now()
        date_path = now.strftime('%Y/%m/%d')
        
        # 从 URL 生成文件名
        domain = urlparse(url).netloc.replace(':', '_')
        timestamp_str = now.strftime('%H%M%S')
        filename_base = f"{domain}_{content_hash[:8]}_{timestamp_str}"
        
        # 4. 构建完整路径
        archive_dir = self.base_dir / 'html' / date_path
        archive_dir.mkdir(parents=True, exist_ok=True)
        
        # 文件扩展名
        ext = '.html.gz' if self.compress else '.html'
        html_filepath = archive_dir / f"{filename_base}{ext}"
        meta_filepath = archive_dir / f"{filename_base}.meta.json"
        
        # 5. 保存 HTML 内容
        original_size = len(html_content.encode('utf-8'))
        
        if self.compress:
            # Gzip 压缩保存
            with gzip.open(html_filepath, 'wt', encoding='utf-8') as f:
                f.write(html_content)
            compressed_size = html_filepath.stat().st_size
            logger.debug(f"压缩率: {(1 - compressed_size/original_size)*100:.1f}%")
        else:
            # 原始保存
            html_filepath.write_text(html_content, encoding='utf-8')
            compressed_size = original_size
        
        # 6. 提取并保存静态资源(可选)
        resource_info = {}
        if self.save_resources:
            resource_info = self._save_resources(html_content, url, content_hash)
        
        # 7. 生成元数据
        archive_metadata = {
            'archive_id': content_hash[:16],
            'url': url,
            'archived_at': now.isoformat(),
            'hash': content_hash,
            'size': original_size,
            'compressed_size': compressed_size,
            'compression_ratio': compressed_size / original_size if original_size > 0 else 0,
            'filepath': str(html_filepath.relative_to(self.base_dir)),
            'resources': resource_info,
            'custom_metadata': metadata or {}
        }
        
        # 8. 保存元数据文件
        meta_filepath.write_text(json.dumps(archive_metadata, indent=2, ensure_ascii=False))
        
        # 9. 更新全局索引(用于快速查询)
        self._update_index(url, archive_metadata)
        
        logger.success(f"HTML 已归档: {url} -> {html_filepath}")
        
        return {
            **archive_metadata,
            'is_duplicate': False,
            'timestamp': now
        }
    
    def _calculate_hash(self, content: str) -> str:
        """
        计算内容 SHA256 哈希值
        
        Args:
            content: 内容字符串
        
        Returns:
            SHA256 哈希字符串(64位)
        """
        return hashlib.sha256(content.encode('utf-8')).hexdigest()
    
    def _find_by_hash(self, url: str, content_hash: str) -> Optional[Dict]:
        """
        根据内容哈希查找已归档的版本
        
        Args:
            url: URL
            content_hash: 内容哈希
        
        Returns:
            归档信息(如果存在)
        """
        # 从索引文件中查询
        index_file = self.base_dir / 'metadata' / f"{self._url_to_key(url)}.index.json"
        
        if not index_file.exists():
            return None
        
        index_data = json.loads(index_file.read_text())
        
        # 查找匹配的哈希
        for record in index_data.get('versions', []):
            if record['hash'] == content_hash:
                return record
        
        return None
    
    def _url_to_key(self, url: str) -> str:
        """
        将 URL 转换为文件名安全的键
        
        Args:
            url: URL
        
        Returns:
            文件名安全的键
        """
        return hashlib.md5(url.encode()).hexdigest()
    
    def _update_index(self, url: str, archive_metadata: Dict):
        """
        更新全局索引文件
        
        索引文件格式:
        {
            "url": "https://example.com/page",
            "first_archived": "2026-02-04T10:00:00",
            "last_archived": "2026-02-04T15:30:00",
            "total_versions": 5,
            "versions": [
                {
                    "archive_id": "abc123",
                    "archived_at": "2026-02-04T15:30:00",
                    "hash": "sha256_hash",
                    "filepath": "html/2026/02/04/example.com_abc123.html.gz"
                },
                ...
            ]
        }
        
        Args:
            url: URL
            archive_metadata: 归档元数据
        """
        index_file = self.base_dir / 'metadata' / f"{self._url_to_key(url)}.index.json"
        
        if index_file.exists():
            index_data = json.loads(index_file.read_text())
        else:
            index_data = {
                'url': url,
                'first_archived': archive_metadata['archived_at'],
                'versions': []
            }
        
        # 添加新版本
        index_data['versions'].append({
            'archive_id': archive_metadata['archive_id'],
            'archived_at': archive_metadata['archived_at'],
            'hash': archive_metadata['hash'],
            'filepath': archive_metadata['filepath'],
            'size': archive_metadata['size']
        })
        
        # 更新统计信息
        index_data['last_archived'] = archive_metadata['archived_at']
        index_data['total_versions'] = len(index_data['versions'])
        
        # 按时间倒序排列(最新的在前)
        index_data['versions'].sort(key=lambda x: x['archived_at'], reverse=True)
        
        # 保存索引
        index_file.write_text(json.dumps(index_data, indent=2, ensure_ascii=False))
    
    def _save_resources(self, html_content: str, base_url: str, archive_id: str) -> Dict:
        """
        提取并保存 HTML 中引用的静态资源
        
        提取的资源:
        1. CSS 文件(<link rel="stylesheet">)
        2. JavaScript 文件(<script src="">)
        3. 图片(<img src="">)
        4. 内联样式和脚本(可选)
        
        Args:
            html_content: HTML 内容
            base_url: 页面 URL(用于解析相对路径)
            {
                'css': [...', 'filepath': '...'}],
                'js': [...],
                'images': [...]
            }
        """
        from urllib.parse import urljoin
        import requests
        
        soup = BeautifulSoup(html_content, 'lxml')
        resources = {'css': [], 'js': [], 'images': []}
        
        # 1. 提取 CSS
        for link in soup.find_all('link', rel='stylesheet'):
            href = link.get('href')
            if href:
                full_url = urljoin(base_url, href)
                try:
                    saved_path = self._download_resource(full_url, 'css', archive_id)
                    resources['css'].append({'url': full_url, 'filepath': saved_path})
                except Exception as e:
                    logger.warning(f"CSS 下载失败: {full_url}, {e}")
        
        # 2. 提取 JavaScript
        for script in soup.find_all('script', src=True):
            src = script.get('src')
            if src:
                full_url = urljoin(base_url, src)
                try:
                    saved_path = self._download_resource(full_url, 'js', archive_id)
                    resources['js'].append({'url': full_url, 'filepath': saved_path})
                except Exception as e:
                    logger.warning(f"JS 下载失败: {full_url}, {e}")
        
        # 3. 提取图片
        for img in soup.find_all('img', src=True):
            src = img.get('src')
            if src:
                full_url = urljoin(base_url, src)
                try:
                    saved_path = self._download_resource(full_url, 'images', archive_id)
                    resources['images'].append({'url': full_url, 'filepath': saved_path})
                except Exception as e:
                    logger.warning(f"图片下载失败: {full_url}, {e}")
        
        logger.info(f"资源提取完成: CSS={len(resources['css'])}, JS={len(resources['js'])}, Images={len(resources['images'])}")
        
        return resources
    
    def _download_resource(self, url: str, resource_type: str, archive_id: str) -> str:
        """
        下载并保存静态资源
        
        Args:
            url: 资源 URL
            resource_type: 
        
        Returns:
            保存的文件路径
        """
        import requests
        
        #于 URL 哈希)
        url_hash = hashlib.md5(url.encode()).hexdigest()[:16]
        
        # 获取文件扩展名
        ext = Path(urlparse(url).path).suffix or '.bin'
        
        filename = f"{archive_id}_{url_hash}{ext}"
        filepath = self.base_dir / 'resources' / resource_type / filename
        
        # 下载文件
        response = requests.get(url, timeout=10)
        response.raise_for_status()
        
        # 保存
        filepath.write_bytes(response.content)
        
        return str(filepath.relative_to(self.base_dir))
    
    def load(self, filepath: str) -> str:
        """
        加载归档的 HTML
        
        Args:
            filepath: 文件路径(相对或绝对)
        
        Returns:
            HTML 内容
        """
        full_path = self.base_dir / filepath if not Path(filepath).is_absolute() else Path(filepath)
        
        if str(full_path).endswith('.gz'):
            # Gzip 压缩文件
            with gzip.open(full_path, 'rt', encoding='utf-8') as f:
                return f.read()
        else:
            # 普通文件
            return full_path.read_text(encoding='utf-8')
    
    def get_versions(self, url: str, limit: Optional[int] = None) -> List[Dict]:
        """
        获取 URL 的所有归档版本
        
        Args:
            url: URL
            limit: 限制返回数量(None 表示全部)
        
        Returns:
            版本列表,按时间倒序
        """
        index_file = self.base_dir / 'metadata' / f"{self._url_to_key(url)}.index.json"
        
        if not index_file.exists():
            return []
        
        index_data = json.loads(index_file.read_text())
        versions = index_data.get('versions', [])
        
        if limit:
            return versions[:limit]
        
        return versions
    
    def get_version_by_id(self, archive_id: str) -> Optional[Dict]:
        """
        根据归档 ID 获取版本信息
        
        Args:
            archive_id: 归档 ID
        
        Returns:
            版本信息
        """
        # 搜索所有索引文件(效率较低,实际应使用数据库索引)
        for index_file in (self.base_dir / 'metadata').glob('*.index.json'):
            index_data = json.loads(index_file.read_text())
            for version in index_data.get('versions', []):
                if version['archive_id'] == archive_id:
                    # 加载完整元数据
                    meta_path = self.base_dir / version['filepath'].replace('.html.gz', '.meta.json').replace('.html', '.meta.json')
                    if meta_path.exists():
                        return json.loads(meta_path.read_text())
        
        return None
    
    def cleanup_old_archives(self, days: int = 90):
        """
        清理旧归档(保留最近 N 天)
        
        Args:
            days: 保留天数
        """
        from datetime import timedelta
        
        cutoff_date = datetime.now() - timedelta(days=days)
        removed_count = 0
        
        # 遍历所有年份目录
        html_dir = self.base_dir / 'html'
        for year_dir in html_dir.iterdir():
            if not year_dir.is_dir():
                continue
            
            for month_dir in year_dir.iterdir():
                if not month_dir.is_dir():
                    continue
                
                for day_dir in month_dir.iterdir():
                    if not day_dir.is_dir():
                        continue
                    
                    # 解析日期
                    try:
                        dir_date = datetime.strptime(
                            f"{year_dir.name}-{month_dir.name}-{day_dir.name}",
                            '%Y-%m-%d'
                        )
                        
                        if dir_date < cutoff_date:
                            # 删除目录
                            import shutil
                            shutil.rmtree(day_dir)
                            removed_count += 1
                            logger.info(f"删除过期归档: {day_dir}")
                    
                    except ValueError:
                        continue
        
        logger.success(f"清理完成,删除 {removed_count} 个过期目录")


# ========== 使用示例 ==========
if __name__ == '__main__':
    # 初始化归档器
    archiver = HTMLArchiver(
        base_dir="data/archives",
        compress=True,
        save_resources=True
    )
    
    # 归档页面
    html = """
    <!DOCTYPE html>
    <html>
    <head>
        <title>测试页面</title>
        <link rel="stylesheet" href="/static/style.css">
    </head>
    <body>
        <h1>欢迎</h1>
        <img src="/images/logo.png" alt="Logo">
    </body>
    </html>
    """
    
    result = archiver.archive(
        url="https://example.com/page1",
        html_content=html,
        metadata={'version': '1.0', 'author': 'admin'}
    )
    
    print(f"归档ID: {result['archive_id']}")
    print(f"文件路径: {result['filepath']}")
    print(f"压缩率: {(1-result['compression_ratio'])*100:.1f}%")
    
    # 查询版本历史
    versions = archiver.get_versions("https://example.com/page1")
    print(f"总共 {len(versions)} 个版本")
    
    # 加载历史版本
    if versions:
        old_html = archiver.load(versions[0]['filepath'])
        print(f"加载历史版本,长度: {len(old_html)}")

🔄 版本控制与差异对比系统

3. Git式版本管理器(core/version_manager.py)

python 复制代码
"""
Git式版本管理器
核心思想:借鉴 Git 的设计,实现网页快照的版本控制

概念映射:
- Commit: 一次页面归档
- Branch: 不同的监控策略(如每小时/每天)
- Tag: 重要的时间点标记
- Diff: 版本间的差异
- Merge: 合并不同来源的归档数据
"""
import json
from pathlib import Path
from datetime import datetime
from typing import Dict, List, Optional, Tuple
import hashlib
from dataclasses import dataclass, asdict
from utils.logger import logger


@dataclass
class Commit:
    """
    提交记录(类似 Git Commit)
    
    属性:
        commit_id: 提交ID(SHA-1)
        parent_id: 父提交ID(用于构建历史链)
        url: 页面 URL
        archive_id: 归档ID(指向实际文件)
        screenshot_id: 截图ID(可选)
        message: 提交说明
        author: 提交者
        timestamp: 提交时间
        tags: 标签列表
        metadata: 额外元数据
    """
    commit_id: str
    parent_id: Optional[str]
    url: str
    archive_id: str
    screenshot_id: Optional[str]
    message: str
    author: str
    timestamp: str
    tags: List[str]
    metadata: Dict


class VersionManager:
    """
    版本管理器
    
    功能:
    1. 提交新版本(commit)
    2. 查看历史记录(log)
    3. 版本对比(diff)
    4. 回滚到指定版本(checkout)
    5. 打标签(tag)
    6. 分支管理(branch)
    """
    
    def __init__(self, base_dir: str = "data/versions"):
        """
        初始化版本管理器
        
        Args:
            base_dir: 版本数据目录
        """
        self.base_dir = Path(base_dir)
        self._init_repository()
    
    def _init_repository(self):
        """
        初始化仓库结构
        
        目录结构:
        data/versions/
        ├── commits/         # 提交记录
        │   └── ab/
        │       └── cdef1234567890.json
        ├── refs/            # 引用(分支、标签)
        │   ├── heads/       # 分支
        │   │   └── main.ref
        │   └── tags/        # 标签
        │       └── v1.0.tag
        ├── index/           # URL 索引
        │   └── example.com_abc.index
        └── HEAD             # 当前分支指针
        """
        subdirs = ['commits', 'refs/heads', 'refs/tags', 'index']
        for subdir in subdirs:
            (self.base_dir / subdir).mkdir(parents=True, exist_ok=True)
        
        # 初始化 HEAD(指向 main 分支)
        head_file = self.base_dir / 'HEAD'
        if not head_file.exists():
            head_file.write_text('ref: refs/heads/main')
    
    def commit(
        self,
        url: str,
        archive_id: str,
        screenshot_id: Optional[str] = None,
        message: str = '',
        author: str = 'archiver',
        tags: Optional[List[str]] = None,
        metadata: Optional[Dict] = None
    ) -> Commit:
        """
        提交新版本
        
        Args:
            url: 页面 URL
            archive_id: 归档ID
            screenshot_id: 截图ID
            message: 提交说明
            author: 提交者
            tags: 标签列表
            metadata: 额外元数据
        
        Returns:
            提交记录
        """
        # 1. 获取父提交(当前分支的最新提交)
        parent_commit = self._get_latest_commit(url)
        parent_id = parent_commit.commit_id if parent_commit else None
        
        # 2. 生成提交ID
        commit_content = f"{url}{archive_id}{datetime.now().isoformat()}"
        commit_id = hashlib.sha1(commit_content.encode()).hexdigest()
        
        # 3. 创建提交对象
        commit = Commit(
            commit_id=commit_id,
            parent_id=parent_id,
            url=url,
            archive_id=archive_id,
            screenshot_id=screenshot_id,
            message=message or f"Archive {url}",
            author=author,
            timestamp=datetime.now().isoformat(),
            tags=tags or [],
            metadata=metadata or {}
        )
        
        # 4. 保存提交记录
        self._save_commit(commit)
        
        # 5. 更新分支指针
        self._update_branch('main', commit_id)
        
        # 6. 更新 URL 索引
        self._update_url_index(url, commit_id)
        
        logger.info(f"新提交: {commit_id[:8]} - {message}")
        
        return commit
    
    def _save_commit(self, commit: Commit):
        """
        保存提交记录到文件
        
        Args:
            commit: 提交对象
        """
        # 使用 Git 风格的目录结构(前2位/后38位)
        commit_dir = self.base_dir / 'commits' / commit.commit_id[:2]
        commit_dir.mkdir(parents=True, exist_ok=True)
        
        commit_file = commit_dir / f"{commit.commit_id[2:]}.json"
        commit_file.write_text(json.dumps(asdict(commit), indent=2, ensure_ascii=False))
    
    def _get_latest_commit(self, url: str) -> Optional[Commit]:
        """
        获取 URL 的最新提交
        
        Args:
            url: URL
        
        Returns:
            最新提交(如果存在)
        """
        index_file = self._get_url_index_file(url)
        
        if not index_file.exists():
            return None
        
        index_data = json.loads(index_file.read_text())
        commits = index_data.get('commits', [])
        
        if not commits:
            return None
        
        # 最新提交在列表开头
        latest_commit_id = commits[0]
        return self._load_commit(latest_commit_id)
    
    def _load_commit(self, commit_id: str) -> Optional[Commit]:
        """
        加载提交记录
        
        Args:
            commit_id: 提交ID
        
        Returns:
            提交对象
        """
        commit_file = self.base_dir / 'commits' / commit_id[:2] / f"{commit_id[2:]}.json"
        
        if not commit_file.exists():
            return None
        
        data = json.loads(commit_file.read_text())
        return Commit(**data)
    
    def _update_branch(self, branch_name: str, commit_id: str):
        """
        更新分支指针
        
        Args:
            branch_name: 分支名
            commit_id: 提交ID
        """
        branch_file = self.base_dir / 'refs' / 'heads' / f"{branch_name}.ref"
        branch_file.write_text(commit_id)
    
    def _get_url_index_file(self, url: str) -> Path:
        """
        获取 URL 的索引文件路径
        
        Args:
            url: URL
        
        Returns:
            索引文件路径
        """
        url_hash = hashlib.md5(url.encode()).hexdigest()
        return self.base_dir / 'index' / f"{url_hash}.index"
    
    def _update_url_index(self, url: str, commit_id: str):
        """
        更新 URL 索引
        
        Args:
            url: URL
            commit_id: 提交ID
        """
        index_file = self._get_url_index_file(url)
        
        if index_file.exists():
            index_data = json.loads(index_file.read_text())
        else:
            index_data = {
                'url': url,
                'created_at': datetime.now().isoformat(),
                'commits': []
            }
        
        # 添加新提交(插入到开头,保持倒序)
        index_data['commits'].insert(0, commit_id)
        index_data['updated_at'] = datetime.now().isoformat()
        
        index_file.write_text(json.dumps(index_data, indent=2, ensure_ascii=False))
    
    def log(self, url: str, limit: Optional[int] = None) -> List[Commit]:
        """
        查看提交历史
        
        Args:
            url: URL
            limit: 限制返回数量
        
        Returns:
            提交记录列表(倒序)
        """
        index_file = self._get_url_index_file(url)
        
        if not index_file.exists():
            return []
        
        index_data = json.loads(index_file.read_text())
        commit_ids = index_data.get('commits', [])
        
        if limit:
            commit_ids = commit_ids[:limit]
        
        # 加载完整的提交对象
        commits = []
        for commit_id in commit_ids:
            commit = self._load_commit(commit_id)
            if commit:
                commits.append(commit)
        
        return commits
    
    def diff(self, commit_id1: str, commit_id2: str) -> Dict:
        """
        比较两个提交的差异
        
        Args:
            commit_id1: 提交1
            commit_id2: 提交2
        
        Returns:
            差异信息:{
                'commit1': {...},
                'commit2': {...},
                'changed': True/False,
                'time_delta': 3600,  # 秒
                'archive_diff': {...},
                'screenshot_diff': {...}
            }
        """
        commit1 = self._load_commit(commit_id1)
        commit2 = self._load_commit(commit_id2)
        
        if not commit1 or not commit2:
            raise ValueError("提交不存在")
        
        # 计算时间差
        time1 = datetime.fromisoformat(commit1.timestamp)
        time2 = datetime.fromisoformat(commit2.timestamp)
        time_delta = abs((time2 - time1).total_seconds())
        
        # 判断内容是否变化
        changed = commit1.archive_id != commit2.archive_id
        
        return {
            'commit1': asdict(commit1),
            'commit2': asdict(commit2),
            'changed': changed,
            'time_delta': time_delta,
            'archive_diff': {
                'id1': commit1.archive_id,
                'id2': commit2.archive_id
            },
            'screenshot_diff': {
                'id1': commit1.screenshot_id,
                'id2': commit2.screenshot_id
            }
        }
    
    def tag(self, commit_id: str, tag_name: str, message: str = ''):
        """
        为提交打标签
        
        Args:
            commit_id: 提交ID
            tag_name: 标签名
            message: 标签说明
        """
        commit = self._load_commit(commit_id)
        if not commit:
            raise ValueError("提交不存在")
        
        # 保存标签
        tag_file = self.base_dir / 'refs' / 'tags' / f"{tag_name}.tag"
        tag_data = {
            'name': tag_name,
            'commit_id': commit_id,
            'message': message,
            'created_at': datetime.now().isoformat()
        }
        tag_file.write_text(json.dumps(tag_data, indent=2))
        
        logger.success(f"标签已创建: {tag_name} -> {commit_id[:8]}")
    
    def get_by_tag(self, tag_name: str) -> Optional[Commit]:
        """
        根据标签获取提交
        
        Args:
            tag_name: 标签名
        
        Returns:
            提交对象
        """
        tag_file = self.base_dir / 'refs' / 'tags' / f"{tag_name}.tag"
        
        if not tag_file.exists():
            return None
        
        tag_data = json.loads(tag_file.read_text())
        return self._load_commit(tag_data['commit_id'])


# ========== 使用示例 ==========
if __name__ == '__main__':
    vm = VersionManager()
    
    # 提交第一个版本
    commit1 = vm.commit(
        url="https://example.com",
        archive_id="abc123",
        screenshot_id="ss001",
        message="Initial version",
        tags=['baseline']
    )
    print(f"提交1: {commit1.commit_id[:8]}")
    
    # 提交第二个版本
    commit2 = vm.commit(
        url="https://example.com",
        archive_id="def456",
        screenshot_id="ss002",
        message="Updated content"
    )
    print(f"提交2: {commit2.commit_id[:8]}")
    
    # 查看历史
    history = vm.log("https://example.com", limit=10)
    print(f"历史记录: {len(history)} 个提交")
    
    # 对比差异
    diff = vm.diff(commit1.commit_id, commit2.commit_id)
    print(f"内容变化: {diff['changed']}")
    print(f"时间间隔: {diff['time_delta']} 秒")
    
    # 打标签
    vm.tag(commit2.commit_id, 'v1.0', 'First stable version')

4. HTML差异对比器(core/html_diff.py)

python 复制代码
"""
HTML 差异对比器
核心功能:文本级、DOM级、视觉级差异检测

对比算法:
1. 文本diff: difflib(Python 标准库)
2. DOM diff: 结构树对比
3. 视觉diff: 像素级图像对比
"""
import difflib
from typing import List, Dict, Tuple
from bs4 import BeautifulSoup
from lxml import html, etree
import re
from utils.logger import logger


class HTMLDiff:
    """
    HTML 差异对比器
    
    支持多种对比模式:
    1. 纯文本模式:忽略HTML标签,仅对比文本内容
    2. DOM结构模式:对比DOM树的结构差异
    3. 完整源码模式:逐行对比HTML源码
    4. 智能模式:自动识别最佳对比方式
    """
    
    @staticmethod
    def text_diff(html1: str, html2: str) -> Dict:
        """
        纯文本差异对比(忽略HTML标签)
        
        Args:
            html1: HTML 1
            html2: HTML 2
        
        Returns:
            差异结果:{
                'similarity': 0.95,
                'added_lines': [...],
                'removed_lines': [...],
                'diff_html': '...'  # 高亮差异的HTML
            }
        """
        # 提取纯文本
        soup1 = BeautifulSoup(html1, 'lxml')
        soup2 = BeautifulSoup(html2, 'lxml')
        
        text1 = soup1.get_text(separator='\n', strip=True)
        text2 = soup2.get_text(separator='\n', strip=True)
        
        # 按行分割
        lines1 = text1.splitlines()
        lines2 = text2.splitlines()
        
        # 使用 difflib 计算差异
        differ = difflib.Differ()
        diff = list(differ.compare(lines1, lines2))
        
        # 统计增删行
        added_lines = [line[2:] for line in diff if line.startswith('+ ')]
        removed_lines = [line[2:] for line in diff if line.startswith('- ')]
        
        # 计算相似度
        matcher = difflib.SequenceMatcher(None, lines1, lines2)
        similarity = matcher.ratio()
        
        # 生成高亮HTML
        diff_html = HTMLDiff._generate_html_diff(lines1, lines2)
        
        return {
            'similarity': similarity,
            'added_lines': added_lines,
            'removed_lines': removed_lines,
            'total_changes': len(added_lines) + len(removed_lines),
            'diff_html': diff_html
        }
    
    @staticmethod
    def _generate_html_diff(lines1: List[str], lines2: List[str]) -> str:
        """
        生成高亮差异的HTML
        
        Args:
            lines1: 文本1的行列表
            lines2: 文本2的行列表
        
        Returns:
            带样式的HTML字符串
        """
        differ = difflib.HtmlDiff()
        html_diff = differ.make_file(lines1, lines2, context=True)
        return html_diff
    
    @staticmethod
    def dom_diff(html1: str, html2: str) -> Dict:
        """
        DOM 结构差异对比
        
        对比内容:
        1. DOM 树结构
        2. 标签类型和数量
        3. 属性变化
        4. 文本节点变化
        
        Args:
            html1: HTML 1
            html2: HTML 2
        
        Returns:
            差异结果:{
                'structural_similarity': 0.9,
                'added_elements': [...],
                'removed_elements': [...],
                'modified_elements': [...]
            }
        """
        tree1 = html.fromstring(html1)
        tree2 = html.fromstring(html2)
        
        # 提取所有元素路径
        paths1 = HTMLDiff._extract_element_paths(tree1)
        paths2 = HTMLDiff._extract_element_paths(tree2)
        
        # 找出增删改的元素
        added = paths2 - paths1
        removed = paths1 - paths2
        common = paths1 & paths2
        
        # 检查共同元素的属性变化
        modified = []
        for path in common:
            elem1 = tree1.xpath(path)
            elem2 = tree2.xpath(path)
            
            if elem1 and elem2:
                if HTMLDiff._element_changed(elem1[0], elem2[0]):
                    modified.append(path)
        
        # 计算结构相似度
        total = len(paths1 | paths2)
        common_count = len(common) - len(modified)
        structural_similarity = common_count / total if total > 0 else 1.0
        
        return {
            'structural_similarity': structural_similarity,
            'added_elements': list(added),
            'removed_elements': list(removed),
            'modified_elements': modified,
            'total_changes': len(added) + len(removed) + len(modified)
        }
    
    @staticmethod
    def _extract_element_paths(tree) -> set:
        """
        提取 DOM 树中所有元素的 XPath
        
        Args:
            tree: lxml 树
        
        Returns:
            XPath 集合
        """
        paths = set()
        
        for elem in tree.iter():
            try:
                path = tree.getroottree().getpath(elem)
                paths.add(path)
            except:
                pass
        
        return paths
    
    @staticmethod
    def _element_changed(elem1, elem2) -> bool:
        """
        判断两个元素是否发生变化
        
        检查:
        1. 标签名
        2. 属性
        3. 直接文本内容
        
        Args:
            elem1: 元素1
            elem2: 元素2
        
        Returns:
            是否变化
        """
        # 标签名
        if elem1.tag != elem2.tag:
            return True
        
        # 属性
        if elem1.attrib != elem2.attrib:
            return True
        
        # 直接文本
        text1 = (elem1.text or '').strip()
        text2 = (elem2.text or '').strip()
        if text1 != text2:
            return True
        
        return False
    
    @staticmethod
    def source_diff(html1: str, html2: str) -> Dict:
        """
        源码级差异对比(逐行对比)
        
        Args:
            html1: HTML 1
            html2: HTML 2
        
        Returns:
            差异结果
        """
        lines1 = html1.splitlines()
        lines2 = html2.splitlines()
        
        # 使用 unified_diff
        diff = difflib.unified_diff(
            lines1,
            lines2,
            fromfile='before.html',
            tofile='after.html',
            lineterm=''
        )
        
        diff_lines = list(diff)
        
        # 统计变化行数
        added = sum(1 for line in diff_lines if line.startswith('+') and not line.startswith('+++'))
        removed = sum(1 for line in diff_lines if line.startswith('-') and not line.startswith('---'))
        
        # 计算相似度
        matcher = difflib.SequenceMatcher(None, lines1, lines2)
        similarity = matcher.ratio()
        
        return {
            'similarity': similarity,
            'added_lines': added,
            'removed_lines': removed,
            'total_changes': added + removed,
            'diff_output': '\n'.join(diff_lines)
        }


# ========== 使用示例 ==========
if __name__ == '__main__':
    # 旧版HTML
    html_old = """
    <html>
    <body>
        <h1 class="title">欢迎</h1>
        <p>这是第一段文字。</p>
        <div class="content">
            <span>内容1</span>
        </div>
    </body>
    </html>
    """
    
    # 新版HTML(有改动)
    html_new = """
    <html>
    <body>
        <h1 class="main-title">欢迎使用</h1>
        <p>这是修改后的文字。</p>
        <div class="content">
            <span>内容1</span>
            <span>内容2</span>
        </div>
        <footer>新增的页脚</footer>
    </body>
    </html>
    """
    
    # 1. 文本差异
    text_result = HTMLDiff.text_diff(html_old, html_new)
    print(f"文本相似度: {text_result['similarity']:.2%}")
    print(f"新增行: {len(text_result['added_lines'])}")
    print(f"删除行: {len(text_result['removed_lines'])}")
    
    # 2. DOM差异
    dom_result = HTMLDiff.dom_diff(html_old, html_new)
    print(f"结构相似度: {dom_result['structural_similarity']:.2%}")
    print(f"新增元素: {len(dom_result['added_elements'])}")
    print(f"删除元素: {len(dom_result['removed_elements'])}")
    
    # 3. 源码差异
    source_result = HTMLDiff.source_diff(html_old, html_new)
    print(f"源码相似度: {source_result['similarity']:.2%}")
    print(f"变化行数: {source_result['total_changes']}")

🕰️ 时光机Web界面实现

5. Flask时光机应用(web/timemachine_app.py)

python 复制代码
"""
时光机 Web 应用
功能:可视化浏览网页历史版本

核心特性:
1. 日历视图:按日期浏览归档
2. 时间轴:可视化展示版本演变
3. 并排对比:对比任意两个版本
4. 全文搜索:在历史版本中搜索内容
5. 导出功能:批量导出历史数据
"""
from flask import Flask, render_template, request, jsonify, send_file
from pathlib import Path
from datetime import datetime, timedelta
from typing import List, Dict
import json
from core.html_archiver import HTMLArchiver
from core.screenshot_archiver import ScreenshotArchiver
from core.version_manager import VersionManager
from core.html_diff import HTMLDiff
from utils.logger import logger


app = Flask(__name__)

# 初始化组件
html_archiver = HTMLArchiver()
version_manager = VersionManager()


@app.route('/')
def index():
    """首页:URL 列表"""
    return render_template('index.html')


@app.route('/api/urls')
def api_urls():
    """
    获取所有已归档的 URL
    
    Returns:
        {
            'urls': [
                {
                    'url': '...',
                    'first_archived': '...',
                    'last_archived': '...',
                    'total_versions': 10
                },
                ...
            ]
        }
    """
    # 扫描索引文件
    index_dir = Path('data/versions/index')
    urls_info = []
    
    for index_file in index_dir.glob('*.index'):
        try:
            data = json.loads(index_file.read_text())
            urls_info.append({
                'url': data['url'],
                'first_archived': data.get('created_at'),
                'last_archived': data.get('updated_at'),
                'total_versions': len(data.get('commits', []))
            })
        except Exception as e:
            logger.error(f"读取索引失败: {index_file}, {e}")
    
    # 按最后归档时间倒序
    urls_info.sort(key=lambda x: x.get('last_archived', ''), reverse=True)
    
    return jsonify({'urls': urls_info})


@app.route('/api/timeline/<path:url>')
def api_timeline(url: str):
    """
    获取 URL 的时间轴数据
    
    Args:
        url: URL(路径参数)
    
    Returns:
        {
            'timeline': [
                {
                    'commit_id': '...',
                    'timestamp': '...',
                    'message': '...',
                    'has_screenshot': True,
                    'tags': [...]
                },
                ...
            ]
        }
    """
    # 查询提交历史
    commits = version_manager.log(url, limit=100)
    
    timeline = []
    for commit in commits:
        timeline.append({
            'commit_id': commit.commit_id,
            'timestamp': commit.timestamp,
            'message': commit.message,
            'has_screenshot': commit.screenshot_id is not None,
            'tags': commit.tags
        })
    
    return jsonify({'timeline': timeline})


@app.route('/api/calendar/<path:url>')
def api_calendar(url: str):
    """
    获取日历视图数据(按天聚合)
    
    Returns:
        {
            'calendar': {
                '2026-02-04': 5,  # 5个版本
                '2026-02-05': 3,
                ...
            }
        }
    """
    commits = version_manager.log(url)
    
    # 按日期聚合
    calendar = {}
    for commit in commits:
        date = datetime.fromisoformat(commit.timestamp).strftime('%Y-%m-%d')
        calendar[date] = calendar.get(date, 0) + 1
    
    return jsonify({'calendar': calendar})


@app.route('/api/version/<commit_id>')
def api_version(commit_id: str):
    """
    获取特定版本的详细信息
    
    Args:
        commit_id: 提交ID
    
    Returns:
        {
            'commit': {...},
            'html_preview': '...',  # HTML 预览
            'screenshot_url': '...'  # 截图URL
        }
    """
    # 加载提交
    commit = version_manager._load_commit(commit_id)
    
    if not commit:
        return jsonify({'error': 'Version not found'}), 404
    
    # 加载HTML内容
    archive_meta = html_archiver.get_version_by_id(commit.archive_id)
    html_content = ''
    
    if archive_meta:
        try:
            html_content = html_archiver.load(archive_meta['filepath'])
        except Exception as e:
            logger.error(f"加载HTML失败: {e}")
    
    # 生成截图URL(如果存在)
    screenshot_url = None
    if commit.screenshot_id:
        screenshot_url = f"/screenshots/{commit.screenshot_id}"
    
    return jsonify({
        'commit': {
            'commit_id': commit.commit_id,
            'url': commit.url,
            'timestamp': commit.timestamp,
            'message': commit.message,
            'author': commit.author,
            'tags': commit.tags
        },
        'html_preview': html_content[:5000],  # 限制长度
        'screenshot_url': screenshot_url
    })


@app.route('/api/compare')
def api_compare():
    """
    对比两个版本
    
    Query params:
        commit1: 提交ID1
        commit2: 提交ID2
        mode: 对比模式(text/dom/source)
    
    Returns:
        {
            'diff_result': {...},
            'commit1_info': {...},
            'commit2_info': {...}
        }
    """
    commit_id1 = request.args.get('commit1')
    commit_id2 = request.args.get('commit2')
    mode = request.args.get('mode', 'text')
    
    if not commit_id1 or not commit_id2:
        return jsonify({'error': 'Missing commit IDs'}), 400
    
    # 加载提交
    commit1 = version_manager._load_commit(commit_id1)
    commit2 = version_manager._load_commit(commit_id2)
    
    if not commit1 or not commit2:
        return jsonify({'error': 'Commit not found'}), 404
    
    # 加载HTML
    html1 = html_archiver.load(
        html_archiver.get_version_by_id(commit1.archive_id)['filepath']
    )
    html2 = html_archiver.load(
        html_archiver.get_version_by_id(commit2.archive_id)['filepath']
    )
    
    # 执行对比
    if mode == 'text':
        diff_result = HTMLDiff.text_diff(html1, html2)
    elif mode == 'dom':
        diff_result = HTMLDiff.dom_diff(html1, html2)
    else:  # source
        diff_result = HTMLDiff.source_diff(html1, html2)
    
    return jsonify({
        'diff_result': diff_result,
        'commit1_info': {
            'commit_id': commit1.commit_id,
            'timestamp': commit1.timestamp
        },
        'commit2_info': {
            'commit_id': commit2.commit_id,
            'timestamp': commit2.timestamp
        }
    })


@app.route('/api/search')
def api_search():
    """
    全文搜索历史版本
    
    Query params:
        url: URL
        keyword: 关键词
        limit: 限制结果数
    
    Returns:
        {
            'results': [
                {
                    'commit_id': '...',
                    'timestamp': '...',
                    'matched_text': '...',
                    'context': '...'  # 上下文片段
                },
                ...
            ]
        }
    """
    url = request.args.get('url')
    keyword = request.args.get('keyword', '')
    limit = int(request.args.get('limit', 20))
    
    if not url or not keyword:
        return jsonify({'error': 'Missing parameters'}), 400
    
    # 查询所有版本
    commits = version_manager.log(url, limit=limit)
    
    results = []
    for commit in commits:
        try:
            # 加载HTML
            archive_meta = html_archiver.get_version_by_id(commit.archive_id)
            html_content = html_archiver.load(archive_meta['filepath'])
            
            # 提取文本
            from bs4 import BeautifulSoup
            soup = BeautifulSoup(html_content, 'lxml')
            text = soup.get_text(separator='\n', strip=True)
            
            # 搜索关键词
            if keyword.lower() in text.lower():
                # 提取上下文
                index = text.lower().find(keyword.lower())
                start = max(0, index - 50)
                end = min(len(text), index + len(keyword) + 50)
                context = text[start:end]
                
                results.append({
                    'commit_id': commit.commit_id,
                    'timestamp': commit.timestamp,
                    'matched_text': keyword,
                    'context': f"...{context}..."
                })
        
        except Exception as e:
            logger.error(f"搜索失败: {commit.commit_id}, {e}")
    
    return jsonify({'results': results})


# ========== 前端页面路由 ==========

@app.route('/timeline/<path:url>')
def timeline(url: str):
    """时间轴页面"""
    return render_template('timeline.html', url=url)


@app.route('/compare/<path:url>')
def compare_page(url: str):
    """对比页面"""
    return render_template('compare.html', url=url)


@app.route('/view/<commit_id>')
def view_version(commit_id: str):
    """查看单个版本"""
    return render_template('view.html', commit_id=commit_id)


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

6. 前端时间轴页面(web/templates/timeline.html)

html 复制代码
<!DOCTYPE html>
<html>
<head>
    <title>时间轴 - {{ url }}</title>
    <meta charset="UTF-8">
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { 
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
            background: #f5f7fa;
            padding: 20px;
        }
        .container { max-width: 1200px; margin: 0 auto; }
        
        /* 页头 */
        .header {
            background: white;
            padding: 20px;
            border-radius: 8px;
            margin-bottom: 20px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
        .header h1 { 
            font-size: 24px; 
            color: #333;
            margin-bottom: 10px;
        }
        .header .url { 
            color: #666;
            word-break: break-all;
        }
        
        /* 时间轴 */
        .timeline {
            position: relative;
            padding-left: 50px;
        }
        .timeline::before {
            content: '';
            position: absolute;
            left: 20px;
            top: 0;
            bottom: 0;
            width: 2px;
            background: #ddd;
        }
        
        .timeline-item {
            position: relative;
            background: white;
            padding: 15px 20px;
            margin-bottom: 15px;
            border-radius: 8px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.05);
            transition: all 0.3s;
        }
        .timeline-item:hover {
            box-shadow: 0 4px 12px rgba(0,0,0,0.1);
            transform: translateX(5px);
        }
        
        .timeline-item::before {
            content: '';
            position: absolute;
            left: -34px;
            top: 20px;
            width: 12px;
            height: 12px;
            border-radius: 50%;
            background: #4CAF50;
            border: 2px solid white;
            box-shadow: 0 0 0 2px #4CAF50;
        }
        
        .timeline-item.has-screenshot::before {
            background: #2196F3;
            box-shadow: 0 0 0 2px #2196F3;
        }
        
        .item-time {
            font-size: 14px;
            color: #999;
            margin-bottom: 5px;
        }
        .item-message {
            font-size: 16px;
            color: #333;
            margin-bottom: 10px;
        }
        .item-tags {
            display: flex;
            gap: 5px;
        }
        .tag {
            display: inline-block;
            padding: 2px 8px;
            background: #e3f2fd;
            color: #2196F3;
            border-radius: 3px;
            font-size: 12px;
        }
        
        .item-actions {
            margin-top: 10px;
            display: flex;
            gap: 10px;
        }
        .btn {
            padding: 6px 12px;
            background: #2196F3;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            font-size: 14px;
            text-decoration: none;
            display: inline-block;
        }
        .btn:hover { background: #1976D2; }
        .btn-secondary { background: #757575; }
        .btn-secondary:hover { background: #616161; }
        
        /* 加载状态 */
        .loading {
            text-align: center;
            padding: 40px;
            color: #999;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="header">
            <h1>📜 版本历史</h1>
            <p class="url">{{ url }}</p>
        </div>
        
        <div class="timeline" id="timeline">
            <div class="loading">正在加载...</div>
        </div>
    </div>
    
    <script>
        const url = "{{ url }}";
        
        // 加载时间轴数据
        async function loadTimeline() {
            try {
                const response = await fetch(`/api/timeline/${encodeURIComponent(url)}`);
                const data = await response.json();
                
                renderTimeline(data.timeline);
            } catch (error) {
                console.error('加载失败:', error);
                document.getElementById('timeline').innerHTML = '<div class="loading">加载失败</div>';
            }
        }
        
        // 渲染时间轴
        function renderTimeline(commits) {
            const timeline = document.getElementById('timeline');
            
            if (commits.length === 0) {
                timeline.innerHTML = '<div class="loading">暂无历史版本</div>';
                return;
            }
            
            timeline.innerHTML = commits.map(commit => {
                const time = new Date(commit.timestamp).toLocaleString('zh-CN');
                const hasScreenshot = commit.has_screenshot ? 'has-screenshot' : '';
                const tags = commit.tags.map(tag => `<span class="tag">${tag}</span>`).join('');
                
                return `
                    <div class="timeline-item ${hasScreenshot}">
                        <div class="item-time">${time}</div>
                        <div class="item-message">${commit.message}</div>
                        ${tags ? `<div class="item-tags">${tags}</div>` : ''}
                        <div class="item-actions">
                            <a href="/view/${commit.commit_id}" class="btn">查看</a>
                            ${commit.has_screenshot ? '<button class="btn btn-secondary" onclick="viewScreenshot(\'' + commit.commit_id + '\')">查看截图</button>' : ''}
                        </div>
                    </div>
                `;
            }).join('');
        }
        
        // 查看截图
        function viewScreenshot(commitId) {
            // 实现截图查看逻辑
            alert('查看截图: ' + commitId);
        }
        
        // 页面加载时执行
        loadTimeline();
    </script>
</body>
</html>

💾 存储优化与扩展性

7. 对象存储集成(storage/s3_storage.py)

python 复制代码
"""
对象存储集成(S3 兼容)
适用于大规模归档系统,将文件存储到云端

支持的服务:
- AWS S3
- 阿里云 OSS
- 腾讯云 COS
- MinIO(自建)
"""
import boto3
from botocore.exceptions import ClientError
from pathlib import Path
from typing import Optional, Dict
from utils.logger import logger


class S3Storage:
    """
    S3 对象存储管理器
    
    优势:
    1. 无限容量
    2. 按需付费
    3. 高可用性(99.99%)
    4. 内置CDN加速
    5. 版本控制(S3 Versioning)
    """
    
    def __init__(
        self,
        bucket_name: str,
        access_key: str,
        secret_key: str,
        endpoint_url: Optional[str] = None,
        region: str = 'us-east-1'
    ):
        """
        初始化 S3 客户端
        
        Args:
            bucket_name: 存储桶名称
            access_key: 访问密钥
            secret_key: 密钥
            endpoint_url: 端点URL(MinIO等自建服务需要)
            region: 区域
        """
        self.bucket_name = bucket_name
        
        # 创建 S3 客户端
        self.s3_client = boto3.client(
            's3',
            aws_access_key_id=access_key,
            aws_secret_access_key=secret_key,
            endpoint_url=endpoint_url,
            region_name=region
        )
        
        # 确保存储桶存在
        self._ensure_bucket()
    
    def _ensure_bucket(self):
        """确保存储桶存在"""
        try:
            self.s3_client.head_bucket(Bucket=self.bucket_name)
        except ClientError:
            # 存储桶不存在,创建
            try:
                self.s3_client.create_bucket(Bucket=self.bucket_name)
                logger.info(f"存储桶已创建: {self.bucket_name}")
            except Exception as e:
                logger.error(f"创建存储桶失败: {e}")
    
    def upload_file(
        self,
        local_path: str,
        object_key: str,
        metadata: Optional[Dict] = None,
        storage_class: str = 'STANDARD'
    ) -> Dict:
        """
        上传文件到 S3
        
        Args:
            local_path: 本地文件路径
            object_key: S3 对象键(类似文件名)
            metadata: 自定义元数据
            storage_class: 存储类别(STANDARD/INTELLIGENT_TIERING/GLACIER)
        
        Returns:
            上传结果:{
                'success': True,
                'object_key': '...',
                'url': '...',
                'size': 12345
            }
        """
        try:
            file_size = Path(local_path).stat().st_size
            
            # 构建额外参数
            extra_args = {
                'StorageClass': storage_class
            }
            
            if metadata:
                extra_args['Metadata'] = metadata
            
            # 上传文件
            self.s3_client.upload_file(
                local_path,
                self.bucket_name,
                object_key,
                ExtraArgs=extra_args
            )
            
            # 生成URL
            url = f"s3://{self.bucket_name}/{object_key}"
            
            logger.success(f"文件已上传: {object_key}")
            
            return {
                'success': True,
                'object_key': object_key,
                'url': url,
                'size': file_size
            }
        
        except Exception as e:
            logger.error(f"上传失败: {e}")
            return {
                'success': False,
                'error': str(e)
            }
    
    def download_file(self, object_key: str, local_path: str) -> bool:
        """
        从 S3 下载文件
        
        Args:
            object_key: S3 对象键
            local_path: 本地保存路径
        
        Returns:
            是否成功
        """
        try:
            self.s3_client.download_file(
                self.bucket_name,
                object_key,
                local_path
            )
            
            logger.success(f"文件已下载: {object_key}")
            return True
        
        except Exception as e:
            logger.error(f"下载失败: {e}")
            return False
    
    def generate_presigned_url(
        self,
        object_key: str,
        expiration: int = 3600
    ) -> Optional[str]:
        """
        生成预签名URL(临时访问链接)
        
        Args:
            object_key: 对象键
            expiration: 过期时间(秒)
        
        Returns:
            预签名URL
        """
        try:
            url = self.s3_client.generate_presigned_url(
                'get_object',
                Params={
                    'Bucket': self.bucket_name,
                    'Key': object_key
                },
                ExpiresIn=expiration
            )
            return url
        
        except Exception as e:
            logger.error(f"生成URL失败: {e}")
            return None
    
    def list_objects(self, prefix: str = '', max_keys: int = 1000) -> List[Dict]:
        """
        列出对象
        
        Args:
            prefix: 前缀过滤
            max_keys: 最大返回数量
        
        Returns:
            对象列表
        """
        try:
            response = self.s3_client.list_objects_v2(
                Bucket=self.bucket_name,
                Prefix=prefix,
                MaxKeys=max_keys
            )
            
            objects = []
            for obj in response.get('Contents', []):
                objects.append({
                    'key': obj['Key'],
                    'size': obj['Size'],
                    'last_modified': obj['LastModified'].isoformat()
                })
            
            return objects
        
        except Exception as e:
            logger.error(f"列表查询失败: {e}")
            return []
    
    def delete_object(self, object_key: str) -> bool:
        """
        删除对象
        
        Args:
            object_key: 对象键
        
        Returns:
            是否成功
        """
        try:
            self.s3_client.delete_object(
                Bucket=self.bucket_name,
                Key=object_key
            )
            
            logger.info(f"对象已删除: {object_key}")
            return True
        
        except Exception as e:
            logger.error(f"删除失败: {e}")
            return False


# ========== 使用示例 ==========
if __name__ == '__main__':
    # 初始化 S3 存储
    s3 = S3Storage(
        bucket_name='webpage-archive',
        access_key='YOUR_ACCESS_KEY',
        secret_key='YOUR_SECRET_KEY',
        endpoint_url='https://s3.amazonaws.com'  # AWS S3
        # endpoint_url='http://localhost:9000'  # MinIO
    )
    
    # 上传文件
    result = s3.upload_file(
        local_path='data/archives/html/2026/02/04/example.html.gz',
        object_key='archives/2026-02-04/example_abc123.html.gz',
        metadata={'url': 'https://example.com', 'version': '1'},
        storage_class='INTELLIGENT_TIERING'  # 智能分层(自动优化成本)
    )
    
    if result['success']:
        # 生成临时访问链接
        temp_url = s3.generate_presigned_url(result['object_key'], expiration=3600)
        print(f"临时链接: {temp_url}")

⚖️ 合规存证与法律证据链

8. 数字签名与时间戳(compliance/evidence_chain.py)

python 复制代码
"""
法律证据链构建
核心:确保归档数据的真实性、完整性、不可篡改性

技术手段:
1. 数字签名(RSA/ECC)
2. 可信时间戳(TSA)
3. 区块链存证(可选)
4. 审计日志
"""
import hashlib
from datetime import datetime
from typing import Dict, Optional
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.backends import default_backend
import requests
from utils.logger import logger


class EvidenceChain:
    """
    证据链管理器
    
    证据要素:
    1. 原始数据(HTML/截图)
    2. 数据哈希值
    3. 数字签名
    4. 可信时间戳
    5. 元数据(URL、采集时间等)
    """
    
    def __init__(self, private_key_path: Optional[str] = None):
        """
        初始化证据链
        
        Args:
            private_key_path: 私钥文件路径(用于签名)
        """
        if private_key_path:
            self.private_key = self._load_private_key(private_key_path)
        else:
            # 生成新的密钥对
            self.private_key = rsa.generate_private_key(
                public_exponent=65537,
                key_size=2048,
                backend=default_backend()
            )
        
        self.public_key = self.private_key.public_key()
    
    def _load_private_key(self, path: str):
        """加载私钥"""
        with open(path, 'rb') as f:
            return serialization.load_pem_private_key(
                f.read(),
                password=None,
                backend=default_backend()
            )
    
    def create_evidence(
        self,
        data: bytes,
        metadata: Dict
    ) -> Dict:
        """
        创建证据记录
        
        Args:
            data: 原始数据(HTML bytes)
            metadata: 元数据
        
        Returns:
            证据记录:{
                'data_hash': '...',
                'signature': '...',
                'timestamp': '...',
                'tsa_token': '...',
                'metadata': {...}
            }
        """
        # 1. 计算数据哈希
        data_hash = hashlib.sha256(data).hexdigest()
        
        # 2. 数字签名
        signature = self._sign_data(data)
        
        # 3. 获取可信时间戳(调用TSA服务)
        tsa_token = self._get_timestamp(data_hash)
        
        # 4. 构建证据记录
        evidence = {
            'data_hash': data_hash,
            'signature': signature.hex(),
            'timestamp': datetime.now().isoformat(),
            'tsa_token': tsa_token,
            'metadata': metadata
        }
        
        logger.success(f"证据已创建: {data_hash[:16]}...")
        
        return evidence
    
    def _sign_data(self, data: bytes) -> bytes:
        """
        对数据进行数字签名
        
        Args:
            data: 原始数据
        
        Returns:
            签名(bytes)
        """
        signature = self.private_key.sign(
            data,
            padding.PSS(
                mgf=padding.MGF1(hashes.SHA256()),
                salt_length=padding.PSS.MAX_LENGTH
            ),
            hashes.SHA256()
        )
        return signature
    
    def verify_signature(self, data: bytes, signature: bytes) -> bool:
        """
        验证签名
        
        Args:
            data: 原始数据
            signature: 签名
        
        Returns:
            是否有效
        """
        try:
            self.public_key.verify(
                signature,
                data,
                padding.PSS(
                    mgf=padding.MGF1(hashes.SHA256()),
                    salt_length=padding.PSS.MAX_LENGTH
                ),
                hashes.SHA256()
            )
            return True
        except Exception:
            return False
    
    def _get_timestamp(self, data_hash: str) -> Optional[str]:
        """
        获取可信时间戳(调用 TSA 服务)
        
        TSA(Time Stamping Authority)服务提供商:
        - DigiCert
        - Symantec
        - GlobalSign
        
        Args:
            data_hash: 数据哈希
        
        Returns:
            时间戳令牌
        """
        # 这里是示例,实际需要调用真实的TSA服务
        try:
            # 示例:调用公开的TSA服务
            tsa_url = "http://timestamp.digicert.com"
            
            # 实际实现需要构建 RFC 3161 格式的请求
            # 这里简化处理
            response = requests.post(
                tsa_url,
                data={'hash': data_hash},
                timeout=10
            )
            
            if response.status_code == 200:
                return response.text
        
        except Exception as e:
            logger.warning(f"TSA 时间戳获取失败: {e}")
        
        # 备用方案:使用本地时间戳(法律效力较弱)
        return datetime.now().isoformat()
    
    def export_public_key(self, path: str):
        """
        导出公钥(用于验证)
        
        Args:
            path: 保存路径
        """
        pem = self.public_key.public_bytes(
            encoding=serialization.Encoding.PEM,
            format=serialization.PublicFormat.SubjectPublicKeyInfo
        )
        
        with open(path, 'wb') as f:
            f.write(pem)
        
        logger.success(f"公钥已导出: {path}")


# ========== 使用示例 ==========
if __name__ == '__main__':
    # 初始化证据链
    chain = EvidenceChain()
    
    # 创建证据
    html_data = b"<html>...</html>"
    evidence = chain.create_evidence(
        data=html_data,
        metadata={
            'url': 'https://example.com',
            'archived_at': '2026-02-04T10:00:00',
            'archiver': 'bot-001'
        }
    )
    
    print(f"数据哈希: {evidence['data_hash']}")
    print(f"签名: {evidence['signature'][:32]}...")
    
    # 验证签名
    signature_bytes = bytes.fromhex(evidence['signature'])
    is_valid = chain.verify_signature(html_data, signature_bytes)
    print(f"签名有效: {is_valid}")
    
    # 导出公钥(提供给第三方验证)
    chain.export_public_key('public_key.pem')

📊 完整实战示例

9. 自动化归档调度器(examples/auto_archiver.py)

python 复制代码
"""
自动化网页归档系统
完整示例:定时归档、版本管理、存证、监控

功能流程:
1. 定时抓取目标 URL
2. 保存 HTML + 截图
3. 版本管理(Git式提交)
4. 数字签名存证
5. 上传到 S3(可选)
6. 发送监控报告
"""
import schedule
import time
from datetime import datetime
from typing import List, Dict
import requests
from pathlib import Path

# 导入自定义模块
from core.html_archiver import HTMLArchiver
from core.screenshot_archiver import ScreenshotArchiver
from core.version_manager import VersionManager
from compliance.evidence_chain import EvidenceChain
from storage.s3_storage import S3Storage
from utils.logger import logger


class AutoArchiver:
    """
    自动化归档器
    
    配置示例:
    {
        "targets": [
            {
                "url": "https://example.com",
                "schedule": "hourly",  # hourly/daily/weekly
                "take_screenshot": True,
                "enable_evidence": True
            }
        ],
        "s3_enabled": False,
        "notification_email": "admin@example.com"
    }
    """
    
    def __init__(self, config: Dict):
        """
        初始化归档器
        
        Args:
            config: 配置字典
        """
        self.config = config
        
        # 初始化组件
        self.html_archiver = HTMLArchiver(compress=True, save_resources=False)
        self.version_manager = VersionManager()
        self.evidence_chain = EvidenceChain()
        
        # S3(可选)
        if config.get('s3_enabled'):
            self.s3 = S3Storage(
                bucket_name=config['s3_bucket'],
                access_key=config['s3_access_key'],
                secret_key=config['s3_secret_key']
            )
        else:
            self.s3 = None
        
        logger.info("自动归档器已初始化")
    
    def archive_url(self, target: Dict):
        """
        归档单个 URL
        
        Args:
            target: 目标配置
        """
        url = target['url']
        logger.info(f"开始归档: {url}")
        
        try:
            # 1. 下载 HTML
            response = requests.get(url, timeout=30)
            response.raise_for_status()
            html_content = response.text
            html_bytes = html_content.encode('utf-8')
            
            # 2. 保存 HTML 快照
            archive_result = self.html_archiver.archive(
                url=url,
                html_content=html_content,
                metadata={'source': 'auto_archiver'}
            )
            
            # 3. 截图(可选)
            screenshot_id = None
            if target.get('take_screenshot'):
                with ScreenshotArchiver() as screenshot:
                    screenshot_result = screenshot.capture_full_page(
                        url=url,
                        remove_elements=['.ad', '.popup']
                    )
                    screenshot_id = screenshot_result.get('image_hash')
            
            # 4. 版本管理(Git式提交)
            commit = self.version_manager.commit(
                url=url,
                archive_id=archive_result['archive_id'],
                screenshot_id=screenshot_id,
                message=f"Auto archive at {datetime.now().strftime('%Y-%m-%d %H:%M')}",
                author='auto_archiver'
            )
            
            # 5. 数字签名存证(可选)
            if target.get('enable_evidence'):
                evidence = self.evidence_chain.create_evidence(
                    data=html_bytes,
                    metadata={
                        'url': url,
                        'commit_id': commit.commit_id,
                        'archive_id': archive_result['archive_id']
                    }
                )
                logger.info(f"证据哈希: {evidence['data_hash'][:16]}...")
            
            # 6. 上传到 S3(可选)
            if self.s3:
                s3_key = f"archives/{datetime.now().strftime('%Y/%m/%d')}/{archive_result['archive_id']}.html.gz"
                s3_result = self.s3.upload_file(
                    local_path=f"data/archives/{archive_result['filepath']}",
                    object_key=s3_key,
                    storage_class='INTELLIGENT_TIERING'
                )
                logger.info(f"已上传至 S3: {s3_key}")
            
            logger.success(f"归档完成: {url} -> {commit.commit_id[:8]}")
        
        except Exception as e:
            logger.error(f"归档失败: {url}, 错误: {e}")
    
    def run(self):
        """启动调度器"""
        logger.info("调度器启动...")
        
        # 注册定时任务
        for target in self.config['targets']:
            schedule_type = target.get('schedule', 'daily')
            
            if schedule_type == 'hourly':
                schedule.every().hour.do(self.archive_url, target=target)
            elif schedule_type == 'daily':
                schedule.every().day.at("02:00").do(self.archive_url, target=target)
            elif schedule_type == 'weekly':
                schedule.every().monday.at("02:00").do(self.archive_url, target=target)
            
            logger.info(f"已注册: {target['url']} ({schedule_type})")
        
        # 主循环
        while True:
            schedule.run_pending()
            time.sleep(60)


# ========== 配置与启动 ==========
if __name__ == '__main__':
    # 配置
    config = {
        'targets': [
            {
                'url': 'https://www.example.com',
                'schedule': 'daily',
                'take_screenshot': True,
                'enable_evidence': True
            },
            {
                'url': 'https://news.example.com',
                'schedule': 'hourly',
                'take_screenshot': True,
                'enable_evidence': False
            }
        ],
        's3_enabled': False  # 启用 S3 需要配置密钥
    }
    
    # 启动
    archiver = AutoArchiver(config)
    archiver.run()

📚 总结与最佳实践

核心价值

完整性保障 :HTML + 截图 + 元数据三重备份

可追溯性 :Git式版本管理,清晰的历史链条

法律效力 :数字签名 + 时间戳 + 证据链

可扩展性 :支持对象存储、分布式架构

易用性:Web时光机界面,可视化浏览

生产部署建议

1. 存储架构

json 复制代码
本地存储(热数据,7天) → S3 Intelligent Tiering(30天) → Glacier(永久归档)

2. 性能优化

  • HTML压缩:Gzip可节省70-90%空间
  • 图像压缩:WebP格式,质量80
  • 增量归档:仅保存变化部分
  • 智能去重:基于内容哈希

3. 安全合规

  • 加密存储:AES-256
  • 访问控制:RBAC权限管理
  • 审计日志:所有操作可追溯
  • 定期备份:异地容灾

4. 成本控制

  • 使用 S3 Intelligent Tiering(自动分层)
  • 设置生命周期策略(自动删除过期数据)
  • 压缩算法选择(Gzip/Brotli)
  • CDN加速(减少源站流量)

🌟 文末

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

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

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

墙裂推荐订阅专栏 👉 《Python爬虫实战》,本专栏秉承着以"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一期内容都做到:

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

📣 想系统提升的小伙伴 :强烈建议先订阅专栏 《Python爬虫实战》,再按目录大纲顺序学习,效率十倍上升~

✅ 互动征集

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

评论区留言告诉我你的需求,我会优先安排实现(更新)哒~


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

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

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


✅ 免责声明

本文爬虫思路、相关技术和代码仅用于学习参考,对阅读本文后的进行爬虫行为的用户本作者不承担任何法律责任。

使用或者参考本项目即表示您已阅读并同意以下条款:

  • 合法使用: 不得将本项目用于任何违法、违规或侵犯他人权益的行为,包括但不限于网络攻击、诈骗、绕过身份验证、未经授权的数据抓取等。
  • 风险自负: 任何因使用本项目而产生的法律责任、技术风险或经济损失,由使用者自行承担,项目作者不承担任何形式的责任。
  • 禁止滥用: 不得将本项目用于违法牟利、黑产活动或其他不当商业用途。
  • 使用或者参考本项目即视为同意上述条款,即 "谁使用,谁负责" 。如不同意,请立即停止使用并删除本项目。!!!
相关推荐
张3蜂2 小时前
Python 四大 Web 框架对比解析:FastAPI、Django、Flask 与 Tornado
前端·python·fastapi
2601_948374572 小时前
商用电子秤怎么选
大数据·python
Volunteer Technology2 小时前
Sentinel的限流算法
java·python·算法
七夜zippoe2 小时前
Python统计分析实战:从描述统计到假设检验的完整指南
开发语言·python·统计分析·置信区间·概率分布
Blurpath住宅代理2 小时前
动态代理的五大优点:提升爬虫效率与安全性
网络·爬虫·动态ip·住宅ip·住宅代理
2601_949146532 小时前
Python语音通知API示例代码汇总:基于Requests库的语音接口调用实战
开发语言·python
去码头整点薯条983 小时前
python第五次作业
linux·前端·python
有代理ip3 小时前
Python 与 Golang 爬虫的隐藏优势
爬虫·python·golang
数研小生3 小时前
1688商品列表API:高效触达批发电商海量商品数据的技术方案
大数据·python·算法·信息可视化·json