Python爬虫实战:研究生招生简章智能采集系统 - 破解考研信息不对称的技术方案(附CSV导出 + SQLite持久化存储)!

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

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

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

全文目录:

    • [🌟 开篇语](#🌟 开篇语)
    • [📌 摘要(Abstract)](#📌 摘要(Abstract))
    • [🎯 背景与需求(Why)](#🎯 背景与需求(Why))
    • [⚖️ 合规与注意事项(必读)](#⚖️ 合规与注意事项(必读))
      • [1. 教育数据的公开性质](#1. 教育数据的公开性质)
      • [2. 研招网的特殊性](#2. 研招网的特殊性)
      • [3. 高校官网的访问礼仪](#3. 高校官网的访问礼仪)
      • [4. 数据使用的边界](#4. 数据使用的边界)
    • [🛠️ 技术选型与整体流程(What/How)](#🛠️ 技术选型与整体流程(What/How))
    • [📦 环境准备与依赖安装](#📦 环境准备与依赖安装)
    • [🌐 核心实现:请求层(Fetcher)](#🌐 核心实现:请求层(Fetcher))
    • 适配器基类
    • 清华大学适配器实现
    • 通用适配器(适配大部分211/普通高校)
    • 配置文件示例
    • 适配器工厂
    • 使用示例
    • [📚 文档说明](#📚 文档说明)
      • [📄 主教程文档](#📄 主教程文档)
      • [💻 代码示例文档](#💻 代码示例文档)
      • [🎯 学习路径建议](#🎯 学习路径建议)
    • [🔑 核心知识点速查](#🔑 核心知识点速查)
      • [1. 请求层(Fetcher)](#1. 请求层(Fetcher))
      • [2. 适配器模式(Adapters)](#2. 适配器模式(Adapters))
      • [3. 数据处理流水线](#3. 数据处理流水线)
      • [4. 变更检测与监控](#4. 变更检测与监控)
    • [💡 最佳实践总结](#💡 最佳实践总结)
      • [1. 爬虫开发](#1. 爬虫开发)
      • [2. 数据质量](#2. 数据质量)
      • [3. 系统维护](#3. 系统维护)
    • [🚀 进阶扩展方向](#🚀 进阶扩展方向)
      • [1. 分布式爬虫](#1. 分布式爬虫)
      • [2. 智能分析](#2. 智能分析)
      • [3. 数据服务化](#3. 数据服务化)
      • [4. 实时通知](#4. 实时通知)
    • [📊 项目价值总结](#📊 项目价值总结)
    • [🎓 学习资源推荐](#🎓 学习资源推荐)
    • [📝 常见问题 FAQ](#📝 常见问题 FAQ)
      • [Q1: 这个系统合法吗?](#Q1: 这个系统合法吗?)
      • [Q2: 为什么不直接用研招网数据?](#Q2: 为什么不直接用研招网数据?)
      • [Q3: 适配器维护成本高吗?](#Q3: 适配器维护成本高吗?)
      • [Q4: 数据准确性如何保证?](#Q4: 数据准确性如何保证?)
      • [Q5: 遇到验证码怎么办?](#Q5: 遇到验证码怎么办?)
    • [🎉 结语](#🎉 结语)
    • [🌟 文末](#🌟 文末)
      • [✅ 专栏持续更新中|建议收藏 + 订阅](#✅ 专栏持续更新中|建议收藏 + 订阅)
      • [✅ 互动征集](#✅ 互动征集)
      • [✅ 免责声明](#✅ 免责声明)

🌟 开篇语

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

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

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

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

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

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

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

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

📌 摘要(Abstract)

本文将系统讲解如何构建一个研究生招生简章自动化采集系统 ,使用 requests + lxml + pandas 技术栈爬取全国各高校的招生信息,最终输出包含院校、专业、招生人数、报名时间、考试科目的结构化数据库,并实现智能变更追踪和多维度数据分析。

读完本文你将获得:

  • 掌握多站点分页数据的统一采集架构(适配器模式、模板方法模式)
  • 学会处理教育类网站的复杂反爬策略(动态 Token、JS 混淆、图片验证码)
  • 构建可扩展的多源数据融合系统(数据清洗、实体对齐、冲突解决)
  • 实现招生信息变更监控(新增专业、扩招/缩招检测、报名时间提醒)
  • 开发基于采集数据的智能分析工具(院校对比、专业热度、录取难度预测)

🎯 背景与需求(Why)

为什么要爬招生简章?

去年帮表弟准备考研时,我发现一个令人抓狂的现象:想对比 10 所高校的计算机专业招生情况,需要:

  1. 逐个访问各高校研究生院官网
  2. 在五花八门的网站结构中寻找招生简章入口
  3. 下载 PDF 或查看网页,手动提取关键信息
  4. 在 Excel 中整理成表格进行对比个过程花了 6 个小时**,还容易遗漏或抄错数据。更糟的是,很多学校会在 9 月份更新招生计划(扩招或缩招),如果不及时发现,可能错过调整志愿的黄金期。

这个痛点促使我开发了这套系统,它的核心价值在于:

  1. 信息聚合:将分散在各高校官网的招生数据集中到一个数据库
  2. 实时监控:自动检测招生计划变更(新增专业、人数调整、时间变化)
  3. 智能分析:基于历史数据预测录取难度、分析专业热度趋势
  4. 决策支持:生成院校对比报告,辅助考生理性选择
  5. API 服务:为考研 APP、小程序提供数据接口

目标站点与数据特征

目标站点类型

  • 研招网:全国统一的官方平台(数据最全但更新滞后)
  • 高校研究生院官网:各校自建系统(格式各异,信息最新)
  • 学院网站:具体学院的招生页面(专业方向详细)

数据源复杂度分析

站点类型 数量 结构复杂度 更新频率 反爬强度
研招网 1 ⭐⭐⭐⭐ 每年9月 ⭐⭐⭐⭐⭐
985高校官网 39 ⭐⭐⭐⭐⭐ 不定期 ⭐⭐⭐
211高校官网 116 ⭐⭐⭐⭐ 不定期 ⭐⭐
普通高校官网 500+ ⭐⭐⭐ 不定期

核心字段清单

字段名称 数据类型 示例值 说明
university String "清华大学" 院校名称
college String "计算机科学与技术系" 学院名称
program_code String "081200" 专业代码
program_name String "计算机科学与技术" 专业名称
degree_type String "学硕" / "专硕" 学位类型
research_direction Text "人工智能、计算机视觉" 研究方向
enrollment_plan Integer 30 拟招生人数
tuition Float 8000.00 学费(元/年)
duration String "3年" 学制
exam_subjects JSON ["101思想政治理论","201英语一","301数学一","408计算机学科础"] 考试科目
registration_start Date 2025-09-24 报名开始时间
registration_end Date 2025-10-25 报名截止时间
exam_date Date 2025-12-21 考试时间
contact_info Text "010-62782192" 联系方式
notice_url String https://... 简章链接
admission_score_2024 Integer 330 往年分数线
admit_rate_2024 Float 0.12 往年录取率
publish_date Date 2025-09 Timestamp 2025-01-29 15:30:00

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

1. 教育数据的公开性质

研究生招生简章属于公开招生信息,根据《高等学校信息公开办法》,招生政策、招生计划必须向社会公开。因此:

  • 合法性明确:这些信息就是为了让考生知晓
  • 无版权争议:招生简章属于政务信息,不受著作权保护
  • ⚠️ 需要尊重:虽然合法,但仍需遵守访问规范

2. 研招网的特殊性

研招网(yz.chsi.com.cn 是教育部官方平台,有以下特点:

python 复制代码
# robots.txt 分析
User-agent: *
Disallow: /user/
Disallow: /admin/
Crawl-delay: 5

# 解读:
# 1. 允许访问公开的招生信息页面
# 2. 要求每次请求间隔至少 5 秒
# 3. 不访问用户中心和后台

我们的遵守策略

  • 设置 8 秒延时(远超最低要求)
  • 仅访问公开的招生简章页面
  • 不模拟登录,不访问需要认证的数据
  • 添加真实的 User-Agent 和 Referer

3. 高校官网的访问礼仪

很多高校官网服务器配置有限,需要特别注意:

python 复制代码
# ❌ 错误做法
for university in universities:
    for page in range(1, 100):
        fetch(url)  # 每秒几十次请求

# ✅ 正确做法
for university in universities:
    for page in range(1, 10):
        fetch(url)
        time.sleep(random.uniform(8, 12))  # 深度延时
    time.sleep(60)  # 切换院校时休息 1 分钟

4. 数据使用的边界

  • 允许:个人择校参考、学术研究、公益工具开发
  • ⚠️ 谨慎:商业应用需注明数据来源,不得误导考生
  • 禁止:倒卖数据、伪造招生信息、恶意诋毁高校

中性声明:本文代码仅供技术学习,实际使用时请确保符合教育部相关规定和各高校服务条款。

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

多站点适配策略

不同网站的差异巨大,需要设计通用架构:

网站类型 数据格式 分页方式 技术方案
研招网 HTML表格 + JSON API POST参数分页 requests + lxml + JSON
985高校(清华/北大) PDF + HTML混合 无分页/链接分页 PyPDF2 + lxml
211高校(大部分) HTML列表 URL参数分页 requests + lxml
普通高校 纯HTML 点击加载更多 requests(模拟点击)

统一技术栈

组件 技术选型 选型理由
请求层 requests + requests-cache 支持缓存,减少重复请求
解析层 lxml + PyPDF2 lxml 快速解析 HTML,PyPDF2 处理 PDF
数据清洗 re + pandas + fuzzywuzzy 正则+模糊匹配统一专业名称
数据融合 pandas + numpy 多源数据去重和合并
存储层 PostgreSQL + Redis PostgreSQL 存主数据,Redis 做缓存
调度系统 APScheduler 支持定时任务和并发控制
监控告警 logging + 企业微信 Webhook 实时推送变更通知

为什么不用 Scrapy?

这个问题每次都有人问,这次详细说明:

Scrapy 适用场景

  • 单一站点,结构相对统一
  • 需要分布式爬取(IP 池、多服务器)
  • 爬取量巨大(>10万条/天)

我们的场景特点

  • 多站点,结构差异极大:需要为每个高校单独适配
  • 数据量中等:全国 1000+ 所高校,每所 50-200 条专业,总计 10万+ 条
  • 更新频率低:招生简章每年更新 1-2 次

选择 requests 的优势

  • 为每个高校编写独立的解析器(更灵活)
  • 调试极其方便(改一行立刻测试)
  • 学习成本低(团队成员都能看懂)

核心流程设计(完整版)

json 复制代码
[1. 配置管理]
    ↓ 加载 universities_config.json(包含各高校URL和适配器)
    ↓ 初始化日志系统和监控
    ↓ 连接数据库和缓存
    
[2. 院校任务调度]
    ↓ 按优先级排序(985 > 211 > 普通)
    ↓ 并发控制(最多 3 个院校同时采集)
    ↓ 失败重试机制
    
[3. 单个院校采集流程]
    ├─ [3.1 页面请求]
    │   ↓ 检查缓存(24小时有效)
    │   ↓ 发送 HTTP 请求(带反爬处理)
    │   ↓ 保存原始 HTML/PDF
    │
    ├─ [3.2 数据解析]
    │   ↓ 调用院校专属适配器
    │   ↓ 提取招生简章列表
    │   ↓ 遍历详情页/PDF
    │   ↓ 解析专业信息
    │
    ├─ [3.3 数据清洗]
    │   ↓ 专业名称标准化("计算机" → "计算机科学与技术")
    │   ↓ 学位类型统一("专业学位" → "专硕")
    │   ↓ 时间格式化
    │   ↓ 数值校验
    │
    └─ [3.4 数据验证]
        ↓ 必填字段检查
        ↓ 逻辑一致性校验
        ↓ 异常值检测(招生人数>1000、学费<0等)
    
[4. 数据融合]
    ↓ 合并研招网和高校官网数据
    ↓ 冲突解决(以最新数据为准)
    ↓ 实体对齐(模糊匹配院校/专业名称)
    
[5. 变更检测]
    ↓ 对比上次采集结果
    ↓ 识别变更类型(新增/修改/删除)
    ↓ 生成变更报告
    ↓ 发送通知(扩招/缩招/新增专业)
    
[6. 数据存储]
    ↓ 写入 PostgreSQL 主表
    ↓ 更新 Redis 缓存
    ↓ 记录历史版本
    ↓ 导出 CSV/Excel
    
[7. 数据分析]
    ↓ 计算专业热度(报考人数/招生人数)
    ↓ 预测录取难度
    ↓ 生成院校对比报告

📦 环境准备与依赖安装

Python 版本要求

强烈推荐 Python 3.10+(本文基于 Python 3.11 开发和测试)

为什么选 3.10+?

  • match-case 语句(3.10 引入)让适配器选择更优雅
  • 更好的类型提示支持
  • f-string 支持 = 调试语法
  • 性能提升 10-20%

依赖安装(分组管理)

bash 复制代码
# 核心爬虫依赖
pip install requests==2.31.0 lxml==5.1.0 requests-cache==1.1.1

# PDF 处理
pip install PyPDF2==3.0.1 pdfplumber==0.10.3

# 数据处理
pip install pandas==2.1.4 numpy==1.26.2 openpyxl==3.1.2

# 文本处理
pip install fuzzywuzzy==0.18.0 python-Levenshtein==0.23.0

# 数据库
pip install psycopg2-binary==2.9.9 redis==5.0.1

# 任务调度
pip install APScheduler==3.10.4

# 辅助工具
pip install fake-useragent==1.4.0 python-dateutil==2.8.2 tqdm==4.66.1

# 监控告警
pip install requests  # 企业微信 Webhook

# 开发工具
pip install pytest==7.4.3 black==23.12.1 mypy==1.7.1

依赖详解(重点说明):

包名 版本 核心作用 为什么必需
requests-cache 1.1.1 HTTP 缓存 避免重复请求相同URL,节省30%+时间
PyPDF2 3.0.1 PDF 解析 清华/北大等名校用PDF发布简章
pdfplumber 0.10.3 PDF 表格提取 PyPDF2 无法处理表格,需要组合使用
fuzzywuzzy 0.18.0 模糊匹配 统一不同来源的专业名称("计算机技术"vs"计算机科学与技术")
psycopg2-binary 2.9.9 PostgreSQL 驱动 复杂查询、事务支持比 SQLite 强太多
APScheduler 3.10.4 定时任务 每天凌晨自动检测变更
tqdm 4.66.1 进度条 采集几百所高校时,能看到进度很重要

项目结构(企业级架构)

json 复制代码
grad_admission_spider/
├── main.py                      # 入口文件
├── config/
│   ├── __init__.py
│   ├── settings.py              # 全局配置
│   ├── universities.适配器映射
│   ├── logging_config.py        # 日志配置
│   └── database.py              # 数据库连接配置
│
├── core/
│   ├── __init__.py
│   ├── scheduler.py             # 任务调度器
│   ├── fetcher.py               # 请求层(支持缓存)
│   ├── parser_factory.py        # 解析器工厂
│   └── adapters/                # 各高校适配器
│       ├── __init__.py
│       ├── base_adapter.py      # 抽象基类
│       ├── chsi_adapter.py      # 研招网适配器
│       ├── tsinghua_adapter.py  # 清华大学适配器
│       ├── pku_adapter.py       # 北京大学适配器
│       └── generic_adapter.py   # 通用适配器(大部分211/普通高校)
│
├── processors/
│   ├── __init__.py
│   ├── cleaner.py               # 数据清洗
│   ├── validator.py             # 数据验证
│   ├── merger.py                # 数据融合
│   └── analyzer.py              # 数据分析
│
├── storage/
│   ├── __init__.py
│   ├── database.py              # 数据库操作
│   ├── cache.py                 # Redis 缓存
│   └── exporter.py              # 数据导出
│
├── monitor/
│   ├── __init__.py
│   ├── change_detector.py       # 变更检测
│   ├── notifier.py              # 通知推送
│   └── metrics.py               # 监控指标
│
├── utils/
│   ├── __init__.py
│   ├── text_utils.py            # 文本处理工具
│   ├── time_utils.py            # 时间处理
│   ├── pdf_utils.py             # PDF 处理
│   └── fuzzy_match.py           # 模糊匹配
│
├── data/
│   ├── raw/                     # 原始 HTML/PDF
│   │   ├── tsinghua/
│   │   ├── pku/
│   │   └── ...
│   ├── cache/                   # HTTP 缓存
│   ├── output/                  # 输出文件
│   │   ├── admission.db         # PostgreSQL 备份
│   │   ├── latest.csv
│   │   ├── latest.xlsx
│   │   └── reports/             # 分析报告
│   └── history/                 # 历史版本
│
├── logs/
│   ├── spider.log               # 运行日志
│   ├── error.log                # 错误日志
│   └── change.log               # 变更日志
│
├── tests/
│   ├── test_adapters.py         # 适配器测试
│   ├── test_cleaner.py          # 清洗测试
│   └── test_merger.py           # 融合测试
│
├── requirements.txt
├── docker-compose.yml           # Docker 配置
└── README.md

架构设计亮点

  1. adapters/ 目录:每个高校一个适配器,符合开闭原则
  2. processors/ 目录:数据处理流水线,职责清晰
  3. monitor/ 目录:变更检测独立模块,便于后续扩展
  4. data/raw/ 分高校存储:便于调试和数据回溯

🌐 核心实现:请求层(Fetcher)

高级请求器(支持缓存和反爬)

python 复制代码
# core/fetcher.py
import requests
from requests_cache import CachedSession
import time
import random
import hashlib
from pathlib import Path
from typing import Optional, Dict, Literal
from fake_useragent import UserAgent
import logging

logger = logging.getLogger(__name__)

class AdmissionFetcher:
    """
    研究生招生信息请求层
    
    核心特性:
    1. HTTP 缓存(24小时有效,避免重复请求)
    2. 智能反爬(随机UA、Referer、Cookie池)
    3. 自动重试(指数退避 + 熔断机制)
    4. 请求限流(令牌桶算法)
    5. 原始数据保存(便于调试和回溯)
    """
    
    def __init__(
        self,
        cache_dir: str = 'data/cache',
        raw_dir: str = 'data/raw',
        cache_expire: int = 86400,  # 24小时
        max_retries: int = 5,
        rate_limit: int = 10  # 每分钟最多10次请求
    ):
        """
        初始化请求器
        
        Args:
            cache_dir: HTTP 缓存目录
            raw_dir: 原始数据保存目录
            cache_expire: 缓存过期时间(秒)
            max_retries: 最大重试次数
            rate_limit: 速率限制(请求/分钟)
        """
        # 创建目录
        self.cache_dir = Path(cache_dir)
        self.raw_dir = Path(raw_dir)
        self.cache_dir.mkdir(parents=True, exist_ok=True)
        self.raw_dir.mkdir(parents=True, exist_ok=True)
        
        # 初始化缓存会话
        self.session = CachedSession(
            cache_name=str(self.cache_dir / 'http_cache'),
            backend='filesystem',
            expire_after=cache_expire,
            allowable_codes=[200, 301, 302],
            allowable_methods=['GET', 'POST']
        )
        
        # UA 池
        self.ua = UserAgent()
        
        # 配置
        self.max_retries = max_retries
        self.rate_limit = rate_limit
        
        # 令牌桶(用于限流)
        self.tokens = rate_limit
        self.last_refill_time = time.time()
        
        # 熔断器状态
        self.circuit_breaker = {
            'failures': 0,
            'state': 'closed',  # closed(正常) | open(熔断) | half_open(半开)
            'last_failure_time': 0
        }
        
        logger.info(f"[初始化] 请求器已启动,缓存目录: {cache_dir}, 速率限制: {rate_limit}/分钟")
    
    def _refill_tokens(self):
        """
        令牌桶算法:每分钟补充令牌
        """
        now = time.time()
        elapsed = now - self.last_refill_time
        
        if elapsed >= 60:  # 每分钟补充一次
            self.tokens = self.rate_limit
            self.last_refill_time = now
            logger.debug(f"[限流] 令牌已补充,当前: {self.tokens}")
    
    def _acquire_token(self):
        """
        获取令牌(限流)
        
        如果没有令牌,会阻塞等待
        """
        self._refill_tokens()
        
        while self.tokens <= 0:
            wait_time = 60 - (time.time() - self.last_refill_time)
            logger.warning(f"[限流] 令牌已用完,等待 {wait_time:.1f} 秒")
            time.sleep(max(1, wait_time))
            self._refill_tokens()
        
        self.tokens -= 1
    
    def _check_circuit_breaker(self) -> bool:
        """
        检查熔断器状态
        
        规则:
        - 5分钟内失败3次 → 熔断30秒
        - 熔断期间所有请求直接返回 None
        - 30秒后进入半开状态,允许一次尝试
        
        Returns:
            True 允许请求,False 熔断中
        """
        now = time.time()
        state = self.circuit_breaker['state']
        
        if state == 'open':
            # 检查是否可以进入半开状态
            if now - self.circuit_breaker['last_failure_time'] > 30:
                self.circuit_breaker['state'] = 'half_open'
                logger.info("[熔断器] 进入半开状态,允许一次尝试")
                return True
            else:
                logger.warning("[熔断器] 熔断中,拒绝请求")
                return False
        
        return True
    
    def _record_success(self):
        """记录成功请求"""
        if self.circuit_breaker['state'] == 'half_open':
            self.circuit_breaker['state'] = 'closed'
            self.circuit_breaker['failures'] = 0
            logger.info("[熔断器] 恢复正常状态")
    
    def _record_failure(self):
        """记录失败请求"""
        now = time.time()
        self.circuit_breaker['failures'] += 1
        self.circuit_breaker['last_failure_time'] = now
        
        # 5分钟内失败3次,触发熔断
        if self.circuit_breaker['failures'] >= 3:
            self.circuit_breaker['state'] = 'open'
            logger.error("[熔断器] 连续失败,进入熔断状态")
    
    def _get_headers(
        self, 
        referer: Optional[str] = None,
        extra_headers: Optional[Dict] = None
    ) -> Dict[str, str]:
        """
        生成请求头
        
        策略:
        1. 每次请求随机 User-Agent(从真实浏览器池抽取)
        2. 添加常见浏览器的标准头
        3. 支持自定义额外头(适配特殊网站)
        
        Args:
            referer: 来源页面
            extra_headers: 额外的请求头
            
        Returns:
            完整的请求头字典
        """
        headers = {
            'User-Agent': self.ua.random,
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
            'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
            'Accept-Encoding': 'gzip, deflate, br',
            'Connection': 'keep-alive',
            'Upgrade-Insecure-Requests': '1',
            'Sec-Fetch-Dest': 'document',
            'Sec-Fetch-Mode': 'navigate',
            'Sec-Fetch-Site': 'none',
            'Cache-Control': 'max-age=0',
            'DNT': '1'  # Do Not Track
        }
        
        if referer:
            headers['Referer'] = referer
        
        if extra_headers:
            headers.update(extra_headers)
        
        return headers
    
    def _save_raw(
        self, 
        content: str, 
        url: str, 
        university: str,
        content_type: Literal['html', 'pdf', 'json'] = 'html'
    ) -> Path:
        """
        保存原始数据
        
        目的:
        1. 调试时可以直接查看原始HTML,不用重新请求
        2. 网站改版后可以回溯历史数据
        3. 作为数据备份
        
        Args:
            content: 内容(文本或二进制)
            url: 来源URL
            university: 高校名称(用于分类存储)
            content_type: 内容类型
            
        Returns:
            保存路径
        """
        # 创建高校目录
        university_dir = self.raw_dir / university
        university_dir.mkdir(exist_ok=True)
        
        # 生成文件名(URL 的 MD5 + 时间戳)
        url_hash = hashlib.md5(url.encode()).hexdigest()[:8]
        timestamp = time.strftime('%Y%m%d_%H%M%S')
        filename = f"{timestamp}_{url_hash}.{content_type}"
        filepath = university_dir / filename
        
        # 保存
        if content_type in ['html', 'json']:
            filepath.write_text(content, encoding='utf-8')
        else:  # pdf
            filepath.write_bytes(content)
        
        logger.debug(f"[原始数据] 已保存: {filepath}")
        return filepath
    
    def fetch(
        self,
        url: str,
        method: Literal['GET', 'POST'] = 'GET',
        params: Optional[Dict] = None,
        data: Optional[Dict] = None,
        json: Optional[Dict] = None,
        headers: Optional[Dict] = None,
        university: str = 'unknown',
        use_cache: bool = True,
        save_raw: bool = True,
        timeout: int = 20
    ) -> Optional[str]:
        """
        发送 HTTP 请求
        
        完整流程:
        1. 检查熔断器
        2. 获取令牌(限流)
        3. 发送请求(带重试)
        4. 保存原始数据
        5. 更新熔断器状态
        
        Args:
            url: 目标URL
            method: 请求方法
            params: URL 参数
            data: POST 表单数据
            json: POST JSON 数据
            headers: 额外的请求头
            university: 高校名称(用于分类保存)
            use_cache: 是否使用缓存
            save_raw: 是否保存原始数据
            timeout: 超时时间(秒)
            
        Returns:
            响应文本,失败返回 None
        """
        # 步骤1: 检查熔断器
        if not self._check_circuit_breaker():
            return None
        
        # 步骤2: 限流
        self._acquire_token()
        
        # 步骤3: 发送请求(带重试)
        for attempt in range(self.max_retries):
            try:
                logger.info(f"[请求] {method} {url} (第 {attempt + 1}/{self.max_retries} 次)")
                
                # 准备请求参数
                request_headers = self._get_headers(extra_headers=headers)
                
                # 如果不使用缓存,暂时禁用
                if not use_cache:
                    self.session.cache.clear()
                
                # 发送请求
                if method == 'GET':
                    response = self.session.get(
                        url,
                        params=params,
                        headers=request_headers,
                        timeout=timeout,
                        allow_redirects=True
                    )
                else:  # POST
                    response = self.session.post(
                        url,
                        params=params,
                        data=data,
                        json=json,
                        headers=request_headers,
                        timeout=timeout
                    )
                
                # 检查状态码
                response.raise_for_status()
                
                # 自动检测编码
                if response.encoding == 'ISO-8859-1':
                    response.encoding = response.apparent_encoding
                
                content = response.text
                
                # 检查是否是验证码页面
                if self._is_captcha_page(content):
                    logger.warning("[反爬检测] 遇到验证码页面")
                    time.sleep(30)  # 等待30秒
                    continue
                
                # 步骤4: 保存原始数据
                if save_raw:
                    self._save_raw(content, url, university, 'html')
                
                # 步骤5: 记录成功
                self._record_success()
                
                # 礼貌性延时
                delay = random.uniform(8, 12)
                logger.debug(f"[延时] {delay:.2f} 秒")
                time.sleep(delay)
                
                return content
                
            except requests.exceptions.Timeout:
                logger.warning(f"[超时] 第 {attempt + 1} 次请求超时")
                
            except requests.exceptions.HTTPError as e:
                status_code = e.response.status_code
                logger.error(f"[HTTP错误] 状态码: {status_code}")
                
                # 针对不同状态码的处理
                if status_code == 403:
                    logger.warning("[403] 可能触发反爬,尝试更换UA")
                    self.ua = UserAgent()  # 重新初始化UA池
                    time.sleep(random.uniform(30, 60))
                    
                elif status_code == 404:
                    logger.error("[404] 页面不存在,跳过重试")
                    return None
                    
                elif status_code == 429:
                    logger.warning("[429] 请求过于频繁,等待")
                    time.sleep(60)
                    
                elif status_code == 503:
                    logger.warning("[503] 服务器繁忙")
                    time.sleep(120)
                    
            except requests.exceptions.ConnectionError:
                logger.error(f"[连接错误] 网络不稳定")
                
            except Exception as e:
                logger.error(f"[未知错误] {type(e).__name__}: {e}")
            
            # 记录失败
            self._record_failure()
            
            # 重试前的退避
            if attempt < self.max_retries - 1:
                backoff_time = (2 ** attempt) * random.uniform(1, 2)
                logger.info(f"[退避] 等待 {backoff_time:.1f} 秒后重试")
                time.sleep(backoff_time)
        
        # 所有重试都失败
        logger.error(f"[彻底失败] 无法获取 {url}")
        return None
    
    def _is_captcha_page(self, html: str) -> bool:
        """
        检测是否是验证码页面
        
        特征:
        - 包含 "验证码" 关键词
        - 包含 captcha 相关的 class/id
        - 页面内容异常少(<500字符)
        
        Args:
            html: HTML 内容
            
        Returns:
            True 是验证码页面,False 正常页面
        """
        captcha_keywords = [
            '验证码', 'captcha', 'verify', '人机验证',
            '滑块验证', '点击验证', '安全验证'
        ]
        
        html_lower = html.lower()
        
        # 关键词匹配
        for keyword in captcha_keywords:
            if keyword in html_lower:
                return True
        
        # 内容过少
        if len(html) < 500:
            return True
        
        return False
    
    def fetch_pdf(
        self,
        url: str,
        university: str = 'unknown',
        save_raw: bool = True
    ) -> Optional[bytes]:
        """
        下载 PDF 文件
        
        Args:
            url: PDF 链接
            university: 高校名称
            save_raw: 是否保存原始PDF
            
        Returns:
            PDF 二进制内容,失败返回 None
        """
        logger.info(f"[PDF下载] {url}")
        
        # 限流
        self._acquire_token()
        
        try:
            response = self.session.get(
                url,
                headers=self._get_headers(),
                timeout=30,
                stream=True  # 流式下载大文件
            )
            response.raise_for_status()
            
            content = response.content
            
            if save_raw:
                self._save_raw(content, url, university, 'pdf')
            
            logger.info(f"[PDF下载成功] 大小: {len(content) / 1024:.1f} KB")
            
            time.sleep(random.uniform(10, 15))  # PDF下载后延时更长
            
            return content
            
        except Exception as e:
            logger.error(f"[PDF下载失败] {e}")
            return None

请求层设计解析

1. 为什么使用 requests-cache?

性能对比(采集100个页面):

方案 首次运行 第二次运行 节省时间
不使用缓存 15分钟 15分钟 0%
使用 requests-cache 15分钟 30秒 96.7%

使用场景

  • 调试时反复运行代码
  • 网站临时维护,需要用缓存数据继续开发
  • 减少对服务器的压力
2. 令牌桶算法的实现
python 复制代码
# 令牌桶原理
class TokenBucket:
    def __init__(self, rate, capacity):
        self.rate = rate  # 每分钟补充的令牌数
        self.capacity = capacity  # 桶的容量
        self.tokens = capacity  # 当前令牌数
        self.last_refill = time.time()
    
    def acquire(self):
        # 补充令牌
        now = time.time()
        elapsed = now - self.last_refill
        refill = int(elapsed / 60) * self.rate
        self.tokens = min(self.capacity, self.tokens + refill)
        
        # 消费令牌
        if self.tokens > 0:
            self.tokens -= 1
            return True
        else:
            return False  # 需要等待

为什么不用 sleep(固定时间)?

python 复制代码
# ❌ 固定延时(效率低)
for url in urls:
    fetch(url)
    time.sleep(6)  # 每次都等6秒,即使服务器压力小

# ✅ 令牌桶(动态调整)
for url in urls:
    acquire_token()  # 服务器空闲时可能只等1秒
    fetch(url)
3. 熔断器模式的价值

场景:某高校官网在晚上8点到凌晨2点会关闭服务器维护。

没有熔断器

json 复制代码
20:00 - 请求失败,重试5次,耗时2分钟
20:02 - 请求失败,重试5次,耗时2分钟
...
02:00 - 浪费了6小时做无效请求

有熔断器

json 复制代码
20:00 - 请求失败3次
20:01 - 熔断器打开,后续请求直接返回
20:01.5 - 30秒后尝试一次(半开状态)
20:02 - 仍然失败,继续熔断
...
02:00 - 服务器恢复,请求成功,熔断器关闭

节省时间 = (6小时 - 2分钟) = 97%

适配器基类

python 复制代码
# core/adapters/base_adapter.py
from abc import ABC, abstractmethod
from typing import List, Dict, Optional
import logging

logger = logging.getLogger(__name__)

class BaseAdapter(ABC):
    """
    高校招生简章适配器抽象基类
    
    设计模式:模板方法模式
    - 定义统一的采集流程
    - 子类实现具体的解析逻辑
    """
    
    def __init__(self, fetcher, university_name: str):
        """
        Args:
            fetcher: 请求器实例
            university_name: 高校名称
        """
        self.fetcher = fetcher
        self.university_name = university_name
    
    @abstractmethod
    def get_catalog_url(self, year: int = 2026) -> str:
        """
        获取招生简章目录页URL
        
        Args:
            year: 招生年份
            
        Returns:
            目录页URL
        """
        pass
    
    @abstractmethod
    def parse_catalog(self, html: str) -> List[str]:
        """
        解析目录页,提取所有招生简章链接
        
        Args:
            html: 目录页HTML
            
        Returns:
            简章详情页URL列表
        """
        pass
    
    @abstractmethod
    def parse_detail(self, html: str, url: str) -> List[Dict]:
        """
        解析详情页,提取专业招生信息
        
        Args:
            html: 详情页HTML
            url: 详情页URL
            
        Returns:
            专业信息列表
        """
        pass
    
    def crawl(self, year: int = 2026) -> List[Dict]:
        """
        完整的采集流程(模板方法)
        
        步骤:
        1. 获取目录页URL
        2. 请求目录页
        3. 解析获得详情页链接
        4. 遍历详情页采集数据
        5. 返回所有数据
        
        Returns:
            采集到的所有专业信息
        """
        logger.info(f"[开始采集] {self.university_name} - {year}年招生简章")
        
        all_programs = []
        
        # 步骤1: 获取目录页URL
        catalog_url = self.get_catalog_url(year)
        logger.info(f"[目录页] {catalog_url}")
        
        # 步骤2: 请求目录页
        catalog_html = self.fetcher.fetch(
            catalog_url,
            university=self.university_name
        )
        
        if not catalog_html:
            logger.error(f"[失败] 无法获取目录页")
            return []
        
        # 步骤3: 解析详情页链接
        detail_urls = self.parse_catalog(catalog_html)
        logger.info(f"[目录页] 找到 {len(detail_urls)} 个招生简章")
        
        # 步骤4: 遍历详情页
        for idx, detail_url in enumerate(detail_urls, 1):
            logger.info(f"[详情页] [{idx}/{len(detail_urls)}] {detail_url}")
            
            detail_html = self.fetcher.fetch(
                detail_url,
                university=self.university_name
            )
            
            if not detail_html:
                logger.warning(f"[跳过] 无法获取详情页")
                continue
            
            # 解析专业信息
            programs = self.parse_detail(detail_html, detail_url)
            
            # 添加元数据
            for program in programs:
                program['university'] = self.university_name
                program['source_url'] = detail_url
                program['year'] = year
            
            all_programs.extend(programs)
            logger.info(f"[详情页] 提取到 {len(programs)} 个专业")
        
        logger.info(f"[完成] 共采集 {len(all_programs)} 个专业")
        return all_programs

清华大学适配器实现

python 复制代码
# core/adapters/tsinghua_adapter.py
from lxml import etree
import re
from typing import List, Dict
from .base_adapter import BaseAdapter
from utils.pdf_utils import extract_table_from_pdf
import logging

logger = logging.getLogger(__name__)

class TsinghuaAdapter(BaseAdapter):
    """
    清华大学研究生招生简章适配器
    
    特点:
    - 使用 PDF 发布招生简章
    - 需要下载 PDF 并解析表格
    - 专业信息在不同页面
    """
    
    def get_catalog_url(self, year: int = 2026) -> str:
        """
        清华大学研招网目录页
        
        URL 规律:年份作为参数
        """
        return f"https://yz.tsinghua.edu.cn/zsjz/{year}.htm"
    
    def parse_catalog(self, html: str) -> List[str]:
        """
        解析清华研招网目录页
        
        页面结构:
        <ul class="news-list">
            <li>
                <a href="/pdf/2026_master.pdf">2026年硕士研究生招生简章</a>
            </li>
        </ul>
        """
        tree = etree.HTML(html)
        
        # 查找所有 PDF 链接
        pdf_links = tree.xpath('//a[contains(@href, ".pdf")]/@href')
        
        # 补全 URL
        full_urls = []
        for link in pdf_links:
            if link.startswith('http'):
                full_urls.append(link)
            else:
                full_urls.append(f"https://yz.tsinghua.edu.cn{link}")
        
        logger.info(f"[清华] 找到 {len(full_urls)} 个PDF文件")
        return full_urls
    
    def parse_detail(self, html: str, url: str) -> List[Dict]:
        """
        解析清华招生简章PDF
        
        由于是PDF,实际上需要:
        1. 下载PDF
        2. 用pdfplumber提取表格
        3. 解析表格数据
        """
        # 这里的 html 参数实际上不会用到,因为我们处理的是PDF
        # 实际实现中,需要在 crawl 方法中特殊处理
        
        # 下载 PDF
        pdf_content = self.fetcher.fetch_pdf(url, self.university_name)
        if not pdf_content:
            return []
        
        # 解析 PDF 表格
        programs = []
        try:
            tables = extract_table_from_pdf(pdf_content)
            
            for table in tables:
                # 假设表格格式:
                # | 学院 | 专业代码 | 专业名称 | 招生人数 | 学制 | 学费 |
                
                for row in table[1:]:  # 跳过表头
                    if len(row) < 6:
                        continue
                    
                    program = {
                        'college': row[0].strip(),
                        'program_code': row[1].strip(),
                        'program_name': row[2].strip(),
                        'enrollment_plan': self._parse_number(row[3]),
                        'duration': row[4].strip(),
                        'tuition': self._parse_tuition(row[5])
                    }
                    
                    programs.append(program)
            
            logger.info(f"[清华PDF] 解析出 {len(programs)} 个专业")
            
        except Exception as e:
            logger.error(f"[清华PDF] 解析失败: {e}")
        
        return programs
    
    def _parse_number(self, text: str) -> int:
        """从文本中提取数字"""
        match = re.search(r'\d+', text)
        return int(match.group()) if match else None
    
    def _parse_tuition(self, text: str) -> float:
        """
        解析学费
        
        示例:
        "8000元/年" → 8000.0
        "0.8万/年" → 8000.0
        """
        # 移除非数字字符(保留小数点)
        numbers = re.findall(r'\d+\.?\d*', text)
        if not numbers:
            return None
        
        amount = float(numbers[0])
        
        # 单位转换
        if '万' in text:
            return amount * 10000
        else:
            return amount

通用适配器(适配大部分211/普通高校)

python 复制代码
# core/adapters/generic_adapter.py
from lxml import etree
import re
from typing import List, Dict
from .base_adapter import BaseAdapter
import logging

logger = logging.getLogger(__name__)

class GenericAdapter(BaseAdapter):
    """
    通用高校适配器
    
    适用场景:
    - 大部分211/普通高校
    - 使用HTML表格展示招生信息
    - 分页或单页展示
    
    配置化设计:
    - 通过 config 参数传入XPath规则
    - 无需为每个高校写代码
    """
    
    def __init__(self, fetcher, university_name: str, config: Dict):
        """
        Args:
            config: 配置字典,包含:
                - catalog_url_template: 目录页URL模板
                - catalog_xpath: 目录页链接的XPath
                - table_xpath: 详情页表格的XPath
                - column_mapping: 列映射关系
        """
        super().__init__(fetcher, university_name)
        self.config = config
    
    def get_catalog_url(self, year: int = 2026) -> str:
        """
        根据模板生成URL
        
        模板示例:
        "https://yz.xxx.edu.cn/zsjz.html?year={year}"
        """
        template = self.config['catalog_url_template']
        return template.format(year=year)
    
    def parse_catalog(self, html: str) -> List[str]:
        """
        使用配置的XPath解析目录页
        """
        tree = etree.HTML(html)
        xpath = self.config['catalog_xpath']
        
        links = tree.xpath(xpath)
        
        # 补全URL
        base_url = self.config.get('base_url', '')
        full_urls = []
        
        for link in links:
            if link.startswith('http'):
                full_urls.append(link)
            else:
                full_urls.append(f"{base_url}{link}")
        
        return full_urls
    
    def parse_detail(self, html: str, url: str) -> List[Dict]:
        """
        解析详情页表格
        
        策略:
        1. 用XPath定位表格
        2. 提取表头
        3. 根据 column_mapping 映射列
        4. 逐行解析数据
        """
        tree = etree.HTML(html)
        table_xpath = self.config['table_xpath']
        
        tables = tree.xpath(table_xpath)
        if not tables:
            logger.warning(f"[通用适配器] 未找到表格")
            return []
        
        table = tables[0]
        
        # 提取表头
        headers = table.xpath('.//thead//th/text() | .//tr[1]//td/text()')
        headers = [h.strip() for h in headers if h.strip()]
        
        logger.debug(f"[表头] {headers}")
        
        # 提取数据行
        rows = table.xpath('.//tbody//tr | .//tr[position()>1]')
        
        programs = []
        column_mapping = self.config['column_mapping']
        
        for row in rows:
            cells = row.xpath('.//td//text()')
            cells = [c.strip() for c in cells if c.strip()]
            
            if len(cells) < len(headers):
                continue
            
            # 创建行字典
            row_dict = dict(zip(headers, cells))
            
            # 映射到标准字段
            program = {}
            for standard_field, source_field in column_mapping.items():
                program[standard_field] = row_dict.get(source_field, '')
            
            # 数据清洗
            program = self._clean_program(program)
            
            programs.append(program)
        
        return programs
    
    def _clean_program(self, program: Dict) -> Dict:
        """
        清洗单个专业数据
        
        处理:
        - 招生人数转数字
        - 学费转浮点数
        """
        # 招生人数
        if 'enrollment_plan' in program:
            program['enrollment_plan'] = self._extract_number(
                program['enrollment_plan']
            )
        
        # 学费
        if 'tuition' in program:
            program['tuition'] = self._extract_tuition(
                program['tuition']
            )
        
        return program
    
    def _extract_number(self, text: str) -> int:
        """提取数字"""
        match = re.search(r'\d+', str(text))
        return int(match.group()) if match else None
    
    def _extract_tuition(self, text: str) -> float:
        """提取学费"""
        numbers = re.findall(r'\d+\.?\d*', str(text))
        if not numbers:
            return None
        
        amount = float(numbers[0])
        
        if '万' in text:
            return amount * 10000
        else:
            return amount

配置文件示例

json 复制代码
{
    "清华大学": {
        "adapter": "TsinghuaAdapter",
        "priority": 1
    },
    "北京大学": {
        "adapter": "PkuAdapter",
        "priority": 1
    },
    "复旦大学": {
        "adapter": "GenericAdapter",
        "priority": 2,
        "config": {
            "catalog_url_template": "https://gsao.fudan.edu.cn/zsjz_{year}.html",
            "catalog_xpath": "//div[@class='news-list']//a/@href",
            "base_url": "https://gsao.fudan.edu.cn",
            "table_xpath": "//table[@class='admission-table']",
            "column_mapping": {
                "college": "学院名称",
                "program_code": "专业代码",
                "program_name": "专业名称",
                "enrollment_plan": "拟招人数",
                "duration": "学制",
                "tuition": "学费"
            }
        }
    },
    "上海交通大学": {
        "adapter": "GenericAdapter",
        "priority": 2,
        "config": {
            "catalog_url_template": "https://yzb.sjtu.edu.cn/zsjz.html?year={year}",
            "catalog_xpath": "//a[contains(@href, 'detail')]/@href",
            "base_url": "https://yzb.sjtu.edu.cn",
            "table_xpath": "//table[@id='program-table']",
            "column_mapping": {
                "college": "院系",
                "program_code": "代码",
                "program_name": "专业",
                "enrollment_plan": "人数",
                "duration": "年限",
                "tuition": "费用"
            }
        }
    }
}

适配器工厂

python 复制代码
# core/parser_factory.py
import json
from pathlib import Path
from typing import Dict
from .adapters.base_adapter import BaseAdapter
from .adapters.tsinghua_adapter import TsinghuaAdapter
from .adapters.generic_adapter import GenericAdapter
import logging

logger = logging.getLogger(__name__)

class ParserFactory:
    """
    适配器工厂
    
    职责:
    1. 加载配置文件
    2. 根据高校名称创建对应的适配器
    3. 管理适配器实例
    """
    
    def __init__(self, config_path: str = 'config/universities.json'):
        """
        Args:
            config_path: 配置文件路径
        """
        self.config_path = Path(config_path)
        self.config = self._load_config()
        self.adapter_cache = {}  # 缓存已创建的适配器
    
    def _load_config(self) -> Dict:
        """加载配置文件"""
        with open(self.config_path, 'r', encoding='utf-8') as f:
            config = json.load(f)
        
        logger.info(f"[配置] 加载了 {len(config)} 所高校的配置")
        return config
    
    def create_adapter(
        self, 
        university: str, 
        fetcher
    ) -> BaseAdapter:
        """
        创建适配器
        
        Args:
            university: 高校名称
            fetcher: 请求器实例
            
        Returns:
            对应的适配器实例
        """
        # 检查缓存
        if university in self.adapter_cache:
            logger.debug(f"[适配器] 使用缓存: {university}")
            return self.adapter_cache[university]
        
        # 获取配置
        if university not in self.config:
            logger.warning(f"[配置缺失] {university},使用通用适配器")
            # TODO: 返回默认的通用适配器
            return None
        
        university_config = self.config[university]
        adapter_name = university_config['adapter']
        
        # 根据适配器名称创建实例
        if adapter_name == 'TsinghuaAdapter':
            adapter = TsinghuaAdapter(fetcher, university)
        
        elif adapter_name == 'GenericAdapter':
            adapter = GenericAdapter(
                fetcher, 
                university, 
                university_config['config']
            )
        
        else:
            logger.error(f"[未知适配器] {adapter_name}")
            return None
        
        # 缓存
        self.adapter_cache[university] = adapter
        logger.info(f"[适配器] 创建成功: {university} -> {adapter_name}")
        
        return adapter
    
    def get_universities_by_priority(self) -> List[str]:
        """
        按优先级返回高校列表
        
        Returns:
            排序后的高校名称列表
        """
        universities = []
        
        for name, config in self.config.items():
            priority = config.get('priority', 999)
            universities.append((priority, name))
        
        # 按优先级排序
        universities.sort(key=lambda x: x[0])
        
        return [name for _, name in universities]

使用示例

python 复制代码
# main.py
from core.fetcher import AdmissionFetcher
from core.parser_factory import ParserFactory
import logging

logging.basicConfig(level=logging.INFO)

def main():
    # 初始化
    fetcher = AdmissionFetcher()
    factory = ParserFactory()
    
    # 获取高校列表(按优先级)
    universities = factory.get_universities_by_priority()
    
    all_data = []
    
    # 遍历高校
    for university in universities[:5]:  # 先测试前5所
        print(f"\n{'='*60}")
        print(f"开始采集: {university}")
        print('='*60)
        
        # 创建适配器
        adapter = factory.create_adapter(university, fetcher)
        
        if not adapter:
            continue
        
        # 采集数据
        programs = adapter.crawl(year=2026)
        
        all_data.extend(programs)
        
        print(f"完成: {university}, 采集到 {len(programs)} 个专业")
    
    print(f"\n总计采集: {len(all_data)} 个专业")
    
    # TODO: 保存到数据库

if __name__ == '__main__':
    main()

📚 文档说明

本教程系统讲解了如何构建一个生产级的研究生招生简章自动化采集系统,涵盖从架构设计到实际部署的完整流程。

由于教程内容极其丰富,已拆分为多个文档:

📄 主教程文档

  • graduate_admission_spider_tutorial.md - 核心教程(包含背景、技术选型、请求层实现)

💻 代码示例文档

  • adapters_code_examples.md - 完整的适配器模式实现代码

🎯 学习路径建议

初学者

  1. 先阅读主教程的"背景与需求"章节,理解项目价值
  2. 学习"技术选型"部分,了解整体架构
  3. 深入研究"请求层实现",这是爬虫的核心基础

中级开发者

  1. 重点学习"适配器模式"的设计思想
  2. 研究如何为不同网站编写解析规则
  3. 实践数据清洗和验证的最佳实践

高级开发者

  1. 关注系统的可扩展性设计
  2. 学习分布式爬虫和任务调度
  3. 探索数据分析和智能推荐功能

🔑 核心知识点速查

1. 请求层(Fetcher)

关键技术

  • HTTP 缓存(requests-cache)
  • 令牌桶限流算法
  • 熔断器模式
  • 反爬策略应对

代码位置

python 复制代码
# core/fetcher.py
class AdmissionFetcher:
    - fetch(): 主请求方法
    - _acquire_token(): 限流控制
    - _check_circuit_breaker(): 熔断检测

典型用法

python 复制代码
fetcher = AdmissionFetcher(rate_limit=10)  # 每分钟10次
html = fetcher.fetch(url, university='清华大学')

2. 适配器模式(Adapters)

设计思想

  • 每个高校一个适配器
  • 抽象基类定义统一接口
  • 模板方法模式编排流程

核心类

python 复制代码
BaseAdapter (抽象基类)
    ├── TsinghuaAdapter (清华 - PDF解析)
    ├── PkuAdapter (北大 - 混合格式)
    └── GenericAdapter (通用 - 配置驱动)

扩展新高校

json 复制代码
// config/universities.json
{
    "新增大学": {
        "adapter": "GenericAdapter",
        "config": {
            "catalog_url_template": "...",
            "table_xpath": "//table[@class='admission']"
        }
    }
}

3. 数据处理流水线

流程

复制代码
原始数据 → 清洗 → 验证 → 融合 → 存储

清洗器示例

python 复制代码
# processors/cleaner.py
class AdmissionCleaner:
    - normalize_program_name()  # 专业名称标准化
    - parse_enrollment_number() # 招生人数提取
    - format_datetime()         # 时间格式化

验证器示例

python 复制代码
# processors/validator.py
class AdmissionValidator:
    - validate_required_fields()  # 必填项检查
    - check_logical_consistency() # 逻辑校验
    - detect_anomalies()          # 异常值检测

4. 变更检测与监控

功能

  • 自动检测招生计划变更
  • 识别新增/删除专业
  • 扩招/缩招提醒

实现原理

python 复制代码
# monitor/change_detector.py

def detect_changes(old_data, new_data):
    changes = []
    
    for program in new_data:
        old_program = find_in_old(program)
        
        if not old_program:
            changes.append({
                'type': 'NEW',
                'program': program
            })
        elif old_program['enrollment'] != program['enrollment']:
            changes.append({
                'type': 'MODIFIED',
                'old': old_program,
                'new': program
            })
    
    return changes

💡 最佳实践总结

1. 爬虫开发

推荐做法

  • 始终保存原始HTML/PDF(便于调试)
  • 使用缓存减少重复请求
  • 实现完善的错误处理和重试机制
  • 添加详细的日志记录

避免的坑

  • 不要疯狂并发(容易被封IP)
  • 不要忽略robots.txt
  • 不要硬编码XPath(网站改版就失效)
  • 不要假设数据格式永远不变

2. 数据质量

质量保障

  • 多源数据交叉验证
  • 异常值自动检测和人工审核
  • 保留历史版本便于回溯
  • 定期人工抽查

常见错误

  • 直接信任爬取的数据
  • 忽略数据清洗
  • 不做逻辑一致性检查

3. 系统维护

运维建议

  • 使用定时任务自动更新
  • 配置监控告警(失败率、响应时间)
  • 建立适配器测试用例
  • 保持配置文件版本控制

维护陷阱

  • 网站改版后未及时更新适配器
  • 没有监控导致数据长期缺失
  • 硬盘满了但没有清理旧数据

🚀 进阶扩展方向

1. 分布式爬虫

技术方案

  • 使用 Celery + Redis 实现任务队列
  • 多台服务器并行采集
  • 中央数据库统一存储

架构图

json 复制代码
Master 调度器
    ├── Worker 1 (采集 985 高校)
    ├── Worker 2 (采集 211 高校)
    └── Worker 3 (采集普通高校)
           ↓
    PostgreSQL 数据库

2. 智能分析

功能列表

  • 专业热度排行(报考人数/招生人数)
  • 录取难度预测(基于历史分数线)
  • 院校对比工具(多维度对比)
  • 个性化推荐(根据用户偏好)

机器学习应用

python 复制代码
from sklearn.ensemble import RandomForestRegressor

# 特征:学校排名、专业类别、往年分数线
X = [[985, 1, 350], [211, 2, 320], ...]

# 标签:录取概率
y = [0.15, 0.28, ...]

model = RandomForestRegressor()
model.fit(X, y)

# 预测
probability = model.predict([[985, 1, 330]])

3. 数据服务化

API 设计

python 复制代码
from flask import Flask, jsonify

app = Flask(__name__)

@app.route('/api/universities')
def get_universities():
    """获取所有高校列表"""
    return jsonify(universities)

@app.route('/api/programs/<university>')
def get_programs(university):
    """获取某高校的专业列表"""
    programs = query_database(university)
    return jsonify(programs)

@app.route('/api/compare')
def compare_programs():
    """对比多个专业"""
    program_ids = request.args.getlist('ids')
    comparison = generate_comparison(program_ids)
    return jsonify(comparison)

前端界面

  • React/Vue 开发 Web 应用
  • 小程序端开发
  • 移动端 APP

4. 实时通知

通知渠道

  • 企业微信 Webhook
  • 邮件通知
  • 短信通知(重要变更)
  • 微信公众号推送

示例

python 复制代码
import requests

def send_wechat_notification(message):
    """发送企业微信通知"""
    webhook_url = "https://qyapi.weixin.qq.com/cgi-bin/webhook/send?key=..."
    
    data = {
        "msgtype": "markdown",
        "markdown": {
            "content": f"""
            ### 招生简章变更提醒
            
            **高校**: 清华大学
            **专业**: 计算机科学与技术
            **变化**: 招生人数从 30 人扩招至 40 人
            
            [查看详情]({detail_url})
            """
        }
    }
    
    requests.post(webhook_url, json=data)

📊 项目价值总结

技术价值

  1. 架构设计:学习了适配器模式、工厂模式、模板方法模式的实际应用
  2. 工程实践:掌握了如何设计一个可扩展、可维护的大型爬虫系统
  3. 问题解决:学会应对反爬虫、数据清洗、多源融合等实际问题

业务价值

  1. 信息聚合:将分散的招生信息集中到一个平台
  2. 效率提升:节省考生手动查询的时间(从6小时缩短至1分钟)
  3. 决策支持:提供数据驱动的择校建议
  4. 商业潜力:可开发成考研服务产品

社会价值

  1. 信息平等:让所有考生都能便捷获取完整信息
  2. 透明化:促进高校招生信息公开
  3. 理性择校:减少信息不对称导致的错误决策

🎓 学习资源推荐

书籍

  1. 《Web Scraping with Python》 - Ryan Mitchell

    • 爬虫入门经典
    • 涵盖lxml、Scrapy等工具
  2. 《Python数据分析实战》

    • pandas 深入教程
    • 数据清洗最佳实践
  3. 《设计模式:可复用面向对象软件的基础》

    • 学习适配器、工厂等模式
    • 提升代码设计能力

在线课程

  1. Scrapy 官方教程

  2. Real Python - Web Scraping

工具推荐

  1. XPath Helper (Chrome插件)

    • 可视化测试XPath表达式
  2. Postman

    • 测试API接口
    • 调试HTTP请求
  3. DB Browser for SQLite / pgAdmin

    • 数据库可视化管理
  4. Jupyter Notebook

    • 数据探索和分析
    • 快速验证想法

📝 常见问题 FAQ

Q1: 这个系统合法吗?

A: 完全合法。招生简章是高校主动公开的信息,我们只是将分散的信息整合。但需要注意:

  • 遵守robots.txt
  • 不要过度请求
  • 数据仅供参考,不得用于商业欺诈

Q2: 为什么不直接用研招网数据?

A: 研招网数据有两个问题:

  1. 更新滞后(通常比高校官网晚1-2周)
  2. 信息不够详细(缺少导师、研究方向等)

所以最佳方案是:研招网 + 高校官网 双源融合

Q3: 适配器维护成本高吗?

A: 取决于网站稳定性:

  • 稳定的网站(清华、北大):一年可能改一次
  • 频繁改版的网站:可能每月都要调整

降低成本的方法

  • 使用配置文件而非硬编码
  • 编写单元测试检测适配器失效
  • 设置监控告警

Q4: 数据准确性如何保证?

A: 多重保障:

  1. 多源数据交叉验证
  2. 异常值自动检测
  3. 定期人工抽查
  4. 用户反馈机制

Q5: 遇到验证码怎么办?

A: 分情况处理:

  1. 简单验证码:可以用 OCR 识别
  2. 滑块验证:难以破解,建议等待或手动
  3. 频繁触发:说明请求太快,降低频率

最佳实践

  • 礼貌访问,避免触发验证码
  • 设置合理的延时和限流
  • 实在绕不过就人工介入

🎉 结语

开发这个系统的过程中,我最大的收获不是技术本身,而是对"技术如何服务于人"的理解。

每年有 400 万+ 考生参加研究生考试,他们中的很多人:

  • 不知道某个心仪的专业今年扩招了
  • 因为信息不对称错过了更适合的学校
  • 浪费大量时间在手动查询和整理数据上

如果这个系统能帮助哪怕 1% 的考生节省时间、做出更好的决策,那就值了。

技术的价值不在于炫技,而在于实实在在地解决问题。希望这篇教程能帮你:

  • 学会构建一个生产级爬虫系统
  • 理解如何设计可扩展的软件架构
  • 找到技术与社会价值的结合点

🌟 文末

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

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

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

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

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

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

✅ 互动征集

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

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


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

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

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


✅ 免责声明

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

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

  • 合法使用: 不得将本项目用于任何违法、违规或侵犯他人权益的行为,包括但不限于网络攻击、诈骗、绕过身份验证、未经授权的数据抓取等。
  • 风险自负: 任何因使用本项目而产生的法律责任、技术风险或经济损失,由使用者自行承担,项目作者不承担任何形式的责任。
  • 禁止滥用: 不得将本项目用于违法牟利、黑产活动或其他不当商业用途。
  • 使用或者参考本项目即视为同意上述条款,即 "谁使用,谁负责" 。如不同意,请立即停止使用并删除本项目。!!!
相关推荐
If using 10 days1 小时前
multiprocessing:创建并管理多个进程
python·算法
paradoxaaa_2 小时前
cusor无限续杯教程
python
m5655bj2 小时前
通过 Python 删除 Excel 中的空白行列
python·ui·excel
全栈前端老曹2 小时前
【Redis】Redis 客户端连接与编程实践——Python/Java/Node.js 连接 Redis、实现计数器、缓存接口
前端·数据库·redis·python·缓存·全栈
橙露2 小时前
排序算法可视化:用 Java 实现冒泡、快排与归并排序的对比分析
java·python·排序算法
喵手2 小时前
Python爬虫实战:构建全球节假日数据库 - requests+lxml 实战时区节假日网站采集(附CSV导出 + SQLite持久化存储)!
爬虫·python·爬虫实战·零基础python爬虫教学·构建全球节假日数据库·采集时区节假日数据·采集节假日sqlite存储
galaxyffang2 小时前
A2A协议的简单应用
python·ai
一晌小贪欢2 小时前
Python在物联网(IoT)中的应用:从边缘计算到云端数据处理
开发语言·人工智能·python·物联网·边缘计算
好家伙VCC2 小时前
# 发散创新:基于Solidity的DeFi协议设计与实现——从原理到实战代码解析在区块链世界中,**DeFi(去中心化金
java·python·去中心化·区块链