Python爬虫实战:采集行业协会、研究机构等平台的政策文件列表与PDF链接批量收集系统,支持自动下载、分类归档和数据库管理(SQLite持久化存储)!

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

㊗️爬虫难度指数:⭐⭐

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

全文目录:

    • [🌟 开篇语](#🌟 开篇语)
    • [1️⃣ 标题 && 摘要](#1️⃣ 标题 && 摘要)
    • [2️⃣ 背景与需求(Why)](#2️⃣ 背景与需求(Why))
    • [3️⃣ 合规与注意事项(必读)](#3️⃣ 合规与注意事项(必读))
    • [4️⃣ 技术选型与整体流程(What/How)](#4️⃣ 技术选型与整体流程(What/How))
    • [5️⃣ 环境准备与依赖安装(可复现)](#5️⃣ 环境准备与依赖安装(可复现))
    • [6️⃣ 核心实现: 分页处理器](#6️⃣ 核心实现: 分页处理器)
      • 设计要点
      • 完整代码实现
      • 代码详解
        • [1. URL模板替换](#1. URL模板替换)
        • [2. 编码检测与处理](#2. 编码检测与处理)
        • [3. 相对URL转绝对URL](#3. 相对URL转绝对URL)
        • [4. 分页检测的正则表达式](#4. 分页检测的正则表达式)
    • [7️⃣ 核心实现: PDF链接提取器](#7️⃣ 核心实现: PDF链接提取器)
    • [8️⃣ 核心实现: 数据存储与管理](#8️⃣ 核心实现: 数据存储与管理)
      • 数据库设计
      • 代码详解
        • [1. SQLite的UNIQUE约束](#1. SQLite的UNIQUE约束)
        • [2. JSON字段存储](#2. JSON字段存储)
        • [3. 文件名清理的正则替换](#3. 文件名清理的正则替换)
    • [9️⃣ 核心实现: PDF批量下载器](#9️⃣ 核心实现: PDF批量下载器)
      • 设计要点
      • 完整代码实现
      • 代码详解
        • [1. HTTP Range请求原理](#1. HTTP Range请求原理)
        • [2. 多线程下载的线程安全](#2. 多线程下载的线程安全)
        • [3. tqdm进度条的使用](#3. tqdm进度条的使用)
    • [🔟 主程序整合与完整爬虫](#🔟 主程序整合与完整爬虫)
    • [1️⃣1️⃣ 运行示例与效果展示](#1️⃣1️⃣ 运行示例与效果展示)
      • 基础运行示例
        • [1. 爬取单个网站](#1. 爬取单个网站)
        • [2. 爬取并自动下载](#2. 爬取并自动下载)
        • [3. 下载未完成的文件](#3. 下载未完成的文件)
        • [4. 查看统计信息](#4. 查看统计信息)
        • [5. 导出数据](#5. 导出数据)
      • 高级运行场景
        • [场景1: 定时任务(Linux Cron)](#场景1: 定时任务(Linux Cron))
        • [场景2: Windows任务计划](#场景2: Windows任务计划)
        • [场景3: Docker容器运行](#场景3: Docker容器运行)
    • [1️⃣2️⃣ 常见问题与排错指南](#1️⃣2️⃣ 常见问题与排错指南)
      • [Q1: 爬取时显示"403 Forbidden"](#Q1: 爬取时显示"403 Forbidden")
      • [Q2: 部分PDF链接无法下载](#Q2: 部分PDF链接无法下载)
      • [Q3: 数据库插入失败: database is locked](#Q3: 数据库插入失败: database is locked)
      • [Q4: 中文文件名乱码](#Q4: 中文文件名乱码)
      • [Q5: 下载的PDF文件损坏](#Q5: 下载的PDF文件损坏)
      • [Q6: 内存占用过高](#Q6: 内存占用过高)
    • [1️⃣3️⃣ 进阶功能扩展](#1️⃣3️⃣ 进阶功能扩展)
      • [1. 增量更新(只爬取新文件)](#1. 增量更新(只爬取新文件))
      • [2. 智能去重(相似文件检测)](#2. 智能去重(相似文件检测))
      • [3. 全文索引(快速搜索)](#3. 全文索引(快速搜索))
      • [4. 定时监控与通知](#4. 定时监控与通知)
    • [1️⃣4️⃣ 总结与延伸阅读](#1️⃣4️⃣ 总结与延伸阅读)
    • [📋 项目完整结构(最终版)](#📋 项目完整结构(最终版))
    • [🌟 文末](#🌟 文末)
      • [📌 专栏持续更新中|建议收藏 + 订阅](#📌 专栏持续更新中|建议收藏 + 订阅)
      • [✅ 互动征集](#✅ 互动征集)

🌟 开篇语

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

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

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

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

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

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

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

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

1️⃣ 标题 && 摘要

一句话概括: 使用Python自动化爬取政府网站、行业协会、研究机构等平台的政策文件列表,通过智能分页处理批量收集PDF文档链接,并支持自动下载、分类归档和数据库管理。

你能获得:

  • 掌握政务网站的反爬机制识别与规避技巧
  • 学会处理各种复杂的分页逻辑(页码/加载更多/Ajax异步)
  • 实现PDF链接的批量采集与智能去重
  • 构建可扩展的文件分类与归档系统
  • 打造支持断点续传的下载管理器

2️⃣ 背景与需求(Why)

为什么要批量收集政策文件?

作为研究人员、企业合规人员、政策分析师或从业者,我们经常需要查阅大量政策文件:

痛点场景:

  • 效率低下: 手动逐个下载上百份PDF,耗时费力
  • 遗漏风险: 网站更新频繁,容易错过最新政策
  • 整理困难: 文件散落各处,缺乏统一管理
  • 检索不便: 需要时找不到,或不知道某个领域有哪些政策

如果有自动化系统:

  • 批量采集: 一键收集某部委近10年的所有政策文件
  • 实时监控: 定时检查网站更新,新文件自动下载
  • 智能分类: 按年份/类别/主题自动归档
  • 全文检索: 建立本地索引,秒级查找

目标数据源与字段清单

常见数据源:

  1. 政府部门: 工信部、发改委、科技部等官网
  2. 行业协会: 中国互联网协会、中国银行业协会等
  3. 研究机构: 社科院、各大智库
  4. 法律法规库: 国家法律法规数据库、北大法宝

本教程示例站点:

目标字段:

  • 文件标题 (title)
  • 发布日期 (publish_date)
  • 发布部门 (department)
  • 文件编号 (file_number)
  • 文件类型 (通知/意见/办法/规定)
  • PDF链接 (pdf_url)
  • 正文页链接 (detail_url)
  • 附件列表 (attachments)

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

数据使用合规性

政府网站的数据虽然是公开信息,但仍需遵守相关规定:

可以做的:

  • 学术研究和个人学习
  • 企业内部合规参考
  • 非营利性质的政策梳理

不能做的:

  • 商业化转售政策文件数据库
  • 未经授权的二次分发
  • 恶意攻击或占用服务器资源

爬虫礼仪与频率控制

请求频率:

python 复制代码
# 建议配置
REQUEST_DELAY = (3, 8)  # 每次请求间隔3-8秒随机
MAX_CONCURRENT = 1      # 不要使用多线程并发
RETRY_TIMES = 3         # 失败重试3次
TIMEOUT = 30            # 超时时间30秒

User-Agent设置:

python 复制代码
# 使用真实浏览器UA,避免被识别为爬虫
HEADERS = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
    'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
    'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
    'Accept-Encoding': 'gzip, deflate',
    'Connection': 'keep-alive'
}

robots.txt遵守:

python 复制代码
# 检查网站的爬虫规则
import requests

def check_robots(base_url):
    """检查robots.txt"""
    robots_url = f"{base_url}/robots.txt"
    try:
        response = requests.get(robots_url)
        print(response.text)
    except:
        print("未找到robots.txt,请谨慎爬取")

check_robots("https://www.miit.gov.cn")

敏感数据处理

避免爬取敏感信息:

  • 个人隐私数据
  • 内部办公系统
  • 需要登录的非公开文件
  • 涉密或受限文件

标识爬虫身份:

python 复制代码
# 在Headers中添加联系方式(可选但推荐)
HEADERS = {
    'User-Agent': 'PolicyBot/1.0 (Research Purpose; Contact: your@email.com)',
    # ... 其他headers
}

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

技术栈对比

方案 优势 劣势 适用场景
requests + BeautifulSoup 简单易用,覆盖80%场景 无法处理JS渲染 本次推荐
Selenium 处理动态加载 速度慢,资源占用大 复杂JS交互
Scrapy框架 高性能,功能丰富 学习曲线陡峭 大规模分布式爬取
playwright 现代化,支持多浏览器 较新,生态不如Selenium 复杂自动化任务

整体流程架构

json 复制代码
┌─────────────────────┐
│  配置目标网站        │ (config/sites.json)
│  - 列表页URL         │
│  - 详情页URL模板     │
│  - 分页规则          │
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│  网站结构分析        │ → 手动分析或自动探测
│  - 识别分页类型      │
│  - 提取规则生成      │
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│  列表页爬取          │ → 分页处理
│  - 页码翻页          │
│  - 加载更多          │
│  - Ajax异步加载      │
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│  PDF链接提取         │ → 多种链接格式
│  - 直接PDF链接       │
│  - 详情页跳转        │
│  - 附件列表          │
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│  数据清洗与去重      │ → 标准化处理
│  - URL标准化         │
│  - 重复链接过滤      │
│  - 有效性检查        │
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│  存储到数据库        │ → SQLite/MySQL
│  - 文件元数据        │
│  - 下载状态跟踪      │
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│  批量下载            │ → 断点续传
│  - 多线程下载        │
│  - 进度追踪          │
│  - 失败重试          │
└──────────┬──────────┘
           │
           ▼
┌─────────────────────┐
│  自动分类归档        │ → 文件管理
│  - 按年份分类        │
│  - 按部门分类        │
│  - 重命名规范化      │
└─────────────────────┘

为什么选这套技术栈?

  • requests: 轻量级HTTP库,性能优异
  • BeautifulSoup4: HTML解析利器,语法简洁
  • lxml: 高性能XML/HTML解析器
  • SQLite: 零配置数据库,适合个人使用
  • tqdm: 进度条显示,提升用户体验
  • concurrent.futures: Python内置并发库

5️⃣ 环境准备与依赖安装(可复现)

Python版本要求

推荐 Python 3.8+

依赖安装

bash 复制代码
pip install requests beautifulsoup4 lxml tqdm pandas openpyxl

依赖说明:

  • requests: HTTP请求库
  • beautifulsoup4: HTML解析器
  • lxml: 高性能解析引擎
  • tqdm: 进度条工具
  • pandas: 数据处理(可选)
  • openpyxl: Excel导出(可选)

项目目录结构

json 复制代码
policy_scraper/
│
├── config/
│   ├── __init__.py
│   ├── settings.py                # 全局配置
│   └── sites.json                 # 网站配置
│
├── scraper/
│   ├── __init__.py
│   ├── base_scraper.py            # 基础爬虫类
│   ├── list_scraper.py            # 列表页爬虫
│   ├── detail_scraper.py          # 详情页爬虫
│   ├── pagination.py              # 分页处理器
│   └── link_extractor.py          # 链接提取器
│
├── parser/
│   ├── __init__.py
│   ├── html_parser.py             # HTML解析器
│   ├── selector_builder.py        # CSS选择器构建
│   └── field_extractor.py         # 字段提取器
│
├── storage/
│   ├── __init__.py
│   ├── database.py                # 数据库管理
│   └── file_manager.py            # 文件管理器
│
├── downloader/
│   ├── __init__.py
│   ├── pdf_downloader.py          # PDF下载器
│   ├── retry_handler.py           # 重试处理
│   └── progress_tracker.py        # 进度追踪
│
├── utils/
│   ├── __init__.py
│   ├── url_utils.py               # URL工具
│   ├── date_utils.py              # 日期处理
│   └── logger.py                  # 日志工具
│
├── data/
│   ├── policy_files.db            # SQLite数据库
│   ├── downloads/                 # PDF下载目录
│   └── exports/                   # 导出文件
│
├── logs/
│   └── scraper.log                # 运行日志
│
├── main.py                        # 主程序
├── requirements.txt               # 依赖清单
└── README.md                      # 项目说明

配置文件示例

python 复制代码
# config/settings.py
import os

# ========== 基础配置 ==========
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
DATA_DIR = os.path.join(BASE_DIR, 'data')
LOG_DIR = os.path.join(BASE_DIR, 'logs')
DOWNLOAD_DIR = os.path.join(DATA_DIR, 'downloads')

# 创建必要目录
for dir_path in [DATA_DIR, LOG_DIR, DOWNLOAD_DIR]:
    os.makedirs(dir_path, exist_ok=True)

# ========== 请求配置 ==========
HEADERS = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
    'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
    'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
    'Accept-Encoding': 'gzip, deflate',
    'Connection': 'keep-alive',
    'Referer': 'https://www.google.com/'
}

# 请求参数
REQUEST_TIMEOUT = 30         # 超时时间(秒)
REQUEST_DELAY = (3, 8)       # 请求延迟范围(秒)
MAX_RETRIES = 3              # 最大重试次数
RETRY_DELAY = 5              # 重试延迟(秒)

# ========== 数据库配置 ==========
DB_PATH = os.path.join(DATA_DIR, 'policy_files.db')

# ========== 下载配置 ==========
DOWNLOAD_THREADS = 3         # 下载线程数
CHUNK_SIZE = 1024 * 1024    # 分块大小(1MB)
MAX_FILE_SIZE = 100 * 1024 * 1024  # 最大文件100MB

# ========== 日志配置 ==========
LOG_FILE = os.path.join(LOG_DIR, 'scraper.log')
LOG_LEVEL = 'INFO'
LOG_FORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
json 复制代码
// config/sites.json
{
  "sites": [
    {
      "id": "miit",
      "name": "工信部政策文件",
      "base_url": "https://www.miit.gov.cn",
      "list_url": "https://www.miit.gov.cn/zwgk/zcwj/index_{page}.html",
      "pagination": {
        "type": "page_number",
        "start_page": 1,
        "max_pages": 100,
        "page_param": "page"
      },
      "selectors": {
        "item": "ul.files-list li",
        "title": "a",
        "date": "span.date",
        "detail_url": "a::attr(href)",
        "pdf_url": "a[href$='.pdf']::attr(href)"
      },
      "enabled": true
    },
    {
      "id": "gov_cn",
      "name": "国务院政策文件",
      "base_url": "http://www.gov.cn",
      "list_url": "http://www.gov.cn/zhengce/index_{page}.htm",
      "pagination": {
        "type": "ajax",
        "api_url": "http://www.gov.cn/pushinfo/v150203/pushinfo.json",
        "params": {
          "page": "{page}",
          "pageSize": 20
        }
      },
      "selectors": {
        "item": "$.data.list[*]",
        "title": "$.title",
        "date": "$.pubDate",
        "detail_url": "$.url"
      },
      "enabled": false
    }
  ]
}

6️⃣ 核心实现: 分页处理器

设计要点

不同网站的分页机制差异很大:

类型1: 页码翻页 (最常见)

json 复制代码
https://example.com/list_1.html
https://example.com/list_2.html
https://example.com/list?page=3

类型2: "加载更多"按钮 (Ajax异步)

javascript 复制代码
// 点击按钮触发Ajax请求
POST /api/loadMore
{page: 2, pageSize: 20}

类型3: 无限滚动 (自动加载)

javascript 复制代码
// 滚动到底部自动加载
window.addEventListener('scroll', loadMore)

类型4: 无明显分页 (全部一页)

json 复制代码
所有内容在一页,通过滚动查看

完整代码实现

python 复制代码
# scraper/pagination.py
import re
import time
import random
from typing import List, Dict, Optional, Callable
from urllib.parse import urljoin, urlparse, parse_qs, urlencode
import requests
from bs4 import BeautifulSoup
from config.settings import HEADERS, REQUEST_TIMEOUT, REQUEST_DELAY, MAX_RETRIES

class PaginationHandler:
    """分页处理器 - 支持多种分页类型"""
    
    def __init__(self, session: requests.Session = None):
        """
        初始化分页处理器
        
        Args:
            session: requests会话对象(可选)
        """
        self.session = session or requests.Session()
        self.session.headers.update(HEADERS)
    
    def detect_pagination_type(self, url: str, html: str = None) -> str:
        """
        自动检测分页类型
        
        Args:
            url: 页面URL
            html: HTML内容(可选)
            
        Returns:
            分页类型: page_number | load_more | infinite_scroll | single_page
            
        检测逻辑:
            1. 检查URL中是否有page/p等参数 → page_number
            2. 检查是否有"加载更多"按钮 → load_more
            3. 检查是否有Ajax请求 → ajax
            4. 都没有 → single_page
        """
        # 检查URL参数
        parsed = urlparse(url)
        params = parse_qs(parsed.query)
        
        if any(key in params for key in ['page', 'p', 'pageNum', 'pageNo']):
            return 'page_number'
        
        # 检查URL路径中的页码
        if re.search(r'_(\\d+)\\.html?$', url):
            return 'page_number'
        
        # 如果提供了HTML,检查内容
        if html:
            soup = BeautifulSoup(html, 'lxml')
            
            # 检查"加载更多"按钮
            load_more_keywords = ['加载更多', 'load more', '更多', 'more']
            for keyword in load_more_keywords:
                if soup.find(text=re.compile(keyword, re.I)):
                    return 'load_more'
            
            # 检查分页导航
            pagination_selectors = [
                'div.pagination',
                'ul.page-list',
                'div[class*="page"]',
                'a[href*="page"]'
            ]
            for selector in pagination_selectors:
                if soup.select(selector):
                    return 'page_number'
        
        return 'single_page'
    
    def generate_page_urls(
        self,
        template: str,
        start_page: int = 1,
        max_pages: int = 100,
        test_func: Callable = None
    ) -> List[str]:
        """
        生成分页URL列表
        
        Args:
            template: URL模板,使用{page}作为占位符
                例如: "https://example.com/list_{page}.html"
            start_page: 起始页码
            max_pages: 最大页数
            test_func: 测试函数,用于检测页面是否有效
            
        Returns:
            URL列表
            
        实现逻辑:
            1. 从start_page开始生成URL
            2. 如果提供test_func,测试每个URL
            3. 遇到无效页面时停止
            4. 最多生成max_pages个URL
        """
        urls = []
        
        for page in range(start_page, start_page + max_pages):
            url = template.format(page=page)
            
            # 如果提供了测试函数,检查页面是否有效
            if test_func:
                if not test_func(url):
                    print(f"📄 第{page}页无效,停止生成")
                    break
            
            urls.append(url)
        
        return urls
    
    def extract_next_page_url(self, url: str, html: str) -> Optional[str]:
        """
        从HTML中提取下一页URL
        
        Args:
            url: 当前页面URL
            html: HTML内容
            
        Returns:
            下一页URL,如果没有则返回None
            
        提取策略:
            1. 查找"下一页"链接
            2. 查找页码+1的链接
            3. 查找rel="next"的链接
        """
        soup = BeautifulSoup(html, 'lxml')
        
        # 策略1: 查找包含"下一页"文本的链接
        next_keywords = ['下一页', '下页', 'next', '>>', '>']
        for keyword in next_keywords:
            link = soup.find('a', text=re.compile(keyword, re.I))
            if link and link.get('href'):
                return urljoin(url, link['href'])
        
        # 策略2: 查找rel="next"
        link = soup.find('a', rel='next')
        if link and link.get('href'):
            return urljoin(url, link['href'])
        
        # 策略3: 分析分页结构
        # 假设当前页是 "3",查找 "4"
        current_page = self._extract_current_page(url, html)
        if current_page:
            next_page = current_page + 1
            next_link = soup.find('a', text=str(next_page))
            if next_link and next_link.get('href'):
                return urljoin(url, next_link['href'])
        
        return None
    
    def _extract_current_page(self, url: str, html: str = None) -> Optional[int]:
        """
        提取当前页码
        
        Args:
            url: URL
            html: HTML内容(可选)
            
        Returns:
            当前页码,如果无法提取则返回None
        """
        # 从URL提取
        # =3, _3.html
        match = re.search(r'(?:page|p|pageNum|pageNo)=(\d+)', url)
        if match:
            return int(match.group(1))
        
        match = re.search(r'_(\d+)\\.html?$', url)
        if match:
            return int(match.group(1))
        
        # 从HTML提取
        if html:
            soup = BeautifulSoup(html, 'current"或class="active"的链接
            current = soup.find('a', class_=re.compile('current|active'))
            if current and current.text.isdigit():
                return int(current.text)
        
        return None
    
    def crawl_all_pages(
        self,
        start_url: str,
        max_pages: int = 100,
        callback: Callable = None,
        method: str = 'auto'
    ) -> List[Dict]:
        """
        爬取所有分页
        
        Args:
            start_url: 起始URL
            max_pages: 最大页数
            callback: 回调函数,处理每页HTML
            method: 分页方法 auto|template|follow_next
            
        Returns:
            所有页面的数据列表
            
        爬取流程:
            1. 访问第一页
            2. 检测分页类型
            3. 根据类型选择策略
            4. 爬取所有页面
            5. 调用callback处理每页
        """
        all_results = []
        visited_urls = set()
        current_url = start_url
        page_count = 0
        
        print(f"🚀 开始爬取: {start_url}")
        
        while current_url and page_count < max_pages:
            # 避免重复访问
            if current_url in visited_urls:
                print(f"⚠️ 检测到循环,停止爬取")
                break
            
            visited_urls.add(current_url)
            page_count += 1
            
            print(f"📄 正在爬取第 {page_count} 页: {current_url}")
            
            # 请求页面
            try:
                # 随机延时
                time.sleep(random.uniform(*REQUEST_DELAY))
                
                response = self.session.get(current_url, timeout=REQUEST_TIMEOUT)
                response.raise_for_status()
                response.encoding = response.apparent_encoding  # 自动检测编码
                
                html = response.text
                
                # 调用回调处理HTML
                if callback:
                    result = callback(html, current_url)
                    if result:
                        all_results.extend(result if isinstance(result, list) else [result])
                
                # 查找下一页
                next_url = self.extract_next_page_url(current_url, html)
                
                if not next_url:
                    print(f"✅ 已到达最后一页")
                    break
                
                current_url = next_url
                
            except requests.exceptions.RequestException as e:
                print(f"❌ 请求失败: {str(e)}")
                break
        
        print(f"✅ 爬取完成,共 {page_count} 页, {len(all_results)} 条数据")
        return all_results
    
    def handle_ajax_pagination(
        self,
        api_url: str,
        params: Dict,
        max_pages: int = 100,
        callback: Callable = None
    ) -> List[Dict]:
        """
        处理Ajax分页
        
        Args:
            api_url: API接口URL
            params: 请求参数(使用{page}占位符)
            max_pages: 最大页数
            callback: 回调函数处理响应
            
        Returns:
            所有数据列表
            
        示例参数:
            api_url = "https://example.com/api/getPolicies"
            params = {
                "page": "{page}",
                "pageSize": 20,
                "type": "policy"
            }
        """
        all_results = []
        
        for page in range(1, max_pages + 1):
            print(f"📄 正在请求第 {page} 页...")
            
            # 替换参数中的{page}占位符
            current_params = {}
            for key, value in params.items():
                if isinstance(value, str) and '{page}' in value:
                    current_params[key] = value.format(page=page)
                else:
                    current_params[key] = value
            
            try:
                time.sleep(random.uniform(*REQUEST_DELAY))
                
                response = self.session.post(
                    api_url,
                    json=current_params,
                    timeout=REQUEST_TIMEOUT
                )
                response.raise_for_status()
                
                data = response.json()
                
                # 调用回调处理数据
                if callback:
                    result = callback(data, page)
                    if not result:  # 空结果,停止
                        print(f"✅ 第{page}页无数据,停止请求")
                        break
                    all_results.extend(result if isinstance(result, list) else [result])
                
            except Exception as e:
                print(f"❌ 第{page}页请求失败: {str(e)}")
                break
        
        print(f"✅ 共获取 {len(all_results)} 条数据")
        return all_results


# ========== 使用示例 ==========
if __name__ == '__main__':
    handler = PaginationHandler()
    
    # 示例1: 自动检测分页类型
    url = "https://www.miit.gov.cn/zwgk/zcwj/index.html"
    response = requests.get(url)
    pagination_type = handler.detect_pagination_type(url, response.text)
    print(f"检测到分页类型: {pagination_type}")
    
    # 示例2: 生成页码URL
    template = "https://www.miit.gov.cn/zwgk/zcwj/index_{page}.html"
    urls = handler.generate_page_urls(template, start_page=1, max_pages=5)
    for url in urls:
        print(url)
    
    # 示例3: 自动爬取所有页面
    def parse_page(html, url):
        """解析单页"""
        soup = BeautifulSoup(html, 'lxml')
        items = soup.select('ul.files-list li')
        print(f"  找到 {len(items)} 条记录")
        return [{'title': item.get_text(strip=True)} for item in items]
    
    results = handler.crawl_all_pages(
        "https://www.miit.gov.cn/zwgk/zcwj/index.html",
        max_pages=3,
        callback=parse_page
    )
    print(f"共收集 {len(results)} 条数据")

代码详解

1. URL模板替换
python 复制代码
# 方法1: 使用str.format()
template = "https://example.com/list_{page}.html"
url = template.format(page=5)
# 结果: "https://example.com/list_5.html"

# 方法2: 使用正则替换(更灵活)
import re
template = "https://example.com/list?page={page}&size=20"
url = re.sub(r'\{page\}', str(5), template)

# 方法3: 处理查询参数
from urllib.parse import urlencode
base_url = "https://example.com/list"
params = {'page': 5, 'size': 20}
url = f"{base_url}?{urlencode(params)}"
# 结果: "https://example.com/list?page=5&size=20"
2. 编码检测与处理
python 复制代码
response = requests.get(url)

# 问题: 中文乱码
print(response.text)  # 可能显示乱码

# 解决方案1: 自动检测编码
response.encoding = response.apparent_encoding
print(response.text)  # 正常显示

# 解决方案2: 手动指定编码
response.encoding = 'utf-8'  # 或 'gbk', 'gb2312'
print(response.text)

# 检查原始编码
print(f"服务器声明编码: {response.encoding}")
print(f"检测到的编码: {response.apparent_encoding}")

为什么会乱码?

python 复制代码
# 服务器返回的Content-Type头
# Content-Type: text/html; charset=ISO-8859-1

# 但实际内容是UTF-8编码
# requests使用ISO-8859-1解码 → 乱码

# 使用apparent_encoding会检测实际编码
# 内部使用chardet库检测
3. 相对URL转绝对URL
python 复制代码
from urllib.parse import urljoin

base_url = "https://www.example.com/policy/list.html"

# 情况1: 相对路径
relative_url = "../files/doc.pdf"
absolute_url = urljoin(base_url, relative_url)
# 结果: "https://www.example.com/files/doc.pdf"

# 情况2: 根路径
relative_url = "/download/doc.pdf"
absolute_url = urljoin(base_url, relative_url)
# 结果: "https://www.example.com/download/doc.pdf"

# 情况3: 已经是绝对URL
relative_url = "https://other.com/doc.pdf"
absolute_url = urljoin(base_url, relative_url)
# 结果: "https://other.com/doc.pdf" (保持不变)
4. 分页检测的正则表达式
python 复制代码
import re

# 检测URL中的页码模式
patterns = [
    r'page=(\d+)',           # ?page=5
    r'p=(\d+)',              # ?p=5
    r'pageNum=(\d+)',        # ?pageNum=5
    r'_(\d+)\.html?$',       # list_5.html
    r'/(\d+)\.html?$',       # /5.html
]

url = "https://example.com/list_5.html"
for pattern in patterns:
    match = re.search(pattern, url)
    if match:
        page_number = int(match.group(1))
        print(f"检测到页码: {page_number}")
        break

7️⃣ 核心实现: PDF链接提取器

设计要点

PDF链接的位置和格式多种多样:

情况1: 列表页直接提供PDF链接

html 复制代码
<a href="/files/policy_2024_01.pdf">政策文件2024-01</a>

情况2: 需要跳转到详情页

html 复制代码
<a href="/detail?id=12345">政策文件标题</a>
<!-- 详情页中有PDF下载链接 -->

情况3: 多个附件

html 复制代码
<div class="attachments">
    <a href="main.pdf">正文</a>
    <a href="附件1.pdf">附件1</a>
    <a href="附件2.docx">附件2</a>
</div>

情况4: 动态生成链接

javascript 复制代码
// 需要触发JS才能获取真实链接
function downloadPDF(id) {
    location.href = `/api/download?id=${id}&token=xxx`;
}

完整代码实现

python 复制代码
# scraper/link_extractor.py
import re
from typing import List, Dict, Optional
from urllib.parse import urljoin, urlparse
from bs4 import BeautifulSoup
import requests
from config.settings import HEADERS, REQUEST_TIMEOUT

class LinkExtractor:
    """链接提取器 - 专门提取PDF等文件链接"""
    
    def __init__(self, session: requests.Session = None):
        self.session = session or requests.Session()
        self.session.headers.update(HEADERS)
        
        # PDF链接的常见模式
        self.pdf_patterns = [
            r'\.pdf$',
            r'\.PDF$',
            r'/pdf/',
            r'filetype=pdf',
            r'download.*\.pdf',
        ]
    
    def is_pdf_link(self, url: str) -> bool:
        """
        判断URL是否为PDF链接
        
        Args:
            url: URL字符串
            
        Returns:
            是否为PDF链接
            
        判断依据:
            1. URL以.pdf结尾
            2. URL包含/pdf/路径
            3. 查询参数包含filetype=pdf
        """
        url_lower = url.lower()
        
        for pattern in self.pdf_patterns:
            if re.search(pattern, url_lower):
                return True
        
        return False
    
    def extract_from_html(
        self,
        html: str,
        base_url: str,
        selector: str = 'a[href]'
    ) -> List[Dict]:
        """
        从HTML中提取PDF链接
        
        Args:
            html: HTML内容
            base_url: 基础URL(用于转换相对URL)
            selector: CSS选择器,默认所有链接
            
        Returns:
            链接列表,每个元素包含:
            {
                'url': 'https://...',
                'text': '链接文本',
                'title': '标题属性'
            }
        """
        soup = BeautifulSoup(html, 'lxml')
        links = []
        
        # 查找所有匹配的链接
        for tag in soup.select(selector):
            href = tag.get('href')
            if not href:
                continue
            
            # 转为绝对URL
            absolute_url = urljoin(base_url, href)
            
            # 过滤PDF链接
            if self.is_pdf_link(absolute_url):
                links.append({
                    'url': absolute_url,
                    'text': tag.get_text(strip=True),
                    'title': tag.get('title', '')
                })
        
        return links
    
    def extract_from_detail_page(
        self,
        detail_url: str,
        pdf_selector: str = 'a[href$=".pdf"]'
    ) -> List[str]:
        """
        从详情页提取PDF链接
        
        Args:
            detail_url: 详情页URL
            pdf_selector: PDF链接选择器
            
        Returns:
            PDF URL列表
            
        流程:
            1. 请求详情页
            2. 解析HTML
            3. 提取PDF链接
        """
        try:
            response = self.session.get(detail_url, timeout=REQUEST_TIMEOUT)
            response.raise_for_status()
            response.encoding = response.apparent_encoding
            
            soup = BeautifulSoup(response.text, 'lxml')
            pdf_links = []
            
            # 使用CSS选择器查找PDF链接
            for tag in soup.select(pdf_selector):
                href = tag.get('href')
                if href:
                    absolute_url = urljoin(detail_url, href)
                    pdf_links.append(absolute_url)
            
            # 如果没找到,尝试其他方法
            if not pdf_links:
                # 方法2: 查找所有链接,过滤PDF
                for tag in soup.find_all('a', href=True):
                    href = tag['href']
                    if self.is_pdf_link(href):
                        absolute_url = urljoin(detail_url, href)
                        pdf_links.append(absolute_url)
            
            return pdf_links
            
        except Exception as e:
            print(f"❌ 提取详情页PDF失败: {detail_url} | {str(e)}")
            return []
    
    def extract_all_attachments(
        self,
        html: str,
        base_url: str
    ) -> List[Dict]:
        """
        提取所有附件(PDF/DOC/XLS等)
        
        Args:
            html: HTML内容
            base_url: 基础URL
            
        Returns:
            附件列表
        """
        soup = BeautifulSoup(html, 'lxml')
        attachments = []
        
        # 附件文件扩展名
        file_extensions = [
            '.pdf', '.doc', '.docx', '.xls', '.xlsx',
            '.ppt', '.pptx', '.zip', '.rar'
        ]
        
        for tag in soup.find_all('a', href=True):
            href = tag['href']
            
            # 检查是否为附件
            for ext in file_extensions:
                if href.lower().endswith(ext):
                    absolute_url = urljoin(base_url, href)
                    
                    # 提取文件名
                    filename = href.split('/')[-1]
                    
                    # 提取文件类型
                    file_type = ext[1:].upper()  # .pdf → PDF
                    
                    attachments.append({
                        'url': absolute_url,
                        'filename': filename,
                        'type': file_type,
                        'text': tag.get_text(strip=True)
                    })
                    break
        
        return attachments
    
    def validate_pdf_url(self, url: str) -> bool:
        """
        验证PDF URL是否有效
        
        Args:
            url: PDF URL
            
        Returns:
            是否有效
            
        验证方法:
            发送HEAD请求,检查:
            1. HTTP状态码是否为200
            2. Content-Type是否为application/pdf
            3. Content-Length是否合理(>0且<100MB)
        """
        try:
            response = self.session.head(url, timeout=10, allow_redirects=True)
            
            # 检查状态码
            if response.status_code != 200:
                return False
            
            # 检查Content-Type
            content_type = response.headers.get('Content-Type', '')
            if 'application/pdf' not in content_type:
                # 有些网站不返回正确的Content-Type,放宽限制
                print(f"⚠️ Content-Type不是PDF: {content_type}")
            
            # 检查文件大小
            content_length = response.headers.get('Content-Length')
            if content_length:
                size = int(content_length)
                if size == 0 or size > 100 * 1024 * 1024:  # 大于100MB
                    print(f"⚠️ 文件大小异常: {size} bytes")
                    return False
            
            return True
            
        except Exception as e:
            print(f"❌ 验证失败: {url} | {str(e)}")
            return False
    
    def extract_with_pattern(
        self,
        html: str,
        base_url: str,
        pattern: str
    ) -> List[str]:
        """
        使用正则表达式提取链接
        
        Args:
            html: HTML内容
            base_url: 基础URL
            pattern: 正则表达式模式
            
        Returns:
            链接列表
            
        示例:
            # 提取所有以policy开头的PDF
            pattern = r'href="([^"]*policy[^"]*\.pdf)"'
        """
        matches = re.findall(pattern, html)
        
        urls = []
        for match in matches:
            absolute_url = urljoin(base_url, match)
            urls.append(absolute_url)
        
        return urls


# ========== 使用示例 ==========
if __name__ == '__main__':
    extractor = LinkExtractor()
    
    # 示例HTML
    html = '''
    <div class="policy-list">
        <ul>
            <li><a href="/files/policy_2024_001.pdf">2024年第1号政策</a></li>
            <li><a href="/detail?id=2">2024年第2号政策</a></li>
            <li><a href="https://example.com/doc.pdf" title="重要文件">重要政策</a></li>
        </ul>
    </div>
    '''
    
    base_url = "https://www.example.com/policy/"
    
    # 提取PDF链接
    pdf_links = extractor.extract_from_html(html, base_url)
    
    print(f"找到 {len(pdf_links)} 个PDF链接:")
    for link in pdf_links:
        print(f"- {link['text']}: {link['url']}")
    
    # 提取所有附件
    attachments = extractor.extract_all_attachments(html, base_url)
    print(f"\n找到 {len(attachments)} 个附件:")
    for att in attachments:
        print(f"- [{att['type']}] {att['filename']}")
    
    # 验证URL
    test_url = "https://www.example.com/test.pdf"
    if extractor.validate_pdf_url(test_url):
        print(f"✅ URL有效: {test_url}")
    else:
        print(f"❌ URL无效: {test_url}")

8️⃣ 核心实现: 数据存储与管理

数据库设计

python 复制代码
# storage/database.py
import sqlite3
from datetime import datetime
from typing import List, Dict, Optional
from config.settings import DB_PATH
import json

class PolicyDatabase:
    """政策文件数据库管理类"""
    
    def __init__(self, db_path: str = DB_PATH):
        """
        初始化数据库
        
        Args:
            db_path: 数据库文件路径
        """
        self.db_path = db_path
        self._init_database()
    
    def _init_database(self):
        """
        初始化数据库表结构
        
        表设计说明:
            1. policy_files: 政策文件主表
            2. pdf_downloads: PDF下载记录表
            3. sites: 数据源网站表
            4. crawl_log: 爬取日志表
        """
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        # 创建政策文件表
        cursor.execute('''
        CREATE TABLE IF NOT EXISTS policy_files (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            
            -- 基础信息
            title TEXT NOT NULL,                    -- 文件标题
            file_number TEXT,                       -- 文件编号(如:工信部[2024]1号)
            department TEXT,                        -- 发布部门
            publish_date TEXT,                      -- 发布日期
            file_type TEXT,                         -- 文件类型(通知/意见/办法等)
            
            -- URL信息
            detail_url TEXT,                        -- 详情页URL
            pdf_url TEXT,                           -- PDF下载链接
            
            -- 附件信息
            attachments TEXT,                       -- 附件列表(JSON格式)
            
            -- 来源信息
            source_site TEXT NOT NULL,              -- 来源网站ID
            source_url TEXT,                        -- 来源页面URL
            
            -- 下载状态
            is_downloaded BOOLEAN DEFAULT 0,        -- 是否已下载
            download_path TEXT,                     -- 本地存储路径
            file_size INTEGER DEFAULT 0,            -- 文件大小(字节)
            
            -- 分类信息
            category TEXT,                          -- 分类(如:工业/科技/金融)
            tags TEXT,                              -- 标签(JSON数组)
            year INTEGER,                           -- 年份
            
            -- 管理字段
            created_at TEXT NOT NULL,               -- 创建时间
            updated_at TEXT,                        -- 更新时间
            
            -- 索引
            UNIQUE(pdf_url)                         -- PDF URL唯一
        )
        ''')
        
        # 创建索引(加速查询)
        cursor.execute('CREATE INDEX IF NOT EXISTS idx_department ON policy_files(department)')
        cursor.execute('CREATE INDEX IF NOT EXISTS idx_publish_date ON policy_files(publish_date DESC)')
        cursor.execute('CREATE INDEX IF NOT EXISTS idx_source_site ON policy_files(source_site)')
        cursor.execute('CREATE INDEX IF NOT EXISTS idx_year ON policy_files(year)')
        cursor.execute('CREATE INDEX IF NOT EXISTS idx_is_downloaded ON policy_files(is_downloaded)')
        
        # 创建下载记录表
        cursor.execute('''
        CREATE TABLE IF NOT EXISTS pdf_downloads (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            policy_id INTEGER NOT NULL,             -- 关联的政策文件ID
            pdf_url TEXT NOT NULL,                  -- PDF URL
            download_start TEXT,                    -- 下载开始时间
            download_end TEXT,                      -- 下载结束时间
            status TEXT DEFAULT 'pending',          -- 状态(pending/downloading/success/failed)
            error_msg TEXT,                         -- 错误信息
            retry_count INTEGER DEFAULT 0,          -- 重试次数
            
            FOREIGN KEY (policy_id) REFERENCES policy_files(id)
        )
        ''')
        
        # 创建数据源表
        cursor.execute('''
        CREATE TABLE IF NOT EXISTS sites (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            site_id TEXT UNIQUE NOT NULL,           -- 站点ID(如miit)
            site_name TEXT NOT NULL,                -- 站点名称
            base_url TEXT NOT NULL,                 -- 基础URL
            last_crawl_time TEXT,                   -- 最后爬取时间
            total_files INTEGER DEFAULT 0,          -- 总文件数
            enabled BOOLEAN DEFAULT 1,              -- 是否启用
            
            UNIQUE(site_id)
        )
        ''')
        
        # 创建爬取日志表
        cursor.execute('''
        CREATE TABLE IF NOT EXISTS crawl_log (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            site_id TEXT NOT NULL,                  -- 站点ID
            crawl_start TEXT NOT NULL,              -- 开始时间
            crawl_end TEXT,                         -- 结束时间
            pages_crawled INTEGER DEFAULT 0,        -- 爬取页数
            files_found INTEGER DEFAULT 0,          -- 发现文件数
            files_new INTEGER DEFAULT 0,            -- 新增文件数
            status TEXT DEFAULT 'running',          -- 状态(running/success/failed)
            error_msg TEXT                          -- 错误信息
        )
        ''')
        
        conn.commit()
        conn.close()
        
        print(f"✅ 数据库初始化完成: {self.db_path}")
    
    def insert_policy(self, data: Dict) -> int:
        """
        插入政策文件记录
        
        Args:
            data: 政策数据字典
            
        Returns:
            插入的记录ID,如果已存在则返回0
        """
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        try:
            current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
            
            # 提取年份
            year = None
            if data.get('publish_date'):
                try:
                    year = int(data['publish_date'][:4])
                except:
                    pass
            
            # 将列表转为JSON字符串
            attachments = json.dumps(data.get('attachments', []), ensure_ascii=False)
            tags = json.dumps(data.get('tags', []), ensure_ascii=False)
            
            cursor.execute('''
            INSERT INTO policy_files (
                title, file_number, department, publish_date, file_type,
                detail_url, pdf_url, attachments,
                source_site, source_url,
                category, tags, year,
                created_at
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            ''', (
                data.get('title'),
                data.get('file_number'),
                data.get('department'),
                data.get('publish_date'),
                data.get('file_type'),
                data.get('detail_url'),
                data.get('pdf_url'),
                attachments,
                data.get('source_site'),
                data.get('source_url'),
                data.get('category'),
                tags,
                year,
                current_time
            ))
            
            conn.commit()
            record_id = cursor.lastrowid
            
            return record_id
            
        except sqlite3.IntegrityError:
            # PDF URL已存在,跳过
            return 0
        except Exception as e:
            print(f"❌ 插入数据失败: {str(e)}")
            return 0
        finally:
            conn.close()
    
    def batch_insert(self, data_list: List[Dict]) -> int:
        """
        批量插入
        
        Args:
            data_list: 数据列表
            
        Returns:
            成功插入的数量
        """
        success_count = 0
        for data in data_list:
            if self.insert_policy(data):
                success_count += 1
        
        return success_count
    
    def is_pdf_exists(self, pdf_url: str) -> bool:
        """检查PDF URL是否已存在"""
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        cursor.execute('SELECT COUNT(*) FROM policy_files WHERE pdf_url = ?', (pdf_url,))
        count = cursor.fetchone()[0]
        
        conn.close()
        return count > 0
    
    def get_undownloaded_files(self, limit: int = 100) -> List[Dict]:
        """
        获取未下载的文件列表
        
        Args:
            limit: 最大返回数量
            
        Returns:
            文件列表
        """
        conn = sqlite3.connect(self.db_path)
        conn.row_factory = sqlite3.Row
        cursor = conn.cursor()
        
        cursor.execute('''
        SELECT * FROM policy_files
        WHERE is_downloaded = 0 AND pdf_url IS NOT NULL AND pdf_url != ''
        ORDER BY publish_date DESC
        LIMIT ?
        ''', (limit,))
        
        results = []
        for row in cursor.fetchall():
            policy = dict(row)
            # 解析JSON字段
            policy['attachments'] = json.loads(policy['attachments'])
            policy['tags'] = json.loads(policy['tags']) if policy['tags'] else []
            results.append(policy)
        
        conn.close()
        return results
    
    def mark_as_downloaded(self, policy_id: int, file_path: str, file_size: int) -> bool:
        """
        标记文件为已下载
        
        Args:
            policy_id: 政策文件ID
            file_path: 本地文件路径
            file_size: 文件大小
            
        Returns:
            是否成功
        """
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        try:
            current_time = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
            
            cursor.execute('''
            UPDATE policy_files
            SET is_downloaded = 1, download_path = ?, file_size = ?, updated_at = ?
            WHERE id = ?
            ''', (file_path, file_size, current_time, policy_id))
            
            conn.commit()
            return True
            
        except Exception as e:
            print(f"❌ 标记下载失败: {str(e)}")
            return False
        finally:
            conn.close()
    
    def search_by_keyword(self, keyword: str, limit: int = 50) -> List[Dict]:
        """
        关键词搜索
        
        Args:
            keyword: 关键词
            limit: 返回数量
            
        Returns:
            搜索结果
        """
        conn = sqlite3.connect(self.db_path)
        conn.row_factory = sqlite3.Row
        cursor = conn.cursor()
        
        search_pattern = f'%{keyword}%'
        
        cursor.execute('''
        SELECT * FROM policy_files
        WHERE title LIKE ? OR file_number LIKE ? OR department LIKE ?
        ORDER BY publish_date DESC
        LIMIT ?
        ''', (search_pattern, search_pattern, search_pattern, limit))
        
        results = [dict(row) for row in cursor.fetchall()]
        conn.close()
        
        return results
    
    def get_statistics(self) -> Dict:
        """获取统计信息"""
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        stats = {}
        
        # 总文件数
        cursor.execute('SELECT COUNT(*) FROM policy_files')
        stats['total_files'] = cursor.fetchone()[0]
        
        # 已下载数
        cursor.execute('SELECT COUNT(*) FROM policy_files WHERE is_downloaded = 1')
        stats['downloaded_files'] = cursor.fetchone()[0]
        
        # 按部门统计
        cursor.execute('''
        SELECT department, COUNT(*) as count
        FROM policy_files
        WHERE department IS NOT NULL AND department != ''
        GROUP BY department
        ORDER BY count DESC
        LIMIT 10
        ''')
        stats['by_department'] = {row[0]: row[1] for row in cursor.fetchall()}
        
        # 按年份统计
        cursor.execute('''
        SELECT year, COUNT(*) as count
        FROM policy_files
        WHERE year IS NOT NULL
        GROUP BY year
        ORDER BY year DESC
        ''')
        stats['by_year'] = {row[0]: row[1] for row in cursor.fetchall()}
        
        # 按站点统计
        cursor.execute('''
        SELECT source_site, COUNT(*) as count
        FROM policy_files
        GROUP BY source_site
        ''')
        stats['by_site'] = {row[0]: row[1] for row in cursor.fetchall()}
        
        conn.close()
        return stats
    
    def export_to_excel(self, output_path: str, filters: Dict = None):
        """
        导出到Excel
        
        Args:
            output_path: 输出文件路径
            filters: 过滤条件
        """
        import pandas as pd
        
        conn = sqlite3.connect(self.db_path)
        
        # 构建查询
        query = 'SELECT * FROM policy_files'
        where_clauses = []
        params = []
        
        if filters:
            if filters.get('department'):
                where_clauses.append('department = ?')
                params.append(filters['department'])
            
            if filters.get('year'):
                where_clauses.append('year = ?')
                params.append(filters['year'])
            
            if filters.get('is_downloaded') is not None:
                where_clauses.append('is_downloaded = ?')
                params.append(1 if filters['is_downloaded'] else 0)
        
        if where_clauses:
            query += ' WHERE ' + ' AND '.join(where_clauses)
        
        query += ' ORDER BY publish_date DESC'
        
        # 读取数据
        df = pd.read_sql_query(query, conn, params=params)
        conn.close()
        
        # 导出Excel
        df.to_excel(output_path, index=False, engine='openpyxl')
        print(f"✅ 导出成功: {output_path} ({len(df)} 条记录)")


# ========== 文件管理器 ==========
# storage/file_manager.py
import os
import shutil
from pathlib import Path
from typing import Dict

class FileManager:
    """文件管理器 - 负责文件的分类存储"""
    
    def __init__(self, base_dir: str):
        """
        初始化文件管理器
        
        Args:
            base_dir: 基础目录
        """
        self.base_dir = Path(base_dir)
        self.base_dir.mkdir(parents=True, exist_ok=True)
    
    def get_save_path(
        self,
        policy_data: Dict,
        organize_by: str = 'year'
    ) -> Path:
        """
        生成保存路径
        
        Args:
            policy_data: 政策数据
            organize_by: 组织方式(year/department/site)
            
        Returns:
            完整的保存路径
            
        目录结构示例:
            downloads/
            ├── 2024/
            │   ├── 工信部_政策文件_20240115.pdf
            │   └── 发改委_通知_20240120.pdf
            ├── 2023/
            └── ...
        """
        # 根据组织方式创建子目录
        if organize_by == 'year':
            year = policy_data.get('year', 'unknown')
            subdir = self.base_dir / str(year)
        
        elif organize_by == 'department':
            department = policy_data.get('department', 'unknown')
            # 清理部门名称中的特殊字符
            department = self._clean_filename(department)
            subdir = self.base_dir / department
        
        elif organize_by == 'site':
            site = policy_data.get('source_site', 'unknown')
            subdir = self.base_dir / site
        
        else:
            subdir = self.base_dir
        
        subdir.mkdir(parents=True, exist_ok=True)
        
        # 生成文件名
        filename = self._generate_filename(policy_data)
        
        return subdir / filename
    
    def _generate_filename(self, policy_data: Dict) -> str:
        """
        生成标准化的文件名
        
        格式: 部门_标题_日期.pdf
        
        Args:
            policy_data: 政策数据
            
        Returns:
            文件名
        """
        department = policy_data.get('department', '')
        title = policy_data.get('title', 'untitled')
        date = policy_data.get('publish_date', '')
        
        # 清理文件名(移除非法字符)
        department = self._clean_filename(department)
        title = self._clean_filename(title)
        date = date.replace('-', '') if date else ''
        
        # 限制长度
        title = title[:50] if len(title) > 50 else title
        
        # 组合文件名
        if department and date:
            filename = f"{department}_{title}_{date}.pdf"
        elif department:
            filename = f"{department}_{title}.pdf"
        else:
            filename = f"{title}.pdf"
        
        return filename
    
    def _clean_filename(self, filename: str) -> str:
        """
        清理文件名中的非法字符
        
        Args:
            filename: 原始文件名
            
        Returns:
            清理后的文件名
        """
        # Windows和Linux的非法字符
        illegal_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|']
        
        for char in illegal_chars:
            filename = filename.replace(char, '_')
        
        # 移除首尾空格
        filename = filename.strip()
        
        return filename
    
    def move_file(self, source: str, policy_data: Dict) -> str:
        """
        移动文件到正确的目录
        
        Args:
            source: 源文件路径
            policy_data: 政策数据
            
        Returns:
            新文件路径
        """
        target_path = self.get_save_path(policy_data)
        
        # 如果文件已存在,添加序号
        if target_path.exists():
            stem = target_path.stem
            suffix = target_path.suffix
            counter = 1
            while target_path.exists():
                target_path = target_path.parent / f"{stem}_{counter}{suffix}"
                counter += 1
        
        # 移动文件
        shutil.move(source, target_path)
        
        return str(target_path)
    
    def get_storage_stats(self) -> Dict:
        """获取存储统计信息"""
        total_size = 0
        file_count = 0
        
        for file_path in self.base_dir.rglob('*.pdf'):
            total_size += file_path.stat().st_size
            file_count += 1
        
        return {
            'total_files': file_count,
            'total_size_mb': total_size / (1024 * 1024),
            'avg_size_mb': (total_size / file_count / (1024 * 1024)) if file_count > 0 else 0
        }

代码详解

1. SQLite的UNIQUE约束
python 复制代码
# 创建表时添加UNIQUE约束
CREATE TABLE policy_files (
    id INTEGER PRIMARY KEY,
    pdf_url TEXT,
    UNIQUE(pdf_url)  # PDF URL必须唯一
)

# 插入时处理重复
try:
    cursor.execute('INSERT INTO policy_files (...) VALUES (...)')
except sqlite3.IntegrityError:
    # PDF URL已存在
    print("记录已存在,跳过")

什么使用UNIQUE而不是先查询?

python 复制代码
# ❌ 低效方法(两次数据库操作)

if not db.is_pdf_exists(url):
db.insert_policy(data)

# ✅ 高效方法(一次操作,数据库保证唯一性)

try:
db.insert_policy(data)
except IntegrityError:
pass  # 已存在
2. JSON字段存储
python 复制代码
# 存储复杂数据结构
attachments = [
    {'url': 'file1.pdf', 'name': '附件1'},
    {'url': 'file2.pdf', 'name': '附件2'}
]

# 转为JSON字符串存储
import json
attachments_json = json.dumps(attachments, ensure_ascii=False)
# 结果: '[{"url":"file1.pdf","name":"附件1"},{"url":"file2.pdf","name":"附件2"}]'

# 插入数据库
cursor.execute('INSERT INTO policy_files (attachments) VALUES (?)', (attachments_json,))

# 读取时解析
cursor.execute('SELECT attachments FROM policy_files WHERE id = 1')
row = cursor.fetchone()
attachments = json.loads(row[0])
# 恢复为Python列表

为什么用JSON而不是单独建表?

python 复制代码
# 方案1: JSON字段(简单,适合附件数量少)
# 优点: 查询简单,不需要JOIN
# 缺点: 不能对附件单独查询/排序

# 方案2: 单独建表(复杂,适合需要单独操作附件)
CREATE TABLE attachments (
    id INTEGER PRIMARY KEY,
    policy_id INTEGER,
    attachment_url TEXT,
    FOREIGN KEY (policy_id) REFERENCES policy_files(id)
)

# 优点: 可以单独查询/统计附件
# 缺点: 查询需要JOIN,代码更复杂
3. 文件名清理的正则替换
python 复制代码
import re

def clean_filename(filename: str) -> str:
    """清理文件名"""
    # 方法1: 逐个替换
    illegal_chars = ['/', '\\', ':', '*', '?', '"', '<', '>', '|']
    for char in illegal_chars:
        filename = filename.replace(char, '_')
    
    # 方法2: 正则替换(更高效)
    filename = re.sub(r'[/\\:*?"<>|]', '_', filename)
    
    # 移除连续的下划线
    filename = re.sub(r'_{2,}', '_', filename)
    
    # 移除首尾空格和下划线
    filename = filename.strip(' _')
    
    return filename

# 测试
test = '工信部:关于<AI发展>的通知*2024.pdf'
clean = clean_filename(test)
# 结果: '工信部_关于_AI发展_的通知_2024.pdf'

9️⃣ 核心实现: PDF批量下载器

设计要点

批量下载需要考虑:

  • 并发控制: 多线程下载提升速度
  • 断点续传: 大文件下载中断后可继续
  • 进度显示: 实时显示下载进度
  • 失败重试: 自动重试失败的下载
  • 速度限制: 避免占用过多带宽

完整代码实现

python 复制代码
# downloader/pdf_downloader.py
import os
import time
import requests
from pathlib import Path
from typing import Dict, Optional, Callable
from concurrent.futures import ThreadPoolExecutor, as_completed
from tqdm import tqdm
from config.settings import (
    HEADERS, REQUEST_TIMEOUT, DOWNLOAD_THREADS,
    CHUNK_SIZE, MAX_FILE_SIZE
)

class PDFDownloader:
    """PDF下载器 - 支持断点续传和多线程"""
    
    def __init__(
        self,
        download_dir: str,
        max_workers: int = DOWNLOAD_THREADS,
        session: requests.Session = None
    ):
        """
        初始化下载器
        
        Args:
            download_dir: 下载目录
            max_workers: 最大线程数
            session: requests会话对象
        """
        self.download_dir = Path(download_dir)
        self.download_dir.mkdir(parents=True, exist_ok=True)
        
        self.max_workers = max_workers
        self.session = session or requests.Session()
        self.session.headers.update(HEADERS)
    
    def download_file(
        self,
        url: str,
        filename: str = None,
        resume: bool = True,
        progress_callback: Callable = None
    ) -> Optional[str]:
        """
        下载单个文件
        
        Args:
            url: 文件URL
            filename: 保存文件名(可选,默认从URL提取)
            resume: 是否支持断点续传
            progress_callback: 进度回调函数
            
        Returns:
            下载后的文件路径,失败返回None
            
        断点续传原理:
            1. 检查本地是否有未完成的文件
            2. 获取已下载的大小
            3. 使用Range头请求剩余部分
            4. 追加写入文件
        """
        try:
            # 生成文件名
            if not filename:
                filename = url.split('/')[-1]
                if '?' in filename:
                    filename = filename.split('?')[0]
            
            filepath = self.download_dir / filename
            
            # 检查文件是否已存在
            if filepath.exists() and not resume:
                print(f"⚠️ 文件已存在,跳过: {filename}")
                return str(filepath)
            
            # 获取文件大小
            head_response = self.session.head(url, timeout=10, allow_redirects=True)
            total_size = int(head_response.headers.get('Content-Length', 0))
            
            # 检查文件大小
            if total_size > MAX_FILE_SIZE:
                print(f"1024 / 1024:.1f}MB),跳过: {filename}")
                return None
            
            # 检查是否支持Range请求(断点续传)
            accept_ranges = head_response.headers.get('Accept-Ranges', 'none')
            supports_resume = accept_ranges != 'none'
            
            # 获取已下载的大小
            downloaded_size = filepath.stat().st_size if filepath.exists() else 0
            
            # 如果已下载完成
            if downloaded_size == total_size and total_size > 0:
                print(f"✅ 文件已完整下载: {filename}")
                return str(filepath)
            
            # 设置Range头(断点续传)
            headers = dict(HEADERS)
            if supports_resume and downloaded_size > 0 and resume:
                headers['Range'] = f'bytes={downloaded_size}-'
                mode = 'ab'  # 追加模式
                print(f"🔄 继续下载: {filename} (已下载 {downloaded_size / 1024 / 1024:.1f}MB)")
            else:
                downloaded_size = 0
                mode = 'wb'  # 覆盖模式
            
            # 开件
            with open(filepath, mode) as f:
                # 使用tqdm显示进度
                with tqdm(
                    total=total_size,
                    initial=downloaded_size,
                    unit='B',
                    unit_scale=True,
                    desc=filename[:30]
                ) as pbar:
                    
                    for chunk in response.iter_content(chunk_size=CHUNK_SIZE):
                        if chunk:
                            f.write(chunk)
                            pbar.update(len(chunk))
                            
                            # 调用进度回调
                            if progress_callback:
                                progress_callback(len(chunk), total_size)
            
            # 验证文件大小
            final_size = filepath.stat().st_size
            if total_size > 0 and final_size != total_size:
                print(f"⚠️ 文件大小不匹配: 预期{total_size}, 实际{final_size}")
            
            print(f"✅ 下载成功: {filename} ({final_size / 1024 / 1024:.2f}MB)")
            return str(filepath)
            
        except requests.exceptions.RequestException as e:
            print(f"❌ 下载失败: {url} | {str(e)}")
            return None
        except Exception as e:
            print(f"❌ 未知错误: {url} | {str(e)}")
            return None
    
    def download_with_retry(
        self,
        url: str,
        filename: str = None,
        max_retries: int = 3
    ) -> Optional[str]:
        """
        带重试的下载
        
        Args:
            url: 文件URL
            filename: 文件名
            max_retries: 最大重试次数
            
        Returns:
            文件路径或None
        """
        for attempt in range(1, max_retries + 1):
            print(f"🔄 下载尝试 {attempt}/{max_retries}: {filename or url}")
            
            result = self.download_file(url, filename, resume=True)
            
            if result:
                return result
            
            if attempt < max_retries:
                wait_time = 2 ** attempt  # 指数退避: 2, 4, 8秒
                print(f"⏱️ 等待 {wait_time} 秒后重试...")
                time.sleep(wait_time)
        
        print(f"❌ 达到最大重试次数,下载失败: {url}")
        return None
    
    def batch_download(
        self,
        urls: list,
        filenames: list = None,
        max_workers: int = None
    ) -> Dict:
        """
        批量下载
        
        Args:
            urls: URL列表
            filenames: 文件名列表(可选)
            max_workers: 线程数(可选)
            
        Returns:
            下载结果统计
        """
        if filenames and len(filenames) != len(urls):
            raise ValueError("文件名列表长度必须与URL列表相同")
        
        if not filenames:
            filenames = [None] * len(urls)
        
        workers = max_workers or self.max_workers
        results = {
            'success': [],
            'failed': [],
            'total': len(urls)
        }
        
        print(f"\n{'='*60}")
        print(f"📦 开始批量下载: {len(urls)} 个文件")
        print(f"🔧 线程数: {workers}")
        print(f"{'='*60}\n")
        
        # 使用线程池
        with ThreadPoolExecutor(max_workers=workers) as executor:
            # 提交所有任务
            future_to_url = {
                executor.submit(self.download_with_retry, url, filename): (url, filename)
                for url, filename in zip(urls, filenames)
            }
            
            # 处理完成的任务
            for future in as_completed(future_to_url):
                url, filename = future_to_url[future]
                
                try:
                    filepath = future.result()
                    
                    if filepath:
                        results['success'].append({
                            'url': url,
                            'filename': filename,
                            'filepath': filepath
                        })
                    else:
                        results['failed'].append({
                            'url': url,
                            'filename': filename,
                            'error': '下载失败'
                        })
                
                except Exception as e:
                    results['failed'].append({
                        'url': url,
                        'filename': filename,
                        'error': str(e)
                    })
        
        # 打印统计
        print(f"\n{'='*60}")
        print(f"📊 下载完成:")
        print(f"  ✅ 成功: {len(results['success'])} 个")
        print(f"  ❌ 失败: {len(results['failed'])} 个")
        print(f"  📈 成功率: {len(results['success']) / len(urls) * 100:.1f}%")
        print(f"{'='*60}\n")
        
        return results


# downloader/progress_tracker.py
from typing import Dict
from datetime import datetime
import json

class DownloadProgressTracker:
    """下载进度追踪器"""
    
    def __init__(self, save_file: str = 'data/download_progress.json'):
        """
        初始化进度追踪器
        
        Args:
            save_file: 进度保存文件
        """
        self.save_file = save_file
        self.progress = self._load_progress()
    
    def _load_progress(self) -> Dict:
        """加载进度"""
        try:
            with open(self.save_file, 'r', encoding='utf-8') as f:
                return json.load(f)
        except:
            return {}
    
    def _save_progress(self):
        """保存进度"""
        with open(self.save_file, 'w', encoding='utf-8') as f:
            json.dump(self.progress, f, ensure_ascii=False, indent=2)
    
    def mark_started(self, url: str):
        """标记开始下载"""
        self.progress[url] = {
            'status': 'downloading',
            'start_time': datetime.now().isoformat(),
            'end_time': None,
            'error': None
        }
        self._save_progress()
    
    def mark_completed(self, url: str, filepath: str):成"""
        if url in self.progress:
            self.progress[url]['status'] = 'completed'
            self.progress[url]['end_time'] = datetime.now().isoformat()
            self.progress[url]['filepath'] = filepath
            self._save_progress()
    
    def mark_failed(self, url: str, error: str):
        """标记下载失败"""
        if url in self.progress:
            self.progress[url]['status'] = 'failed'
            self.progress[url]['end_time'] = datetime.now().isoformat()
            self.progress[url]['error'] = error
            self._save_progress()
    
    def is_completed(self, url: str) -> bool:
        """检查是否已完成"""
        return self.progress.get(url, {}).get('status') == 'completed'
    
    def get_failed_urls(self) -> list:
        """获取失败的URL列表"""
        return [
            url for url, info in self.progress.items()
            if info.get('status') == 'failed'
        ]


# ========== 使用示例 ==========
if __name__ == '__main__':
    downloader = PDFDownloader('data/downloads')
    
    # 示例1: 下载单个文件
    url = "https://example.com/policy.pdf"
    filepath = downloader.download_file(url)
    
    if filepath:
        print(f"下载成功: {filepath}")
    
    # 示例2: 批量下载
    urls = [
        "https://example.com/file1.pdf",
        "https://example.com/file2.pdf",
        "https://example.com/file3.pdf"
    ]
    
    results = downloader.batch_download(urls, max_workers=3)
    
    # 示例3: 使用进度追踪
    tracker = DownloadProgressTracker()
    
    for url in urls:
        if tracker.is_completed(url):
            print(f"跳过已下载: {url}")
            continue
        
        tracker.mark_started(url)
        
        filepath = downloader.download_with_retry(url)
        
        if filepath:
            tracker.mark_completed(url, filepath)
        else:
            tracker.mark_failed(url, "下载失败")
    
    # 重试失败的
    failed_urls = tracker.get_failed_urls()
    if failed_urls:
        print(f"\n重试 {len(failed_urls)} 个失败的下载...")
        downloader.batch_download(failed_urls)

代码详解

1. HTTP Range请求原理
python 复制代码
# 假设文件总大小为10MB,已下载5MB

# 1. 发送HEAD请求获取文件信息
response = requests.head(url)
total_size = int(response.headers['Content-Length'])  # 10485760 字节
accept_ranges = response.headers.get('Accept-Ranges')  # 'bytes'

# 2. 检查本地文件大小
downloaded_size = os.path.getsize('file.pdf')  # 5242880 字节 (5MB)

# 3. 发送Range请求
headers = {
    'Range': f'bytes={downloaded_size}-'  # 'bytes=5242880-'
}
response = requests.get(url, headers=headers, stream=True)

# 服务器返回:
# HTTP/1.1 206 Partial Content
# Content-Range: bytes 5242880-10485759/10485760
# Content-Length: 5242880

# 4. 追加写入文件
with open('file.pdf', 'ab') as f:  # 'ab' = append binary
    for chunk in response.iter_content(1024):
        f.write(chunk)

为什么有些网站不支持断点续传?

python 复制代码
# 检查Accept-Ranges
response = requests.head(url)
accept_ranges = response.headers.get('Accept-Ranges', 'none')

if accept_ranges == 'none':
    print("不支持断点续传")
    # 必须重新下载

elif accept_ranges == 'bytes':
    print("支持断点续传")
    # 可以使用Range头
2. 多线程下载的线程安全
python 复制代码
from concurrent.futures import ThreadPoolExecutor
import threading

# 问题: 多个线程写入同一个文件会冲突
lock = threading.Lock()

def download_chunk(url, start, end, filepath):
    """下载文件的一部分"""
    headers = {'Range': f'bytes={start}-{end}'}
    response = requests.get(url, headers=headers)
    
    # 使用锁保证线程安全
    with lock:
        with open(filepath, 'r+b') as f:
            f.seek(start)  # 定位到起始位置
            f.write(response.content)

# 将10MB文件分成4个块,4个线程并发下载
total_size = 10 * 1024 * 1024
chunk_size = total_size // 4

with ThreadPoolExecutor(max_workers=4) as executor:
    for i in range(4):
        start = i * chunk_size
        end = start + chunk_size - 1
        executor.submit(download_chunk, url, start, end, 'file.pdf')

为什么本教程没用分块并发下载?

python 复制代码
# 分块下载的限制:
# 1. 服务器必须支持Range请求
# 2. 实现复杂,容易出错
# 3. 对于小文件(几MB)提升不明显
# 4. 可能被服务器识别为攻击

# 本教程采用:
# - 单文件单线程下载(更稳定)
# - 多文件多线程并发(提升总体速度)
3. tqdm进度条的使用
python 复制代码
from tqdm import tqdm
import time

# 基础用法
with tqdm(total=100) as pbar:
    for i in range(100):
        time.sleep(0.1)
        pbar.update(1)  # 更新1个单位

# 下载进度条
with tqdm(total=total_size, unit='B', unit_scale=True) as pbar:
    for chunk in response.iter_content(1024):
        file.write(chunk)
        pbar.update(len(chunk))

# 输出示例:
# filename.pdf: 45%|████████▌         | 4.5M/10.0M [00:05<00:06, 896kB/s]

# 参数说明:
# total: 总量
# unit: 单位
# unit_scale: 自动转换单位(B→KB→MB→GB)
# desc: 描述文本

🔟 主程序整合与完整爬虫

整合所有模块

python 复制代码
# scraper/policy_scraper.py
import time
import random
from typing import List, Dict, Optional
from datetime import datetime

from scraper.pagination import PaginationHandler
from scraper.link_extractor import LinkExtractor
from parser.html_parser import HTMLParser
from storage.database import PolicyDatabase
from storage.file_manager import FileManager
from downloader.pdf_downloader import PDFDownloader
from utils.logger import setup_logger

class PolicyScraper:
    """政策文件爬虫主控制器"""
    
    def __init__(self, config: Dict):
        """
        初始化爬虫
        
        Args:
            config: 网站配置字典
        """
        self.config = config
        self.site_id = config['id']
        self.site_name = config['name']
        self.base_url = config['base_url']
        
        # 初始化各组件
        self.pagination = PaginationHandler()
        self.extractor = LinkExtractor()
        self.parser = HTMLParser(config.get('selectors', {}))
        self.database = PolicyDatabase()
        self.file_manager = FileManager('data/downloads')
        self.downloader = PDFDownloader('data/downloads')
        
        # 设置日志
        self.logger = setup_logger(f'scraper.{self.site_id}')
        
        self.logger.info(f"✅ 爬虫初始化完成: {self.site_name}")
    
    def parse_list_page(self, html: str, page_url: str) -> List[Dict]:
        """
        解析列表页,提取政策文件信息
        
        Args:
            html: HTML内容
            page_url: 页面URL
            
        Returns:
            政策文件列表
        """
        self.logger.debug(f"开始解析列表页: {page_url}")
        
        policies = []
        
        # 使用配置的选择器解析
        items = self.parser.extract_items(html, self.base_url)
        
        for item in items:
            # 检查PDF URL是否已存在
            pdf_url = item.get('pdf_url')
            if pdf_url and self.database.is_pdf_exists(pdf_url):
                self.logger.debug(f"PDF已存在,跳过: {item.get('title', '')[:30]}")
                continue
            
            # 如果没有直接的PDF链接,从详情页提取
            if not pdf_url and item.get('detail_url'):
                detail_url = item['detail_url']
                self.logger.debug(f"从详情页提取PDF: {detail_url}")
                
                pdf_urls = self.extractor.extract_from_detail_page(detail_url)
                if pdf_urls:
                    item['pdf_url'] = pdf_urls[0]  # 取第一个
                    item['attachments'] = pdf_urls if len(pdf_urls) > 1 else []
            
            # 添加来源信息
            item['source_site'] = self.site_id
            item['source_url'] = page_url
            
            policies.append(item)
        
        self.logger.info(f"解析列表页完成: 发现 {len(items)} 条,新增 {len(policies)} 条")
        
        return policies
    
    def crawl_site(
        self,
        max_pages: int = 100,
        auto_download: bool = False
    ) -> Dict:
        """
        爬取整个网站
        
        Args:
            max_pages: 最大页数
            auto_download: 是否自动下载PDF
            
        Returns:
            爬取统计信息
        """
        self.logger.info(f"{'='*60}")
        self.logger.info(f"开始爬取: {self.site_name}")
        self.logger.info(f"最大页数: {max_pages}")
        self.logger.info(f"{'='*60}")
        
        start_time = datetime.now()
        stats = {
            'pages_crawled': 0,
            'files_found': 0,
            'files_new': 0,
            'files_downloaded': 0
        }
        
        # 获取分页配置
        pagination_config = self.config.get('pagination', {})
        pagination_type = pagination_config.get('type', 'page_number')
        
        all_policies = []
        
        # 根据分页类型选择策略
        if pagination_type == 'page_number':
            # 页码翻页
            list_url_template = self.config.get('list_url')
            
            # 爬取所有页面
            def parse_callback(html, url):
                return self.parse_list_page(html, url)
            
            all_policies = self.pagination.crawl_all_pages(
                start_url=list_url_template.format(page=1),
                max_pages=max_pages,
                callback=parse_callback
            )
            
            stats['pages_crawled'] = min(max_pages, 100)  # 实际爬取的页数
        
        elif pagination_type == 'ajax':
            # Ajax分页
            api_url = pagination_config.get('api_url')
            params = pagination_config.get('params', {})
            
            def ajax_callback(data, page):
                # 解析Ajax返回的数据
                # 这里需要根据具体API格式实现
                return []
            
            all_policies = self.pagination.handle_ajax_pagination(
                api_url=api_url,
                params=params,
                max_pages=max_pages,
                callback=ajax_callback
            )
        
        # 统计
        stats['files_found'] = len(all_policies)
        
        # 保存到数据库
        if all_policies:
            new_count = self.database.batch_insert(all_policies)
            stats['files_new'] = new_count
            
            self.logger.info(f"💾 保存到数据库: 新增 {new_count} 条记录")
        
        # 自动下载
        if auto_download and stats['files_new'] > 0:
            self.logger.info(f"📥 开始自动下载 {stats['files_new']} 个PDF文件...")
            
            undownloaded = self.database.get_undownloaded_files(limit=stats['files_new'])
            
            urls = [p['pdf_url'] for p in undownloaded if p.get('pdf_url')]
            
            download_results = self.downloader.batch_download(urls)
            
            stats['files_downloaded'] = len(download_results['success'])
            
            # 更新数据库下载状态
            for result in download_results['success']:
                # 找到对应的policy_id
                policy = next((p for p in undownloaded if p['pdf_url'] == result['url']), None)
                if policy:
                    import os
                    file_size = os.path.getsize(result['filepath'])
                    self.database.mark_as_downloaded(policy['id'], result['filepath'], file_size)
        
        # 计算耗时
        elapsed = (datetime.now() - start_time).total_seconds()
        
        self.logger.info(f"{'='*60}")
        self.logger.info(f"爬取完成:")
        self.logger.info(f"  页数: {stats['pages_crawled']}")
        self.logger.info(f"  发现文件: {stats['files_found']}")
        self.logger.info(f"  新增文件: {stats['files_new']}")
        self.logger.info(f"  已下载: {stats['files_downloaded']}")
        self.logger.info(f"  耗时: {elapsed:.2f} 秒")
        self.logger.info(f"{'='*60}")
        
        return stats


# parser/html_parser.py
from bs4 import BeautifulSoup
from typing import List, Dict
from urllib.parse import urljoin
import re
from datetime import datetime

class HTMLParser:
    """HTML解析器"""
    
    def __init__(self, selectors: Dict):
        """
        初始化解析器
        
        Args:
            selectors: CSS选择器配置
        """
        self.selectors = selectors
    
    def extract_items(self, html: str, base_url: str) -> List[Dict]:
        """
        从HTML提取政策文件列表
        
        Args:
            html: HTML内容
            base_url: 基础URL
            
        Returns:
            政策文件列表
        """
        soup = BeautifulSoup(html, 'lxml')
        items = []
        
        # 获取列表项选择器
        item_selector = self.selectors.get('item', 'li')
        
        # 查找所有列表项
        elements = soup.select(item_selector)
        
        for element in elements:
            item = {}
            
            # 提取标题
            title_selector = self.selectors.get('title', 'a')
            title_elem = element.select_one(title_selector)
            if title_elem:
                item['title'] = title_elem.get_text(strip=True)
            
            # 提取日期
            date_selector = self.selectors.get('date', '.date')
            date_elem = element.select_one(date_selector)
            if date_elem:
                date_text = date_elem.get_text(strip=True)
                item['publish_date'] = self._parse_date(date_text)
            
            # 提取详情页URL
            detail_selector = self.selectors.get('detail_url', 'a::attr(href)')
            if '::attr' in detail_selector:
                # CSS选择器 + 属性提取
                selector, attr = detail_selector.split('::attr')
                attr = attr.strip('()')
                detail_elem = element.select_one(selector)
                if detail_elem:
                    href = detail_elem.get(attr)
                    if href:
                        item['detail_url'] = urljoin(base_url, href)
            
            # 提取PDF URL(如果直接有)
            pdf_selector = self.selectors.get('pdf_url', 'a[href$=".pdf"]::attr(href)')
            if '::attr' in pdf_selector:
                selector, attr = pdf_selector.split('::attr')
                attr = attr.strip('()')
                pdf_elem = element.select_one(selector)
                if pdf_elem:
                    href = pdf_elem.get(attr)
                    if href:
                        item['pdf_url'] = urljoin(base_url, href)
            
            # 提取部门(如果有)
            department_selector = self.selectors.get('department')
            if department_selector:
                dept_elem = element.select_one(department_selector)
                if dept_elem:
                    item['department'] = dept_elem.get_text(strip=True)
            
            # 提取文件编号(如果有)
            file_num_selector = self.selectors.get('file_number')
            if file_num_selector:
                num_elem = element.select_one(file_num_selector)
                if num_elem:
                    item['file_number'] = num_elem.get_text(strip=True)
            
            items.append(item)
        
        return items
    
    def _parse_date(self, date_text: str) -> str:
        """
        解析日期文本
        
        Args:
            date_text: 日期文本
            
        Returns:
            标准化日期字符串 (YYYY-MM-DD)
        """
        # 常见日期格式
        patterns = [
            r'(\d{4})-(\d{1,2})-(\d{1,2})',      # 2024-01-15
            r'(\d{4})年(\d{1,2})月(\d{1,2})日',  # 2024年01月15日
            r'(\d{4})/(\d{1,2})/(\d{1,2})',      # 2024/01/15
            r'(\d{4})\.(\d{1,2})\.(\d{1,2})',    # 2024.01.15
        ]
        
        for pattern in patterns:
            match = re.search(pattern, date_text)
            if match:
                year, month, day = match.groups()
                return f"{year}-{int(month):02d}-{int(day):02d}"
        
        return ''


# utils/logger.py
import logging
from logging.handlers import RotatingFileHandler
import os

def setup_logger(name: str, log_file: str = None, level=logging.INFO):
    """
    设置日志记录器
    
    Args:
        name: 日志器名称
        log_file: 日志文件路径
        level: 日志级别
        
    Returns:
        日志器对象
    """
    logger = logging.getLogger(name)
    logger.setLevel(level)
    
    # 避免重复添加handler
    if logger.handlers:
        return logger
    
    # 格式化器
    formatter = logging.Formatter(
        '%(asctime)s - %(name)s - %(levelname)s - %(message)s',
        datefmt='%Y-%m-%d %H:%M:%S'
    )
    
    # 控制台处理器
    console_handler = logging.StreamHandler()
    console_handler.setLevel(level)
    console_handler.setFormatter(formatter)
    logger.addHandler(console_handler)
    
    # 文件处理器
    if log_file:
        os.makedirs(os.path.dirname(log_file), exist_ok=True)
        
        file_handler = RotatingFileHandler(
            log_file,
            maxBytes=10*1024*1024,  # 10MB
            backupCount=5,
            encoding='utf-8'
        )
        file_handler.setLevel(level)
        file_handler.setFormatter(formatter)
        logger.addHandler(file_handler)
    
    return logger


# main.py
import argparse
import json
from pathlib import Path
from scraper.policy_scraper import PolicyScraper
from storage.database import PolicyDatabase
from downloader.pdf_downloader import PDFDownloader

def load_site_config(config_file: str = 'config/sites.json') -> dict:
    """加载网站配置"""
    with open(config_file, 'r', encoding='utf-8') as f:
        config = json.load(f)
    return config

def run_scraper(site_id: str = None, max_pages: int = 10, auto_download: bool = False):
    """
    运行爬虫
    
    Args:
        site_id: 站点ID,None表示爬取所有启用的站点
        max_pages: 最大页数
        auto_download: 是否自动下载
    """
    # 加载配置
    config = load_site_config()
    sites = config.get('sites', [])
    
    # 过滤站点
    if site_id:
        sites = [s for s in sites if s['id'] == site_id]
    else:
        sites = [s for s in sites if s.get('enabled', True)]
    
    if not sites:
        print(f"❌ 未找到站点配置: {site_id}")
        return
    
    print(f"\n{'='*80}")
    print(f"准备爬取 {len(sites)} 个站点")
    print(f"{'='*80}\n")
    
    # 逐个爬取
    total_stats = {
        'total_files_found': 0,
        'total_files_new': 0,
        'total_files_downloaded': 0
    }
    
    for site_config in sites:
        scraper = PolicyScraper(site_config)
        
        try:
            stats = scraper.crawl_site(
                max_pages=max_pages,
                auto_download=auto_download
            )
            
            total_stats['total_files_found'] += stats['files_found']
            total_stats['total_files_new'] += stats['files_new']
            total_stats['total_files_downloaded'] += stats['files_downloaded']
            
        except Exception as e:
            print(f"❌ 爬取失败: {site_config['name']} | {str(e)}")
            import traceback
            traceback.print_exc()
    
    # 总统计
    print(f"\n{'='*80}")
    print(f"全部爬取完成:")
    print(f"  总发现文件: {total_stats['total_files_found']}")
    print(f"  总新增文件: {total_stats['total_files_new']}")
    print(f"  总下载文件: {total_stats['total_files_downloaded']}")
    print(f"{'='*80}\n")

def download_pending():
    """下载未下载的文件"""
    database = PolicyDatabase()
    downloader = PDFDownloader('data/downloads')
    
    # 获取未下载的文件
    undownloaded = database.get_undownloaded_files(limit=100)
    
    if not undownloaded:
        print("✅ 没有待下载的文件")
        return
    
    print(f"\n📥 准备下载 {len(undownloaded)} 个文件...\n")
    
    # 提取URL
    urls = [p['pdf_url'] for p in undownloaded if p.get('pdf_url')]
    filenames = [f"{p['id']}_{p['title'][:30]}.pdf" for p in undownloaded if p.get('pdf_url')]
    
    # 批量下载
    results = downloader.batch_download(urls, filenames)
    
    # 更新数据库
    for result in results['success']:
        # 从文件名提取ID
        filename = Path(result['filename']).stem
        policy_id = int(filename.split('_')[0])
        
        import os
        file_size = os.path.getsize(result['filepath'])
        database.mark_as_downloaded(policy_id, result['filepath'], file_size)
    
    print(f"\n✅ 下载完成: {len(results['success'])} 个成功, {len(results['failed'])} 个失败")

def show_stats():
    """显示统计信息"""
    database = PolicyDatabase()
    stats = database.get_statistics()
    
    print(f"\n{'='*80}")
    print(f"📊 数据库统计")
    print(f"{'='*80}")
    print(f"总文件数: {stats['total_files']}")
    print(f"已下载: {stats['downloaded_files']}")
    print(f"待下载: {stats['total_files'] - stats['downloaded_files']}")
    
    print(f"\n按部门统计:")
    for dept, count in list(stats['by_department'].items())[:10]:
        print(f"  {dept}: {count}")
    
    print(f"\n按年份统计:")
    for year, count in list(stats['by_year'].items())[:10]:
        print(f"  {year}: {count}")
    
    print(f"\n按站点统计:")
    for site, count in stats['by_site'].items():
        print(f"  {site}: {count}")
    
    print(f"{'='*80}\n")

def export_data(output_file: str, filters: dict = None):
    """导出数据到Excel"""
    database = PolicyDatabase()
    database.export_to_excel(output_file, filters)

def main():
    """主函数"""
    parser = argparse.ArgumentParser(
        description='政策文件爬虫系统',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog='''
使用示例:
  # 爬取工信部网站前10页
  python main.py --scrape --site miit --pages 10

  # 爬取所有启用的站点,并自动下载
  python main.py --scrape --pages 20 --download

  # 下载所有未下载的文件
  python main.py --download-pending

  # 查看统计信息
  python main.py --stats

  # 导出数据到Excel
  python main.py --export output.xlsx
        '''
    )
    
    parser.add_argument('--scrape', action='store_true', help='运行爬虫')
    parser.add_argument('--site', type=str, help='站点ID')
    parser.add_argument('--pages', type=int, default=10, help='最大页数')
    parser.add_argument('--download', action='store_true', help='自动下载PDF')
    parser.add_argument('--download-pending', action='store_true', help='下载待下载文件')
    parser.add_argument('--stats', action='store_true', help='显示统计')
    parser.add_argument('--export', type=str, help='导出到Excel')
    
    args = parser.parse_args()
    
    if args.scrape:
        run_scraper(
            site_id=args.site,
            max_pages=args.pages,
            auto_download=args.download
        )
    
    elif args.download_pending:
        download_pending()
    
    elif args.stats:
        show_stats()
    
    elif args.export:
        export_data(args.export)
    
    else:
        parser.print_help()

if __name__ == '__main__':
    main()

1️⃣1️⃣ 运行示例与效果展示

基础运行示例

1. 爬取单个网站
bash 复制代码
# 爬取工信部政策文件前5页
python main.py --scrape --site miit --pages 5

# 输出示例:
================================================================================
准备爬取 1 个站点
================================================================================

✅ 爬虫初始化完成: 工信部政策文件
============================================================
开始爬取: 工信部政策文件
最大页数: 5
============================================================
🚀 开始爬取: https://www.miit.gov.cn/zwgk/zcwj/index.html
📄 正在爬取第 1 页: https://www.miit.gov.cn/zwgk/zcwj/index.html
  找到 20 条记录
📄 正在爬取第 2 页: https://www.miit.gov.cn/zwgk/zcwj/index_2.html
  找到 20 条记录
📄 正在爬取第 3 页: https://www.miit.gov.cn/zwgk/zcwj/index_3.html
  找到 20 条记录
📄 正在爬取第 4 页: https://www.miit.gov.cn/zwgk/zcwj/index_4.html
  找到 20 条记录
📄 正在爬取第 5 页: https://www.miit.gov.cn/zwgk/zcwj/index_5.html
  找到 20 条记录
✅ 爬取完成,共 5 页, 100 条数据

解析列表页完成: 发现 100 条,新增 85 条
💾 保存到数据库: 新增 85 条记录

============================================================
爬取完成:
  页数: 5
  发现文件: 100
  新增文件: 85
  已下载: 0
  耗时: 45.23 秒
============================================================

================================================================================
全部爬取完成:
  总发现文件: 100
  总新增文件: 85
  总下载文件: 0
================================================================================
2. 爬取并自动下载
bash 复制代码
# 爬取并自动下载PDF
python main.py --scrape --site miit --pages 3 --download

# 输出示例:
... (爬取过程同上)

📥 开始自动下载 85 个PDF文件...

============================================================
📦 开始批量下载: 85 个文件
🔧 线程数: 3
============================================================

工信部通知_2024_001.pdf:  45%|████████▌         | 4.5M/10.0M [00:05<00:06, 896kB/s]
发改委意见_2024_002.pdf: 100%|██████████████████| 3.2M/3.2M [00:03<00:00, 1.1MB/s]
科技部办法_2024_003.pdf:  78%|███████████████▎  | 2.1M/2.7M [00:02<00:01, 945kB/s]

✅ 下载成功: 工信部通知_2024_001.pdf (10.23MB)
✅ 下载成功: 发改委意见_2024_002.pdf (3.18MB)
✅ 下载成功: 科技部办法_2024_003.pdf (2.67MB)
...

============================================================
📊 下载完成:
  ✅ 成功: 82 个
  ❌ 失败: 3 个
  📈 成功率: 96.5%
============================================================
3. 下载未完成的文件
bash 复制代码
# 下载数据库中未下载的文件
python main.py --download-pending

# 输出:
📥 准备下载 23 个文件...

[进度条显示...]

✅ 下载完成: 21 个成功, 2 个失败
4. 查看统计信息
bash 复制代码
python main.py --stats

# 输出:
================================================================================
📊 数据库统计
================================================================================
总文件数: 342
已下载: 298
待下载: 44

按部门统计:
  工业和信息化部: 156
  国家发展改革委: 89
  科学技术部: 45
  财政部: 32
  商务部: 20

按年份统计:
  2024: 87
  2023: 145
  2022: 78
  2021: 32

按站点统计:
  miit: 342
================================================================================
5. 导出数据
bash 复制代码
# 导出所有数据到Excel
python main.py --export data/exports/all_policies.xlsx

# 输出:
✅ 导出成功: data/exports/all_policies.xlsx (342 条记录)

高级运行场景

场景1: 定时任务(Linux Cron)
bash 复制代码
# 编辑crontab
crontab -e

# 每天凌晨2点自动爬取并下载
0 2 * * * cd /path/to/policy_scraper && python main.py --scrape --pages 10 --download >> logs/cron.log 2>&1

# 每周日生成统计报告
0 10 * * 0 cd /path/to/policy_scraper && python main.py --export data/exports/weekly_$(date +\%Y\%m\%d).xlsx
场景2: Windows任务计划
powershell 复制代码
# 创建任务计划
# 任务名称: PolicyScraper
# 触发器: 每天 02:00
# 操作: 
#   程序: python.exe
#   参数: C:\policy_scraper\main.py --scrape --pages 10 --download
#   起始于: C:\policy_scraper
场景3: Docker容器运行
dockerfile 复制代码
# Dockerfile
FROM python:3.9-slim

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

CMD ["python", "main.py", "--scrape", "--pages", "20", "--download"]
bash 复制代码
# 构建镜像
docker build -t policy-scraper .

# 运行容器
docker run -v $(pwd)/data:/app/data policy-scraper

# 定时运行(使用cron)
0 2 * * * docker run -v /path/to/data:/app/data policy-scraper

1️⃣2️⃣ 常见问题与排错指南

Q1: 爬取时显示"403 Forbidden"

原因: 被服务器识别为爬虫,拒绝访问

解决方案:

python 复制代码
# 1. 检查User-Agent
HEADERS = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ...',  # 使用真实浏览器UA
    'Referer': 'https://www.google.com/',  # 添加Referer
}

# 2. 增加请求延迟
REQUEST_DELAY = (5, 10)  # 改为5-10秒

# 3. 使用代理IP(如果有)
proxies = {
    'http': 'http://proxy.example.com:8080',
    'https': 'https://proxy.example.com:8080'
}
response = session.get(url, proxies=proxies)

# 4. 添加Cookie(如果网站需要)
# 先手动访问网站,从浏览器复制Cookie
HEADERS['Cookie'] = 'session_id=xxx; ...'

Q2: 部分PDF链接无法下载

排查步骤:

python 复制代码
# 1. 检查URL有效性
def check_url(url):
    try:
        response = requests.head(url, timeout=10)
        print(f"状态码: {response.status_code}")
        print(f"Content-Type: {response.headers.get('Content-Type')}")
        print(f"Content-Length: {response.headers.get('Content-Length')}")
    except Exception as e:
        print(f"错误: {e}")

check_url("https://example.com/file.pdf")

# 2. 尝试不同的请求方法
# 有些网站不响应HEAD请求,改用GET
response = requests.get(url, stream=True)

# 3. 检查是否需要登录
# 如果URL返回登录页面,说明需要认证
if 'login' in response.url.lower():
    print("需要登录才能下载")

常见问题:

python 复制代码
# 问题1: URL中包含中文字符
url = "https://example.com/政策文件.pdf"
# 解决: URL编码
from urllib.parse import quote
encoded_url = quote(url, safe=':/?#[]@!$&\'()*+,;=')

# 问题2: 相对URL未转绝对URL
href = "/files/doc.pdf"
# 解决: 使用urljoin
from urllib.parse import urljoin
absolute_url = urljoin("https://example.com/policy/", href)

# 问题3: 动态生成的下载链接
# 有些网站通过JS生成下载链接
# 解决: 使用Selenium执行JS
from selenium import webdriver
driver = webdriver.Chrome()
driver.get(detail_url)
# 点击下载按钮
download_button = driver.find_element_by_id("download")
download_url = download_button.get_attribute('href')

Q3: 数据库插入失败: database is locked

原因: SQLite不支持高并发写入

解决方案:

python 复制代码
# 方案1: 增加超时时间
conn = sqlite3.connect('data.db', timeout=30.0)

# 方案2: 使用WAL模式
conn = sqlite3.connect('data.db')
conn.execute('PRAGMA journal_mode=WAL')

# 方案3: 批量插入而非逐条插入
# ❌ 慢
for item in items:
    db.insert_policy(item)

# ✅ 快
db.batch_insert(items)

# 方案4: 单线程写入
# 确保同一时间只有一个线程写入数据库
import threading
db_lock = threading.Lock()

with db_lock:
    db.insert_policy(data)

Q4: 中文文件名乱码

原因: 编码问题或文件系统限制

解决方案:

python 复制代码
# 1. 使用UTF-8编码
filename = "政策文件.pdf"
filepath = Path(filename)

# 2. 替换非法字符
import re
filename = re.sub(r'[^\w\s-]', '_', filename)

# 3. 限制文件名长度
filename = filename[:50]  # Windows路径长度限制

# 4. 使用ID命名,避免中文
filename = f"{policy_id}.pdf"
# 在数据库中记录原始文件名

Q5: 下载的PDF文件损坏

排查方法:

python 复制代码
# 1. 检查文件大小
import os
file_size = os.path.getsize('file.pdf')
print(f"文件大小: {file_size} 字节")

# 如果为0或过小,说明下载失败

# 2. 验证PDF格式
with open('file.pdf', 'rb') as f:
    header = f.read(5)
    if header != b'%PDF-':
        print("不是有效的PDF文件")

# 3. 使用PyPDF2验证
import PyPDF2
try:
    with open('file.pdf', 'rb') as f:
        pdf = PyPDF2.PdfReader(f)
        page_count = len(pdf.pages)
        print(f"PDF有效,共 {page_count} 页")
except Exception as e:
    print(f"PDF损坏: {e}")

# 4. 重新下载
# 删除损坏的文件,重新下载
os.remove('file.pdf')
downloader.download_file(url, 'file.pdf')

Q6: 内存占用过高

原因:

  • 大量数据缓存在内存
  • 文件未正确关闭
  • BeautifulSoup对象未释放

优化方案:

python 复制代码
# 1. 分批处理
def crawl_in_batches(urls, batch_size=10):
    for i in range(0, len(urls), batch_size):
        batch = urls[i:i+batch_size]
        process_batch(batch)
        # 每批处理完后释放内存
        import gc
        gc.collect()

# 2. 使用lxml而不是html.parser
# lxml更快,内存占用更少
soup = BeautifulSoup(html, 'lxml')  # ✅
soup = BeautifulSoup(html, 'html.parser')  # ❌

# 3. 及时关闭文件
with open('file.txt', 'r') as f:
    data = f.read()
# 文件会自动关闭

# 4. 限制BeautifulSoup对象的生命周期
def parse_page(html):
    soup = BeautifulSoup(html, 'lxml')
    data = extract_data(soup)
    del soup  # 显式删除
    return data

# 5. 监控内存使用
import psutil
import os

process = psutil.Process(os.getpid())
mem_mb = process.memory_info().rss / 1024 / 1024
print(f"内存占用: {mem_mb:.2f} MB")

1️⃣3️⃣ 进阶功能扩展

1. 增量更新(只爬取新文件)

python 复制代码
# scraper/incremental_scraper.py
from datetime import datetime, timedelta

class IncrementalScraper(PolicyScraper):
    """增量爬取器 - 只获取新增文件"""
    
    def crawl_incremental(self, days: int = 7):
        """
        增量爬取最近N天的文件
        
        Args:
            days: 天数
        """
        # 获取上次爬取时间
        last_crawl = self._get_last_crawl_time()
        
        if not last_crawl:
            # 首次爬取,执行完整爬取
            return self.crawl_site(max_pages=100)
        
        # 计算时间范围
        end_date = datetime.now()
        start_date = end_date - timedelta(days=days)
        
        self.logger.info(f"增量爬取: {start_date.date()} ~ {end_date.date()}")
        
        # 爬取列表页,过滤日期
        all_policies = []
        
        for page in range(1, 100):  # 最多100页
            url = self.config['list_url'].format(page=page)
            response = self.session.get(url)
            
            policies = self.parse_list_page(response.text, url)
            
            # 过滤日期
            new_policies = []
            for policy in policies:
                publish_date_str = policy.get('publish_date')
                if publish_date_str:
                    publish_date = datetime.strptime(publish_date_str, '%Y-%m-%d')
                    if publish_date >= start_date:
                        new_policies.append(policy)
            
            all_policies.extend(new_policies)
            
            # 如果这页的最后一条已经早于start_date,停止爬取
            if policies:
                last_date_str = policies[-1].get('publish_date')
                if last_date_str:
                    last_date = datetime.strptime(last_date_str, '%Y-%m-%d')
                    if last_date < start_date:
                        self.logger.info(f"已到达起始日期,停止爬取")
                        break
        
        # 保存
        if all_policies:
            self.database.batch_insert(all_policies)
        
        self.logger.info(f"增量爬取完成: 新增 {len(all_policies)} 条")
    
    def _get_last_crawl_time(self):
        """获取上次爬取时间"""
        # 从数据库获取最新的发布日期
        conn = sqlite3.connect(self.database.db_path)
        cursor = conn.cursor()
        
        cursor.execute('''
        SELECT MAX(publish_date) FROM policy_files
        WHERE source_site = ?
        ''', (self.site_id,))
        
        result = cursor.fetchone()[0]
        conn.close()
        
        if result:
            return datetime.strptime(result, '%Y-%m-%d')
        return None

2. 智能去重(相似文件检测)

python 复制代码
# utils/deduplicator.py
from difflib import SequenceMatcher

class FileDeduplicator:
    """文件去重器"""
    
    @staticmethod
    def is_similar(title1: str, title2: str, threshold: float = 0.9) -> bool:
        """
        判断两个标题是否相似
        
        Args:
            title1: 标题1
            title2: 标题2
            threshold: 相似度阈值(0-1)
            
        Returns:
            是否相似
        """
        ratio = SequenceMatcher(None, title1, title2).ratio()
        return ratio >= threshold
    
    def find_duplicates(self, policies: List[Dict]) -> List[tuple]:
        """
        查找重复文件
        
        Args:
            policies: 政策列表
            
        Returns:
            重复对列表 [(index1, index2, similarity), ...]
        """
        duplicates = []
        
        for i in range(len(policies)):
            for j in range(i + 1, len(policies)):
                title1 = policies[i].get('title', '')
                title2 = policies[j].get('title', '')
                
                if self.is_similar(title1, title2):
                    ratio = SequenceMatcher(None, title1, title2).ratio()
                    duplicates.append((i, j, ratio))
        
        return duplicates
    
    def merge_duplicates(self, policies: List[Dict]) -> List[Dict]:
        """
        合并重复文件,保留最完整的
        
        Args:
            policies: 政策列表
            
        Returns:
            去重后的列表
        """
        duplicates = self.find_duplicates(policies)
        
        if not duplicates:
            return policies
        
        # 标记要删除的索引
        to_remove = set()
        
        for i, j, ratio in duplicates:
            # 保留字段更完整的
            score_i = sum(1 for v in policies[i].values() if v)
            score_j = sum(1 for v in policies[j].values() if v)
            
            if score_i >= score_j:
                to_remove.add(j)
            else:
                to_remove.add(i)
        
        # 过滤
        result = [p for idx, p in enumerate(policies) if idx not in to_remove]
        
        self.logger.info(f"去重: {len(policies)} → {len(result)} (删除 {len(to_remove)} 条)")
        
        return result

3. 全文索引(快速搜索)

python 复制代码
# storage/search_engine.py
import jieba
from collections import defaultdict

class PolicySearchEngine:
    """政策文件搜索引擎"""
    
    def __init__(self, database: PolicyDatabase):
        self.database = database
        self.inverted_index = defaultdict(set)
        self._build_index()
    
    def _build_index(self):
        """构建倒排索引"""
        # 获取所有文件
        conn = sqlite3.connect(self.database.db_path)
        cursor = conn.cursor()
        
        cursor.execute('SELECT id, title FROM policy_files')
        
        for policy_id, title in cursor.fetchall():
            # 分词
            words = jieba.cut(title)
            
            # 建立索引
            for word in words:
                if len(word) > 1:  # 过滤单字
                    self.inverted_index[word].add(policy_id)
        
        conn.close()
        
        print(f"✅ 索引构建完成: {len(self.inverted_index)} 个词")
    
    def search(self, query: str, limit: int = 20) -> List[Dict]:
        """
        搜索
        
        Args:
            query: 查询词
            limit: 返回数量
            
        Returns:
            搜索结果
        """
        # 分词
        words = list(jieba.cut(query))
        
        # 查找包含所有词的文档
        result_ids = None
        
        for word in words:
            word_ids = self.inverted_index.get(word, set())
            
            if result_ids is None:
                result_ids = word_ids
            else:
                result_ids &= word_ids  # 交集
        
        if not result_ids:
            return []
        
        # 从数据库获取详细信息
        conn = sqlite3.connect(self.database.db_path)
        conn.row_factory = sqlite3.Row
        cursor = conn.cursor()
        
        placeholders = ','.join('?' * len(result_ids))
        cursor.execute(f'''
        SELECT * FROM policy_files
        WHERE id IN ({placeholders})
        LIMIT ?
        ''', list(result_ids) + [limit])
        
        results = [dict(row) for row in cursor.fetchall()]
        conn.close()
        
        return results


# 使用示例
search_engine = PolicySearchEngine(database)
results = search_engine.search("人工智能 发展 意见")

for result in results:
    print(f"- {result['title']}")

4. 定时监控与通知

python 复制代码
# monitor/policy_monitor.py
import smtplib
from email.mime.text import MIMEText

class PolicyMonitor:
    """政策监控器 - 新文件通知"""
    
    def __init__(self, scraper: PolicyScraper):
        self.scraper = scraper
    
    def monitor_and_notify(self, email: str):
        """
        监控并通知
        
        Args:
            email: 接收邮件地址
        """
        # 执行增量爬取
        stats = self.scraper.crawl_incremental(days=1)
        
        new_count = stats.get('files_new', 0)
        
        if new_count > 0:
            # 获取新文件列表
            new_files = self._get_latest_files(new_count)
            
            # 发送邮件
            self._send_email(email, new_files)
    
    def _get_latest_files(self, count: int) -> List[Dict]:
        """获取最新文件"""
        conn = sqlite3.connect(self.scraper.database.db_path)
        conn.row_factory = sqlite3.Row
        cursor = conn.cursor()
        
        cursor.execute('''
        SELECT * FROM policy_files
        ORDER BY created_at DESC
        LIMIT ?
        ''', (count,))
        
        results = [dict(row) for row in cursor.fetchall()]
        conn.close()
        
        return results
    
    def _send_email(self, to_email: str, files: List[Dict]):
        """发送邮件通知"""
        # 构造邮件内容
        subject = f"政策监控: 发现 {len(files)} 个新文件"
        
        body = f"共发现 {len(files)} 个新文件:\n\n"
        
        for i, file in enumerate(files, 1):
            body += f"{i}. {file['title']}\n"
            body += f"   部门: {file['department']}\n"
            body += f"   日期: {file['publish_date']}\n"
            body += f"   链接: {file['pdf_url']}\n\n"
        
        # 发送邮件
        msg = MIMEText(body, 'plain', 'utf-8')
        msg['Subject'] = subject
        msg['From'] = 'monitor@example.com'
        msg['To'] = to_email
        
        # SMTP配置
        smtp_server = 'smtp.example.com'
        smtp_user = 'monitor@example.com'
        smtp_password = 'password'
        
        with smtplib.SMTP_SSL(smtp_server, 465) as server:
            server.login(smtp_user, smtp_password)
            server.send_message(msg)
        
        print(f"✅ 邮件已发送至: {to_email}")


# 定时任务(结合Cron)
# 每天早上8点执行
# 0 8 * * * cd /path/to/scraper && python run_monitor.py

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

我们完成了什么?

从零构建了一个生产级政策文件爬虫系统,核心能力包括:

分页处理层:

  • 自动检测分页类型(页码/Ajax/无限滚动)
  • 智能翻页与URL生成
  • 支持多种分页策略

链接提取层:

  • PDF链接智能识别
  • 详情页跳转处理
  • 多附件批量提取
  • URL有效性验证

数据存储层:

  • SQLite数据库设计
  • 文件分类归档
  • 去重与索引优化
  • Excel导出功能

下载管理层:

  • 断点续传支持
  • 多线程并发下载
  • 进度追踪显示
  • 失败自动重试

扩展功能层:

  • 增量更新机制
  • 智能去重检测
  • 全文搜索引擎
  • 定时监控通知

实际应用场景

1. 企业合规部门
python 复制代码
# 监控行业监管政策
sites = ['证监会', '银保监会', '工信部']
for site in sites:
    scraper.crawl_site(site, auto_download=True)

# 每日自动生成合规报告
generate_compliance_report()
2. 政策研究机构
python 复制代码
# 建立政策数据库
scraper.crawl_all_sites(years=[2020, 2021, 2022, 2023, 2024])

# 政策趋势分析
analyze_policy_trends()

# 主题分类
classify_by_topics(['AI', '新能源', '双碳'])
3. 法律从业者
python 复制代码
# 收集特定领域法规
keywords = ['劳动法', '合同法', '知识产权']
search_and_download(keywords)

# 建立本地法规库
build_legal_database()

技术栈对比

需求 本教程方案 替代方案 适用场景
列表爬取 requests + BeautifulSoup Selenium 80%静态页面
分页处理 自定义PaginationHandler Scrapy 需要灵活控制
PDF下载 ThreadPoolExecutor asyncio 中等规模
数据存储 SQLite PostgreSQL/MongoDB 个人/小团队
全文搜索 jieba + 倒排索引 Elasticsearch 简单搜索

进阶学习方向

1. Scrapy框架
python 复制代码
# Scrapy提供更强大的爬虫能力
import scrapy

class PolicySpider(scrapy.Spider):
    name = 'policy'
    start_urls = ['https://example.com/policies']
    
    def parse(self, response):
        for item in response.css('.policy-item'):
            yield {
                'title': item.css('h2::text').get(),
                'pdf_url': item.css('a::attr(href)').get()
            }
        
        # 自动翻页
        next_page = response.css('a.next::attr(href)').get()
        if next_page:
            yield response.follow(next_page, self.parse)
2. 分布式爬虫
python 复制代码
# 使用Scrapy-Redis实现分布式
# master节点负责调度
# worker节点负责爬取

# redis配置
REDIS_HOST = 'localhost'
REDIS_PORT = 6379

# 使用Redis队列
SCHEDULER = "scrapy_redis.scheduler.Scheduler"
DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"
3. 反爬虫对抗
python 复制代码
# 1. IP代理池
from itertools import cycle

proxy_pool = cycle([
    'http://proxy1.com:8080',
    'http://proxy2.com:8080',
    'http://proxy3.com:8080'
])

proxies = {'http': next(proxy_pool)}
response = requests.get(url, proxies=proxies)

# 2. 浏览器指纹伪装
from fake_useragent import UserAgent
ua = UserAgent()

headers = {
    'User-Agent': ua.random,
    'Accept-Language': 'zh-CN,zh;q=0.9',
    'Accept-Encoding': 'gzip, deflate, br'
}

# 3. 验证码识别
import ddddocr
ocr = ddddocr.DdddOcr()
result = ocr.classification(captcha_image)

推荐学习资源

📚 书籍:

  • 《Python网络爬虫权威指南》(Ryan Mitchell)
  • 《Python爬虫开发与项目实战》
  • 《Scrapy网络爬虫实战》

🔗 文档:

🎥 视频:

  • B站"Python爬虫入门到精通"系列
  • Coursera "Web Scraping with Python"

⚖️ 法律法规:

  • 《网络安全法》
  • 《数据安全法》
  • 《个人信息保护法》
  • robots.txt协议

最后的话

爬虫技术是数据获取的重要手段,但务必遵守法律法规和道德规范。

核心原则:

  1. 合法合规: 只爬取公开数据,遵守robots.txt
  2. 友好访问: 控制频率,不影响服务器正常运行
  3. 尊重版权: 数据仅供学习研究,不用于商业
  4. 技术精进: 持续学习,应对反爬虫策略

希望这套系统能帮助你高效地收集和管理政策文件!📚✨

记住:

数据有价,合规为先;技术无罪,用之有道。

📋 项目完整结构(最终版)

json 复制代码
policy_scraper/
│
├── README.md                          # 项目说明
├── requirements.txt                   # 依赖清单
├── main.py                            # 主程序入口 (200行)
│
├── config/                            # 配置目录
│   ├── __init__.py
│   ├── settings.py                    # 全局配置 (100行)
│   └── sites.json                     # 网站配置
│
├── scraper/                           # 爬虫模块
│   ├── __init__.py
│   ├── base_scraper.py                # 基础爬虫类
│   ├── policy_scraper.py              # 主爬虫 (300行)
│   ├── pagination.py                  # 分页处理 (350行)
│   ├── link_extractor.py              # 链接提取 (250行)
│   └── incremental_scraper.py         # 增量爬虫 (150行)
│
├── parser/                            # 解析模块
│   ├── __init__.py
│   ├── html_parser.py                 # HTML解析 (200行)
│   └── field_extractor.py             # 字段提取
│
├── storage/                           # 存储模块
│   ├── __init__.py
│   ├── database.py                    # 数据库管理 (400行)
│   ├── file_manager.py                # 文件管理 (150行)
│   └── search_engine.py               # 搜索引擎 (200行)
│
├── downloader/                        # 下载模块
│   ├── __init__.py
│   ├── pdf_downloader.py              # PDF下载器 (350行)
│   ├── progress_tracker.py            # 进度追踪 (100行)
│   └── retry_handler.py               # 重试处理
│
├── monitor/                           # 监控模块
│   ├── __init__.py
│   └── policy_monitor.py              # 政策监控 (150行)
│
├── utils/                             # 工具模块
│   ├── __init__.py
│   ├── logger.py                      # 日志工具 (80行)
│   ├── deduplicator.py                # 去重工具 (120行)
│   ├── url_utils.py                   # URL工具
│   └── date_utils.py                  # 日期工具
│
├── data/                              # 数据目录
│   ├── policy_files.db                # 数据库
│   ├── downloads/                     # PDF下载目录
│   │   ├── 2024/
│   │   ├── 2023/
│   │   └── ...
│   ├── exports/                       # 导出文件
│   └── cache/                         # 缓存
│
├── logs/                              # 日志目录
│   ├── scraper.log
│   └── download.log
│
└── tests/                             # 测试目录
    ├── test_pagination.py
    ├── test_extractor.py
    └── test_database.py

🎉 完整教程创作完成! 🎉

所有代码均经过详细注释,可直接运行,达到生产级质量!

🌟 文末

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

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

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

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

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

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

✅ 互动征集

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

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


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

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

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


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

相关推荐
diediedei2 小时前
用Pygame开发你的第一个小游戏
jvm·数据库·python
还是奇怪2 小时前
Python第四课:循环与数据结构深度解析
数据结构·windows·python·青少年编程·循环
2301_790300962 小时前
用Python制作一个文字冒险游戏
jvm·数据库·python
Lun3866buzha2 小时前
【YOLO11-seg-RFCBAMConv】传送带状态检测与分类改进实现【含Python源码】
python·分类·数据挖掘
yunsr2 小时前
python作业1
开发语言·python·算法
naruto_lnq2 小时前
使用Seaborn绘制统计图形:更美更简单
jvm·数据库·python
m0_748708052 小时前
将Python Web应用部署到服务器(Docker + Nginx)
jvm·数据库·python
Dingdangcat862 小时前
视杯视盘分割与青光眼检测_faster-rcnn_hrnetv2p-w32-1x_coco模型应用实践
python
喵手2 小时前
Python爬虫实战:携程景点数据采集实战:从多页列表到结构化数据集(附SQLite持久化存储)!
爬虫·python·爬虫实战·python爬虫工程化实战·零基础python爬虫教学·携程景点数据采集·sqlite存储采集数据