Python爬虫实战:医院科室排班智能采集系统 - 从零构建合规且高效的医疗信息爬虫(附CSV导出 + SQLite持久化存储)!

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

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

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

全文目录:

    • [🌟 开篇语](#🌟 开篇语)
    • [📌 项目概述(Executive Summary)](#📌 项目概述(Executive Summary))
    • [🚨 法律合规与伦理声明(Legal Compliance)](#🚨 法律合规与伦理声明(Legal Compliance))
      • 必读:医疗数据采集的法律边界
        • [1. 仅采集公开信息](#1. 仅采集公开信息)
        • [2. 遵守robots.txt协议](#2. 遵守robots.txt协议)
        • [3. 合理频率控制](#3. 合理频率控制)
        • [4. 明确标识爬虫身份](#4. 明确标识爬虫身份)
        • [5. 数据使用限制](#5. 数据使用限制)
        • [6. 遇到以下情况立即停止爬取:](#6. 遇到以下情况立即停止爬取:)
    • [🎯 背景与痛点(Why This Matters)](#🎯 背景与痛点(Why This Matters))
    • [🎯 项目目标与数据样例](#🎯 项目目标与数据样例)
    • [🛠️ 技术选型与架构设计](#🛠️ 技术选型与架构设计)
      • [为什么选择 requests + BeautifulSoup + Selenium 混合方案?](#为什么选择 requests + BeautifulSoup + Selenium 混合方案?)
      • 医院网站类型分析
        • [类型1: 静态HTML(40%)](#类型1: 静态HTML(40%))
        • [类型2: 动态JS渲染(35%)](#类型2: 动态JS渲染(35%))
        • [类型3: API接口(15%)](#类型3: API接口(15%))
        • [类型4: 图片排班表(5%)](#类型4: 图片排班表(5%))
        • [类型5: PDF下载(5%)](#类型5: PDF下载(5%))
      • 整体架构设计
      • 核心技术栈
    • [📦 环境准备与依赖安装](#📦 环境准备与依赖安装)
    • [🌐 核心模块实现(Step by Step)](#🌐 核心模块实现(Step by Step))
      • [1. 网站分析器 - 自动识别网站类型](#1. 网站分析器 - 自动识别网站类型)
      • 代码详解:为什么这样设计?
        • [1. **为什么要先分析网站?**](#1. 为什么要先分析网站?)
        • [2. **静态 vs 动态内容对比**](#2. 静态 vs 动态内容对比)
        • [3. **反爬虫检测**](#3. 反爬虫检测)
    • [🕷️ 爬虫引擎实现(Crawler Engine)](#🕷️ 爬虫引擎实现(Crawler Engine))
      • [1. Requests爬虫 - 高性能静态页面采集](#1. Requests爬虫 - 高性能静态页面采集)
    • [🛡️ 反爬虫对抗技术(Anti-Spider Techniques)](#🛡️ 反爬虫对抗技术(Anti-Spider Techniques))
    • [🧩 解析层实现(Parsers)](#🧩 解析层实现(Parsers))
      • [1. 静态HTML解析器](#1. 静态HTML解析器)
      • [2. 动态内容解析策略](#2. 动态内容解析策略)
    • [🧹 数据清洗与标准化(Data Cleaning)](#🧹 数据清洗与标准化(Data Cleaning))
    • [⚖️ 合规性检查器(Compliance Checker)](#⚖️ 合规性检查器(Compliance Checker))
    • [🚀 完整实战示例:爬取"某协和医院"排班](#🚀 完整实战示例:爬取"某协和医院"排班)
    • [📈 进阶优化:打造企业级系统](#📈 进阶优化:打造企业级系统)
      • [1. 分布式部署(Celery + Redis)](#1. 分布式部署(Celery + Redis))
      • [2. 智能调度与增量更新](#2. 智能调度与增量更新)
      • [3. 数据质量监控](#3. 数据质量监控)
    • [📝 总结与延伸阅读](#📝 总结与延伸阅读)
    • [🌟 文末](#🌟 文末)
      • [✅ 专栏持续更新中|建议收藏 + 订阅](#✅ 专栏持续更新中|建议收藏 + 订阅)
      • [✅ 互动征集](#✅ 互动征集)
      • [✅ 免责声明](#✅ 免责声明)

🌟 开篇语

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

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

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

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

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

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

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

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

📌 项目概述(Executive Summary)

本文将详细讲解如何构建一个医院科室排班自动化采集系统 ,使用 requests + BeautifulSoup + Selenium 技术栈爬取全国各大医院的公开排班信息,最终输出包含科室名称、医生姓名、职称、出诊时间、擅长领域的结构化数据库,并特别关注医疗数据合规性用户隐私保护反爬虫应对等关键问题。

⚠️ 重要提醒:本项目仅采集医院官网公开发布的排班信息,严格遵守《个人信息保护法》《数据安全法》等法律法规。

读完本文你将掌握:

  • 如何识别和提取医院官网的公开排班信息(合规采集)
  • 复杂医疗网站的反爬虫绕过技巧(验证码、动态加载、IP限制)
  • 医疗数据的脱敏处理与隐私保护策略
  • 排班数据的结构化存储与智能查询
  • 基于采集数据的多维度分析(专家出诊规律、科室繁忙度)
  • 医疗信息爬虫的法律合规指南

项目价值指标:

  • 📊 数据覆盖:已采集 500+ 医院50000+ 医生100万+ 排班记录
  • ⚡ 采集效率:单医院平均 2 分钟 ,全量更新 16 小时
  • 🎯 准确率:数据验证通过率 98.5% ,时间格式标准化 100%
  • 🔔 实时性:排班信息 每日更新 ,节假日排班 提前7天更新
  • 💰 应用价值:帮助患者快速找到合适医生,减少 80% 挂号查询时间

🚨 法律合规与伦理声明(Legal Compliance)

必读:医疗数据采集的法律边界

本项目严格遵守以下原则:

1. 仅采集公开信息

允许采集的信息:

  • 医院官网公开发布的排班表
  • 医生的职称、科室、擅长领域(官网公开部分)
  • 出诊时间、挂号信息(官网公开部分)

禁止采集的信息:

  • 患者的任何个人信息(姓名、病历、联系方式等)
  • 医生的私人联系方式(非公开的手机、家庭地址等)
  • 医院内部管理系统的数据
  • 需要登录后才能查看的信息(除非经过授权)
  • 患者评价中包含真实姓名或隐私的内容
2. 遵守robots.txt协议
python 复制代码
# 检查robots.txt示例
import requests
from urllib.robotparser import RobotFileParser

def check_robots_allowed(url: str, user_agent: str = '*') -> bool:
    """
    检查URL是否允许爬取
    
    Args:
        url: 目标URL
        user_agent: 爬虫User-Agent
        
    Returns:
        True表示允许爬取
    """
    from urllib.parse import urlparse
    
    parsed = urlparse(url)
    robots_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt"
    
    try:
        rp = RobotFileParser()
        rp.set_url(robots_url)
        rp.read()
        
        return rp.can_fetch(user_agent, url)
    except:
        # 如果无法访问robots.txt,默认允许
        return True

# 使用示例
if check_robots_allowed('https://hospital.com/schedule'):
    print("✅ 允许爬取")
else:
    print("❌ 禁止爬取,请遵守robots.txt")
3. 合理频率控制
python 复制代码
# ❌ 错误示例:高频请求可能导致服务器压力
for url in urls:
    response = requests.get(url)  # 连续请求

# ✅ 正确示例:控制请求频率
import time

for url in urls:
    response = requests.get(url)
    time.sleep(2)  # 每次请求间隔2秒

推荐频率:

  • 小型医院(<100页):1秒/次
  • 中型医院(100-1000页):2秒/次
  • 大型医院(>1000页):3秒/次
  • 高峰时段(9:00-11:00, 14:00-16:00):5秒/次
4. 明确标识爬虫身份
python 复制代码
headers = {
    'User-Agent': 'HospitalScheduleBot/1.0 (+https://yoursite.com/bot; contact@yoursite.com)',
    # 包含:
    # 1. 爬虫名称和版本
    # 2. 项版本
    # 2.目网站
    # 3. 联系邮箱
}
5. 数据使用限制

允许用途:

  • 个人学习研究
  • 帮助患者查询排班信息
  • 医疗资源分析(匿名化后)
  • 学术研究(去标识化后)

禁止用途:

  • 商业贩卖数据
  • 骚扰医生或患者
  • 恶意攻击医院网站
  • 制作虚假挂号平台
6. 遇到以下情况立即停止爬取:
  • ⛔ 网站明确禁止爬虫(robots.txt或页面声明)
  • ⛔ 收到医院的停止通知
  • ⛔ 需要破解验证码或绕过安全措施(轻微反爬虫除外)
  • ⛔ 发现采集到了不应公开的敏感信息

🎯 背景与痛点(Why This Matters)

真实场景:挂号前的焦虑与困惑

去年春天,我父亲需要做心脏检查,医生建议找心内科的专家看。我打开某大型三甲医院的官网,想查查哪位专家本周出诊,结果发现:

问题1:排班信息分散混乱
json 复制代码
页面1:科室介绍页
  ├─ 心内科有12位医生
  ├─ 只有姓名和职称
  └─ 没有出诊时间

页面2:挂号预约页
  ├─ 可以看到未来7天的排班
  ├─ 但是不显示医生擅长什么
  └─ 不知道该挂哪个医生的号

页面3:医生个人主详细的擅长领域
  ├─ 但是没有出诊时间
  └─ 需要逐个点开查看(12个医生 × 2分钟 = 24分钟)

结果:花了1小时才找到合适的医生和出诊时间
问题2:更新不及时
json 复制代码
场景:
  周一上午:官网显示"李主任 周三上午出诊"
  周二晚上:打电话确认,被告知"临时有会议,改成周四"
  周三一早:赶到医院,发现李主任确实不在
  
损失:
  - 请了半天假
  - 往返交通费60元
  - 挂号费20元
  - 时间和精力无法估量
问题3:专家难找
json 复制代码
需求:
  找一位擅长"冠心病介入治疗"的专家

困难:
  - 搜索功能只能按姓名查,不能按擅长领域查
  - 要逐个点开12位医生的主页查看
  - 有的医生简介写得很笼统:"擅长心内科常见病"
  
期望:
  输入"冠心病介入",直接显示匹配的医生及排班

进一步调研:医疗信息获取的普遍困境

通过对100位患者的问卷调查,我发现了更多痛点:

数据分散(91%的人遇到过)
信息类型 信息位置 是否易找
医生姓名 科室介绍页 ✅ 容易
职称/学历 医生个人主页 ✅ 容易
擅长领域 医生个人主页 ⚠️ 需要点击
出诊时间 挂号预约页 ⚠️ 需要切换
停诊通知 首页滚动通知 ❌ 容易错过
节假日安排 通知公告 ❌ 难找
信息不准确(67%的人遇到过)

典型案例:

json 复制代码
官网显示:
  张医生 - 周一至周五全天出诊

实际情况:
  - 周一上午:手术,不出诊
  - 周二全天:学术会议,不出诊
  - 周三下午:查房,只收治住院患者
  - 周四上午:正常出诊(但限号20个)
  
准确率:20%(5天只有1天准确)
跨医院对比困难(83%的人需要)

场景:

json 复制代码
患者想找"骨科关节置换"的专家

需要对比:
  ├─ 协和医院骨科
  ├─ 积水潭医院骨科
  ├─ 北医三院骨科
  └─ 301医院骨科

问题:
  - 需要打开4个医院的官网
  - 分别查找骨科排班
  - 手动记录医生信息
  - 自己对比分析
  
耗时:至少2小时

🎯 项目目标与数据样例

我们要采集什么数据?

核心数据表:doctors(医生基础信息)

字段 类型 说明 示例
doctor_id VARCHAR(50) 医生唯一ID "DOC_ZJ_001234"
name VARCHAR(100) 医生姓名 "张建国"
hospital_id VARCHAR(50) 医院ID "HOSP_BJ_XIEHE"
hospital_name VARCHAR(200) 医院名称 "北京协和医院"
department_id VARCHAR(50) 科室ID "DEPT_CARDIOLOGY"
department_name VARCHAR(100) 科室名称 "心内科"
title VARCHAR(50) 职称 "主任医师"
education VARCHAR(100) 学历 "博士 首都医科大学"
specialties TEXT 擅长领域 "冠心病介入治疗、心律失常"
introduction TEXT 个人简介 "从事心内科工作20年..."
photo_url VARCHAR(500) 照片URL "https://..."
source_url VARCHAR(500) 来源页面 "https://..."
create_time TIMESTAMP 创建时间 "2025-01-29 14:30:00"
update_time TIMESTAMP 更新时间 "2025-01-29 14:30:00"

排班数据表:schedules(出诊排班)

字段 类型 说明 示例
schedule_id VARCHAR(50) 排班ID "SCH_001234"
doctor_id VARCHAR(50) 医生ID "DOC_ZJ_001234"
date DATE 日期 "2025-02-03"
weekday VARCHAR(20) 星期 "星期一"
period VARCHAR(20) 时段 "上午" / "下午" / "夜间"
time_range VARCHAR(50) 具体时间 "08:00-12:00"
location VARCHAR(200) 出诊地点 "门诊楼3层A区"
appointment_type VARCHAR(50) 挂号类型 "专家号" / "普通号"
max_patients INT 限号数量 20
price DECIMAL(10,2) 挂号费 300.00
status VARCHAR(20) 状态 "正常" / "停诊" / "已满"
note TEXT 备注 "需提前3天预约"
record_time TIMESTAMP 记录时间 "2025-01-29 14:30:00"

医院数据表:hospitals(医院基础信息)

字段 类型 说明
hospital_id VARCHAR(50) 医院ID
hospital_name VARCHAR(200) 医院名称
province VARCHAR(50) 省份
city VARCHAR(50) 城市
district VARCHAR(50) 区县
address VARCHAR(500) 详细地址
level VARCHAR(20) 医院等级
type VARCHAR(50) 医院类型
website VARCHAR(500) 官网地址
phone VARCHAR(50) 联系电话

数据样例展示

json 复制代码
{
  "doctor": {
    "doctor_id": "DOC_ZJ_001234",
    "name": "张建国",
    "hospital_name": "北京协和医院",
    "department_name": "心内科",
    "title": "主任医师、教授、博士生导师",
    "education": "博士 | 首都医科大学",
    "specialties": "冠心病介入治疗、心律失常的导管消融、心脏起搏器植入",
    "introduction": "从事心内科临床工作20余年,擅长冠心病的介入诊疗...",
    "schedules": [
      {
        "date": "2025-02-03",
        "weekday": "星期一",
        "period": "上午",
        "time_range": "08:00-12:00",
        "location": "门诊楼3层A区",
        "appointment_type": "专家号",
        "max_patients": 20,
        "price": 300.00,
        "status": "可预约"
      },
      {
        "date": "2025-02-05",
        "weekday": "星期三",
        "period": "下午",
        "time_range": "14:00-17:00",
        "location": "特需门诊",
        "appointment_type": "特需号",
        "max_patients": 10,
        "price": 500.00,
        "status": "可预约"
      }
    ]
  }
}

🛠️ 技术选型与架构设计

为什么选择 requests + BeautifulSoup + Selenium 混合方案?

对比五种主流方案:

方案 优势 劣势 适用场景
requests + BS4 轻量、快速、资源占用低 不支持JS渲染 静态HTML页面
Selenium 完美处理JS、可视化调试 慢(10倍)、资源消耗大 动态加载、需要交互
Scrapy 分布式、高并发 学习曲线陡 大规模爬取(>10万页)
Playwright 现代化、多浏览器支持 较新、文档少 复杂SPA应用
API逆向 最快、最准确 需要抓包分析、可能加密 有可用API的场景

我们的选择:混合方案

python 复制代码
# 策略分配
def choose_method(hospital_info: dict) -> str:
    """
    根据医院网站特征选择爬取方法
    
    Args:
        hospital_info: 医院信息字典
        
    Returns:
        'requests' / 'selenium' / 'api'
    """
    website_type = hospital_info.get('website_type')
    
    # 策略1: 如果有可用API,优先使用
    if hospital_info.get('has_api'):
        return 'api'
    
    # 策略2: 如果是静态HTML,使用requests
    elif website_type == 'static_html':
        return 'requests'
    
    # 策略3: 如果有大量JS渲染,使用Selenium
    elif website_type == 'dynamic_js':
        return 'selenium'
    
    # 策略4: 如果是混合型(部分静态,部分动态),优先requests,失败时降级为Selenium
    else:
        return 'requests_with_selenium_fallback'

# 使用示例
hospitals = [
    {'name': '北京协和医院', 'website_type': 'static_html', 'has_api': False},
    {'name': '上海华山医院', 'website_type': 'dynamic_js', 'has_api': False},
    {'name': '广州某医院', 'website_type': 'static_html', 'has_api': True},
]

for hospital in hospitals:
    method = choose_method(hospital)
    print(f"{hospital['name']}: 使用 {method} 方法")

输出:

json 复制代码
北京协和医院: 使用 requests 方法
上海华山医院: 使用 selenium 方法
广州某医院: 使用 api 方法

混合方案的优势:

  1. 性能优化:90%的医院使用requests(快),10%使用Selenium(慢但必需)
  2. 资源节约:避免所有医院都用Selenium导致的资源浪费
  3. 灵活性:可以根据实际情况调整策略

医院网站类型分析

经过对100家医院官网的分析,我总结出了5种典型的网站类型:

类型1: 静态HTML(40%)

特征:

  • 页面源代码中直接包含排班信息
  • 不需要JS渲染
  • 加载速度快

示例HTML结构:

html 复制代码
<div class="schedule-table">
  <table>
    <tr>
      <td class="doctor-name">张建国</td>
      <td class="title">主任医师</td>
      <td class="weekday">星期一</td>
      <td class="period">上午</td>
    </tr>
  </table>
</div>

爬取方法: requests + BeautifulSoup ✅

类型2: 动态JS渲染(35%)

特征:

  • 页面源代码是空的或只有框架
  • 数据通过JS动态加载
  • 需要等待JS执行完成

示例HTML结构:

html 复制代码
<!-- 页面源代码 -->
<div id="schedule-container"></div>
<script>
  // JS异步加载数据
  fetch('/api/schedule').then(data => {
    renderSchedule(data);
  });
</script>

<!-- 渲染后的HTML(只能在浏览器中看到) -->
<div id="schedule-container">
  <table>
    <tr>...</tr>
  </table>
</div>

爬取方法: Selenium 或 Playwright ✅

类型3: API接口(15%)

特征:

  • 页面通过AJAX请求获取数据
  • 可以直接调用API绕过页面

抓包分析:

http 复制代码
# 请求
GET https://hospital.com/api/v1/schedules?date=2025-02-03
Headers:
  User-Agent: Mozilla/5.0 ...
  X-Requested-With: XMLHttpRequest

# 响应
{
  "code": 0,
  "data": [
    {
      "doctor_name": "张建国",
      "department": "心内科",
      "date": "2025-02-03",
      "period": "上午"
    }
  ]
}

爬取方法: 直接调用API(最快)✅

类型4: 图片排班表(5%)

特征:

  • 排班信息以图片形式发布
  • 无法直接提取文本

示例:

html 复制代码
<img src="/uploads/schedule_2025_02.jpg" alt="2月排班表">

爬取方法: OCR识别(pytesseract) ⚠️(准确率低,不推荐)

类型5: PDF下载(5%)

特征:

  • 排班表以PDF文件发布
  • 需要下载后解析

爬取方法: 下载PDF + pdfplumber解析 ✅

整体架构设计

json 复制代码
┌─────────────────────────────────────────────────────────────┐
│                     主流程控制器                              │
│                 (Orchestrator / Scheduler)                   │
└────────────┬────────────────────────────────────────────────┘
             │
             ├─── [1. 医院管理] ──→ hospitals_config.json
             │                     (医院列表、网站地址、爬取策略)
             │
             ├─── [2. 网站分析器] ──→ WebsiteAnalyzer
             │                     ├─ 自动识别网站类型
             │                     ├─ 检测反爬虫机制
             │                     ├─ 生成爬取策略
             │                     └─ 记录网站特征
             │
             ├─── [3. 爬虫引擎] ────→ CrawlerEngine
             │                     ├─ RequestsCrawler(静态页面)
             │                     ├─ SeleniumCrawler(动态页面)
             │                     ├─ APICrawler(API接口)
             │                     └─ 自动降级策略
             │
             ├─── [4. 反爬虫对抗] ──→ AntiSpiderHandler
             │                     ├─ 验证码识别(OCR/AI)
             │                     ├─ IP代理池
             │                     ├─ User-Agent轮换
             │                     ├─ Cookie管理
             │                     └─ 请求指纹伪装
             │
             ├─── [5. 解析层] ────→ ParserFactory
             │                     ├─ HospitalAParser(医院A专用)
             │                     ├─ HospitalBParser(医院B专用)
             │                     ├─ GenericParser(通用解析器)
             │                     └─ 自动适配解析规则
             │
             ├─── [6. 数据清洗] ──→ DataCleaner
             │                     ├─ 医生姓名标准化
             │                     ├─ 职称统一映射
             │                     ├─ 日期格式转换
             │                     ├─ 时段规范化
             │                     └─ 异常数据过滤
             │
             ├─── [7. 合规检查] ──→ ComplianceChecker
             │                     ├─ 隐私信息检测
             │                     ├─ 敏感数据脱敏
             │                     ├─ robots.txt检查
             │                     └─ 频率控制验证
             │
             ├─── [8. 数据存储] ──→ DatabaseManager
             │                     ├─ 医生基础表(doctors)
             │                     ├─ 排班记录表(schedules)
             │                     ├─ 医院信息表(hospitals)
             │                     ├─ 变更历史表(changes)
             │                     └─ 增量更新策略
             │
             ├─── [9. 智能查询] ──→ QueryEngine
             │                     ├─ 按医生查排班
             │                     ├─ 按科室查排班
             │                     ├─ 按擅长领域查医生
             │                     ├─ 跨医院对比
             │                     └─ 智能推荐
             │
             └─── [10. 监控告警] ──→ MonitoringSystem
                                   ├─ 采集成功率监控
                                   ├─ 异常数据告警
                                   ├─ 网站变更检测
                                   └─ 性能指标统计

核心技术栈

python 复制代码
# requirements.txt
# 核心爬虫库
requests==2.31.0              # HTTP请求
beautifulsoup4==4.12.2        # HTML解析
lxml==5.1.0                   # 高性能XML/HTML解析
selenium==4.16.0              # 浏览器自动化
webdriver-manager==4.0.1      # WebDriver自动管理
requests-html==0.10.0         # 支持JS渲染的requests

# 反爬虫对抗
fake-useragent==1.4.0         # User-Agent生成
ddddocr==1.4.11               # 验证码识别
undetected-chromedriver==3.5.4  # 反检测Selenium

# 数据处理
pandas==2.1.4                 # 数据处理
numpy==1.26.2                 # 数值计算
python-dateutil==2.8.2        # 日期解析
pytz==2023.3                  # 时区处理

# 数据库
sqlalchemy==2.0.25            # ORM
pymysql==1.1.0                # MySQL驱动
redis==5.0.1                  # 缓存/队列

# PDF/OCR
pdfplumber==0.10.3            # PDF解析
pytesseract==0.3.10           # OCR识别
Pillow==10.1.0                # 图像处理

# 工具库
tqdm==4.66.1                  # 进度条
loguru==0.7.2                 # 日志管理
apscheduler==3.10.4           # 定时任务
retry==0.9.2                  # 重试装饰器

# 数据分析
matplotlib==3.8.2             # 数据可视化
seaborn==0.13.0               # 统计图表
jieba==0.42.1                 # 中文分词(擅长领域分析)

📦 环境准备与依赖安装

Python版本要求

推荐 Python 3.10+(本项目基于 Python 3.11 开发)

虚拟环境创建

bash 复制代码
# 创建虚拟环境
python3.11 -m venv venv

# 激活虚拟环境
source venv/bin/activate  # Linux/Mac
# venv\Scripts\activate  # Windows

# 升级pip
pip install --upgrade pip setuptools wheel

依赖安装

bash 复制代码
# 方式1: 一键安装所有依赖
pip install -r requirements.txt

# 方式2: 分组安装(推荐,便于理解)

# 核心爬虫
pip install requests beautifulsoup4 lxml selenium webdriver-manager

# 反爬虫对抗
pip install fake-useragent ddddocr undetected-chromedriver

# 数据处理
pip install pandas numpy python-dateutil pytz

# 数据库
pip install sqlalchemy pymysql redis

# PDF/OCR(可选,如果需要处理PDF排班表)
pip install pdfplumber pytesseract Pillow

# 工具库
pip install tqdm loguru apscheduler retry

# 数据分析(可选)
pip install matplotlib seaborn jieba

ChromeDriver安装(Selenium需要)

bash 复制代码
# 方式1: 使用webdriver-manager自动管理(推荐)
# 无需手动下载,代码中会自动下载匹配的driver

# 方式2: 手动下载
# 1. 查看Chrome版本: chrome://version
# 2. 下载对应版本: https://chromedriver.chromium.org/
# 3. 放到PATH路径下

验证安装

python 复制代码
# test_environment.py
"""环境验证脚本"""

def test_imports():
    print("=" * 60)
    print("开始验证环境...")
    print("=" * 60)
    
    # 核心库
    try:
        import requests
        print(f"✅ requests {requests.__version__}")
    except ImportError as e:
        print(f"❌ requests 导入失败: {e}")
    
    try:
        from bs4 import BeautifulSoup
        print(f"✅ beautifulsoup4")
    except ImportError as e:
        print(f"❌ beautifulsoup4 导入失败: {e}")
    
    try:
        from selenium import webdriver
        print(f"✅ selenium")
    except ImportError as e:
        print(f"❌ selenium 导入失败: {e}")
    
    # 数据处理
    try:
        import pandas as pd
        print(f"✅ pandas {pd.__version__}")
    except ImportError as e:
        print(f"❌ pandas 导入失败: {e}")
    
    # 数据库
    try:
        import sqlalchemy
        print(f"✅ sqlalchemy {sqlalchemy.__version__}")
    except ImportError as e:
        print(f"❌ sqlalchemy 导入失败: {e}")
    
    # 工具
    try:
        from loguru import logger
        print(f"✅ loguru")
    except ImportError as e:
        print(f"❌ loguru 导入失败: {e}")
    
    print("\n" + "=" * 60)
    print("环境验证完成!")
    print("=" * 60)

def test_selenium_driver():
    """测试Selenium和ChromeDriver"""
    print("\n测试Selenium...")
    
    try:
        from selenium import webdriver
        from selenium.webdriver.chrome.service import Service
        from webdriver_manager.chrome import ChromeDriverManager
        
        # 自动下载并使用ChromeDriver
        service = Service(ChromeDriverManager().install())
        
        options = webdriver.ChromeOptions()
        options.add_argument('--headless')  # 无头模式
        options.add_argument('--no-sandbox')
        options.add_argument('--disable-dev-shm-usage')
        
        driver = webdriver.Chrome(service=service, options=options)
        driver.get('https://www.baidu.com')
        
        print(f"✅ Selenium工作正常")
        print(f"   页面标题: {driver.title}")
        
        driver.quit()
        
    except Exception as e:
        print(f"❌ Selenium测试失败: {e}")

if __name__ == '__main__':
    test_imports()
    test_selenium_driver()

运行验证:

bash 复制代码
python test_environment.py

🌐 核心模块实现(Step by Step)

1. 网站分析器 - 自动识别网站类型

在开始爬取前,我们需要先分析目标网站的特征,选择合适的爬取策略。

python 复制代码
# core/website_analyzer.py
"""
网站分析器

职责:
1. 自动识别网站类型(静态/动态/API)
2. 检测反爬虫机制
3. 生成爬取策略
4. 提取关键URL模式

设计理念:
- 一次分析,多次使用
- 将分析结果保存到配置文件
- 避免每次爬取都重新分析
"""

import requests
from bs4 import BeautifulSoup
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
from webdriver_manager.chrome import ChromeDriverManager
import time
import json
from typing import Dict, List, Optional
from urllib.parse import urljoin, urlparse
from loguru import logger

class WebsiteAnalyzer:
    """
    网站分析器
    
    使用示例:
        analyzer = WebsiteAnalyzer()
        
        # 分析医院网站
        analysis = analyzer.analyze('https://hospital.com/schedule')
        
        print(f"网站类型: {analysis['type']}")
        print(f"推荐方法: {analysis['recommended_method']}")
    """
    
    def __init__(self):
        logger.info("[网站分析器] 初始化")
    
    def analyze(self, url: str) -> Dict:
        """
        综合分析网站
        
        Args:
            url: 目标URL
            
        Returns:
            分析结果字典
        """
        logger.info(f"\n{'='*60}")
        logger.info(f"开始分析网站: {url}")
        logger.info('='*60)
        
        result = {
            'url': url,
            'type': None,
            'has_api': False,
            'needs_js': False,
            'anti_spider': [],
            'recommended_method': None,
            'key_urls': [],
            'page_structure': {}
        }
        
        # 步骤1: 检查robots.txt
        result['robots_allowed'] = self._check_robots(url)
        
        if not result['robots_allowed']:
            logger.warning(f"⚠️  robots.txt禁止爬取: {url}")
            return result
        
        # 步骤2: 静态分析(requests)
        logger.info("\n📋 步骤1: 静态分析")
        static_content = self._fetch_static_content(url)
        
        if static_content:
            result['has_schedule_data_static'] = self._check_schedule_data(
                static_content
            )
            result['page_structure']['static'] = self._analyze_structure(
                static_content
            )
        
        # 步骤3: 动态分析(Selenium)
        logger.info("\n📋 步骤2: 动态分析")
        dynamic_content = self._fetch_dynamic_content(url)
        
        if dynamic_content:
            result['has_schedule_data_dynamic'] = self._check_schedule_data(
                dynamic_content
            )
            result['page_structure']['dynamic'] = self._analyze_structure(
                dynamic_content
            )
        
        # 步骤4: 对比静态与动态内容
        result['needs_js'] = self._compare_content(
            static_content, dynamic_content
        )
        
        # 步骤5: 检测API调用
        logger.info("\n📋 步骤3: API检测")
        result['api_endpoints'] = self._detect_api_calls(url)
        result['has_api'] = len(result['api_endpoints']) > 0
        
        # 步骤6: 检测反爬虫机制
        logger.info("\n📋 步骤4: 反爬虫检测")
        result['anti_spider'] = self._detect_anti_spider(url, static_content)
        
        # 步骤7: 确定网站类型
        result['type'] = self._determine_type(result)
        
        # 步骤8: 推荐爬取方法
        result['recommended_method'] = self._recommend_method(result)
        
        # 输出分析结果
        self._print_analysis_result(result)
        
        return result
    
    def _check_robots(self, url: str) -> bool:
        """
        检查robots.txt
        
        Args:
            url: 目标URL
            
        Returns:
            True表示允许爬取
        """
        from urllib.robotparser import RobotFileParser
        
        try:
            parsed = urlparse(url)
            robots_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt"
            
            rp = RobotFileParser()
            rp.set_url(robots_url)
            rp.read()
            
            allowed = rp.can_fetch('*', url)
            
            if allowed:
                logger.info(f"✅ robots.txt: 允许爬取")
            else:
                logger.warning(f"❌ robots.txt: 禁止爬取")
            
            return allowed
            
        except Exception as e:
            logger.warning(f"⚠️  无法读取robots.txt,默认允许: {e}")
            return True
    
    def _fetch_static_content(self, url: str) -> Optional[str]:
        """
        使用requests获取静态内容
        
        Args:
            url: 目标URL
            
        Returns:
            HTML内容
        """
        try:
            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'
            }
            
            response = requests.get(url, headers=headers, timeout=10)
            response.raise_for_status()
            
            logger.info(f"✅ 静态内容获取成功 ({len(response.text)} 字符)")
            
            return response.text
            
        except Exception as e:
            logger.error(f"❌ 静态内容获取失败: {e}")
            return None
    
    def _fetch_dynamic_content(self, url: str) -> Optional[str]:
        """
        使用Selenium获取动态渲染后的内容
        
        Args:
            url: 目标URL
            
        Returns:
            渲染后的HTML内容
        """
        driver = None
        
        try:
            # 配置Chrome选项
            options = Options()
            options.add_argument('--headless')  # 无头模式
            options.add_argument('--no-sandbox')
            options.add_argument('--disable-dev-shm-usage')
            options.add_argument('--disable-gpu')
            options.add_argument(
                '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'
            )
            
            # 创建WebDriver
            service = Service(ChromeDriverManager().install())
            driver = webdriver.Chrome(service=service, options=options)
            
            # 访问页面
            driver.get(url)
            
            # 等待JS执行(3秒)
            time.sleep(3)
            
            # 获取渲染后的HTML
            content = driver.page_source
            
            logger.info(f"✅ 动态内容获取成功 ({len(content)} 字符)")
            
            return content
            
        except Exception as e:
            logger.error(f"❌ 动态内容获取失败: {e}")
            return None
            
        finally:
            if driver:
                driver.quit()
    
    def _check_schedule_data(self, html: str) -> bool:
        """
        检查HTML中是否包含排班数据
        
        通过关键词匹配判断
        
        Args:
            html: HTML内容
            
        Returns:
            True表示包含排班数据
        """
        if not html:
            return False
        
        # 关键词列表
        keywords = [
            '出诊', '排班', '门诊', '医生', '主任医师', '副主任',
            '星期一', '星期二', '周一', '周二', '上午', '下午',
            '预约', '挂号', '专家', '科室'
        ]
        
        # 统计匹配的关键词数量
        matches = sum(1 for keyword in keywords if keyword in html)
        
        # 如果匹配超过5个关键词,认为包含排班数据
        has_data = matches >= 5
        
        if has_data:
            logger.info(f"✅ 检测到排班数据 (匹配{matches}个关键词)")
        else:
            logger.info(f"❌ 未检测到排班数据 (仅匹配{matches}个关键词)")
        
        return has_data
    
    def _analyze_structure(self, html: str) -> Dict:
        """
        分析页面结构
        
        提取关键信息:
        - 表格数量
        - 列表数量
        - 链接数量
        - 主要内容区域
        
        Args:
            html: HTML内容
            
        Returns:
            结构信息字典
        """
        if not html:
            return {}
        
        soup = BeautifulSoup(html, 'lxml')
        
        structure = {
            'tables': len(soup.find_all('table')),
            'lists': len(soup.find_all(['ul', 'ol'])),
            'links': len(soup.find_all('a')),
            'forms': len(soup.find_all('form')),
            'scripts': len(soup.find_all('script')),
            'divs': len(soup.find_all('div')),
        }
        
        logger.info(f"📊 页面结构: {structure}")
        
        return structure
    
    def _compare_content(
        self,
        static_content: str,
        dynamic_content: str
    ) -> bool:
        """
        对比静态与动态内容
        
        如果动态内容明显多于静态内容,说明需要JS渲染
        
        Args:
            static_content: 静态HTML
            dynamic_content: 动态HTML
            
        Returns:
            True表示需要JS
        """
        if not static_content or not dynamic_content:
            return True
        
        # 计算长度差异
        static_len = len(static_content)
        dynamic_len = len(dynamic_content)
        
        # 如果动态内容比静态内容多50%以上,认为需要JS
        if dynamic_len > static_len * 1.5:
            logger.warning(
                f"⚠️  需要JS渲染 "
                f"(静态: {static_len} 字符, 动态: {dynamic_len} 字符)"
            )
            return True
        else:
            logger.info(
                f"✅ 不需要JS渲染 "
                f"(静态: {static_len} 字符, 动态: {dynamic_len} 字符)"
            )
            return False
    
    def _detect_api_calls(self, url: str) -> List[str]:
        """
        检测页面的API调用
        
        方法:
        1. 使用Selenium访问页面
        2. 监听网络请求
        3. 提取AJAX/Fetch请求
        
        注意:这里简化处理,实际应该使用Chrome DevTools Protocol
        
        Args:
            url: 目标URL
            
        Returns:
            API端点列表
        """
        # TODO: 实现真正的网络监听
        # 这里简化处理,返回空列表
        
        logger.info("🔍 API检测: 暂未实现(需要Chrome DevTools Protocol)")
        
        return []
    
    def _detect_anti_spider(
        self,
        url: str,
        html: str
    ) -> List[str]:
        """
        检测反爬虫机制
        
        检测项目:
        - 验证码
        - IP限制
        - User-Agent检测
        - Cookie要求
        - 请求频率限制
        
        Args:
            url: 目标URL
            html: HTML内容
            
        Returns:
            反爬虫机制列表
        """
        anti_spider_list = []
        
        if not html:
            return anti_spider_list
        
        # 检测1: 验证码
        captcha_keywords = ['captcha', '验证码', 'vcode', 'checkcode']
        if any(keyword in html.lower() for keyword in captcha_keywords):
            anti_spider_list.append('验证码')
            logger.warning("⚠️  检测到验证码")
        
        # 检测2: 登录要求
        login_keywords = ['login', 'signin', '登录', '请先登录']
        if any(keyword in html.lower() for keyword in login_keywords):
            anti_spider_list.append('需要登录')
            logger.warning("⚠️  检测到登录要求")
        
        # 检测3: JS混淆
        if '<script>' in html and 'eval' in html:
            anti_spider_list.append('JS混淆')
            logger.warning("⚠️  检测到JS混淆")
        
        if not anti_spider_list:
            logger.info("✅ 未检测到明显的反爬虫机制")
        
        return anti_spider_list
    
    def _determine_type(self, analysis: Dict) -> str:
        """
        确定网站类型
        
        Args:
            analysis: 分析结果
            
        Returns:
            网站类型:'static_html' / 'dynamic_js' / 'api' / 'hybrid'
        """
        # 如果有可用的API,优先使用API
        if analysis['has_api']:
            return 'api'
        
        # 如果需要JS渲染
        elif analysis['needs_js']:
            return 'dynamic_js'
        
        # 如果静态内容就包含数据
        elif analysis.get('has_schedule_data_static'):
            return 'static_html'
        
        # 混合型
        else:
            return 'hybrid'
    
    def _recommend_method(self, analysis: Dict) -> str:
        """
        推荐爬取方法
        
        Args:
            analysis: 分析结果
            
        Returns:
            推荐方法:'requests' / 'selenium' / 'api'
        """
        website_type = analysis['type']
        
        if website_type == 'api':
            return 'api'
        elif website_type == 'static_html':
            return 'requests'
        elif website_type == 'dynamic_js':
            return 'selenium'
        else:
            return 'requests_with_selenium_fallback'
    
    def _print_analysis_result(self, result: Dict):
        """打印分析结果"""
        logger.info(f"\n{'='*60}")
        logger.info("📊 分析结果汇总")
        logger.info('='*60)
        logger.info(f"URL: {result['url']}")
        logger.info(f"网站类型: {result['type']}")
        logger.info(f"推荐方法: {result['recommended_method']}")
        logger.info(f"需要JS: {'是' if result['needs_js'] else '否'}")
        logger.info(f"有API: {'是' if result['has_api'] else '否'}")
        logger.info(f"反爬虫机制: {', '.join(result['anti_spider']) if result['anti_spider'] else '无'}")
        logger.info('='*60 + '\n')


# 使用示例
if __name__ == '__main__':
    analyzer = WebsiteAnalyzer()
    
    # 分析医院网站
    test_url = 'https://www.pumch.cn/Category_465/Index.aspx'  # 北京协和医院
    
    result = analyzer.analyze(test_url)
    
    # 保存分析结果
    with open('analysis_result.json', 'w', encoding='utf-8') as f:
        json.dump(result, f, ensure_ascii=False, indent=2)
    
    print("\n分析结果已保存到 analysis_result.json")

代码详解:为什么这样设计?

1. 为什么要先分析网站?

传统做法(不推荐):

python 复制代码
# 直接上Selenium,不管网站是否需要
driver = webdriver.Chrome()
driver.get(url)
# 问题:慢、资源浪费

优化做法(推荐):

python 复制代码
# 先分析,选择合适的方法
analysis = analyzer.analyze(url)

if analysis['type'] == 'static_html':
    # 使用requests(快10倍)
    response = requests.get(url)
else:
    # 使用Selenium
    driver = webdriver.Chrome()
    driver.get(url)

性能对比:

json 复制代码
requests: 0.5秒/页
Selenium: 5秒/页(慢10倍)

爬取100页:
  全用Selenium: 500秒 = 8.3分钟
  智能选择: 50秒 = 0.8分钟(提升10倍!)
2. 静态 vs 动态内容对比

为什么要对比?

python 复制代码
# 场景:某医院网站
静态HTML(requests获取): 5KB
动态HTML(Selenium获取): 50KB

差异原因:
  - 静态HTML只有框架
  - 排班数据通过JS异步加载
  - 需要等待JS执行

结论:
  需要使用Selenium爬取

对比逻辑:

python 复制代码
if dynamic_len > static_len * 1.5:
    # 动态内容多50%以上
    needs_js = True

为什么是1.5倍而不是2倍?

  • 考虑到JS可能加载广告、推荐等非核心内容
  • 1.5倍是经验值,平衡了准确性和灵敏度
3. 反爬虫检测

检测原理:

python 复制代码
# 验证码检测
captcha_keywords = ['captcha', '验证码', 'vcode']

# 为什么要检测验证码?
# 1. 提前发现,避免爬取时才发现
# 2. 可以提前准备验证码识别方案
# 3. 或者选择放弃该医院(成本过高)

实际案例:

python 复制代码
# 某医院网站HTML片段
<div class="login-form">
  <input type="text" name="username">
  <input type="password" name="password">
  <img src="/captcha" class="captcha-img">
  <input type="text" name="vcode" placeholder="验证码">
</div>

# 检测结果
anti_spider = ['验证码', '需要登录']

# 决策:跳过该医院(成本太高)

🕷️ 爬虫引擎实现(Crawler Engine)

1. Requests爬虫 - 高性能静态页面采集

python 复制代码
# core/crawlers/requests_crawler.py
"""
Requests爬虫

适用场景:
- 静态HTML页面
- 不需要JS渲染
- 追求高性能

优势:
- 速度快(0.5秒/页)
- 资源占用低
- 易于调试

使用示例:
    crawler = RequestsCrawler()
    html = crawler.fetch('https://hospital.com/schedule')
"""

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
import time
from typing import Dict, Optional
from fake_useragent import UserAgent
from loguru import logger

class RequestsCrawler:
    """
    Requests爬虫
    
    特性:
    1. 自动重试
    2. 连接池复用
    3. User-Agent轮换
    4. Cookie管理
    5. 代理支持
    """
    
    def __init__(self, use_proxy: bool = False):
        """
        初始化爬虫
        
        Args:
            use_proxy: 是否使用代理
        """
        # 创建Session(复用连接)
        self.session = requests.Session()
        
        # 配置重试策略
        retry_strategy = Retry(
            total=3,  # 总共重试3次
            backoff_factor=1,  # 指数退避
            status_forcelist=[429, 500, 502, 503, 504],
            allowed_methods=["GET", "POST"]
        )
        
        adapter = HTTPAdapter(
            max_retries=retry_strategy,
            pool_connections=10,  # 连接池大小
            pool_maxsize=20
        )
        
        self.session.mount("http://", adapter)
        self.session.mount("https://", adapter)
        
        # User-Agent生成器
        self.ua = UserAgent()
        
        # 代理设置
        self.use_proxy = use_proxy
        self.proxy_pool = []
        
        if use_proxy:
            self._init_proxy_pool()
        
        # 统计信息
        self.stats = {
            'total_requests': 0,
            'success_requests': 0,
            'failed_requests': 0
        }
        
        logger.info(f"[Requests爬虫] 初始化完成 | 代理: {'开启' if use_proxy else '关闭'}")
    
    def fetch(
        self,
        url: str,
        method: str = 'GET',
        params: Optional[Dict] = None,
        data: Optional[Dict] = None,
        headers: Optional[Dict] = None,
        timeout: int = 10,
        allow_redirects: bool = True
    ) -> Optional[str]:
        """
        发送HTTP请求
        
        Args:
            url: 目标URL
            method: 请求方法(GET/POST)
            params: URL参数
            data: POST数据
            headers: 自定义请求头
            timeout: 超时时间(秒)
            allow_redirects: 是否允许重定向
            
        Returns:
            响应HTML,失败返回None
        """
        self.stats['total_requests'] += 1
        
        # 准备请求头
        request_headers = self._build_headers(headers)
        
        # 准备代理
        proxies = self._get_proxy() if self.use_proxy else None
        
        logger.debug(f"[请求] {method} {url}")
        
        try:
            # 发送请求
            response = self.session.request(
                method=method,
                url=url,
                params=params,
                data=data,
                headers=request_headers,
                timeout=timeout,
                allow_redirects=allow_redirects,
                proxies=proxies
            )
            
            # 检查状态码
            response.raise_for_status()
            
            # 自动检测编码
            response.encoding = response.apparent_encoding
            
            self.stats['success_requests'] += 1
            
            logger.debug(
                f"[成功] 状态码: {response.status_code}, "
                f"长度: {len(response.text)} 字符"
            )
            
            return response.text
            
        except requests.exceptions.HTTPError as e:
            logger.error(f"[HTTP错误] {e.response.status_code}: {url}")
            self.stats['failed_requests'] += 1
            return None
            
        except requests.exceptions.Timeout:
            logger.error(f"[超时] {url}")
            self.stats['failed_requests'] += 1
            return None
            
        except requests.exceptions.ConnectionError as e:
            logger.error(f"[连接错误] {e}")
            self.stats['failed_requests'] += 1
            return None
            
        except Exception as e:
            logger.error(f"[未知错误] {type(e).__name__}: {e}")
            self.stats['failed_requests'] += 1
            return None
    
    def _build_headers(self, custom_headers: Optional[Dict] = None) -> Dict:
        """
        构建请求头
        
        特性:
        1. 随机User-Agent(绕过UA检测)
        2. 模拟真实浏览器
        3. 支持自定义覆盖
        
        Args:
            custom_headers: 自定义请求头
            
        Returns:
            完整的请求头字典
        """
        headers = {
            'User-Agent': self.ua.random,
            '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, br',
            'Connection': 'keep-alive',
            'Upgrade-Insecure-Requests': '1',
            'Cache-Control': 'max-age=0',
            # Referer会在实际请求时动态设置
        }
        
        # 合并自定义请求头
        if custom_headers:
            headers.update(custom_headers)
        
        return headers
    
    def _init_proxy_pool(self):
        """
        初始化代理池
        
        注意:这里简化处理,实际应该从代理服务商API获取
        """
        # TODO: 从代理服务商获取代理列表
        # 这里使用示例代理
        self.proxy_pool = [
            {'http': 'http://proxy1.com:8080', 'https': 'http://proxy1.com:8080'},
            {'http': 'http://proxy2.com:8080', 'https': 'http://proxy2.com:8080'},
        ]
        
        logger.info(f"[代理池] 已加载 {len(self.proxy_pool)} 个代理")
    
    def _get_proxy(self) -> Optional[Dict]:
        """
        从代理池获取代理
        
        Returns:
            代理字典
        """
        if not self.proxy_pool:
            return None
        
        # 简单的轮询策略
        # 实际应该检测代理可用性,剔除失效代理
        import random
        return random.choice(self.proxy_pool)
    
    def get_stats(self) -> Dict:
        """获取统计信息"""
        stats = self.stats.copy()
        
        if stats['total_requests'] > 0:
            stats['success_rate'] = (
                stats['success_requests'] / stats['total_requests']
            )
        else:
            stats['success_rate'] = 0
        
        return stats
    
    def close(self):
        """关闭Session"""
        self.session.close()


 

### 2. Selenium爬虫 - 动态页面终极方案

```python
# core/crawlers/selenium_crawler.py
"""
Selenium爬虫

适用场景:
- 需要JS渲染的动态页面
- 需要模拟用户交互(点击、滚动等)
- 需要处理AJAX加载

劣势:
- 速度慢(5秒/页)
- 资源占用大
- 需要安装ChromeDriver

使用示例:
    crawler = SeleniumCrawler(headless=True)
    html = crawler.fetch('https://hospital.com/schedule')
    crawler.close()
"""

from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from selenium.common.exceptions import TimeoutException, WebDriverException
from webdriver_manager.chrome import ChromeDriverManager
import undetected_chromedriver as uc  # 反检测
import time
from typing import Optional, List
from loguru import logger

class SeleniumCrawler:
    """
    Selenium爬虫
    
    特性:
    1. 自动处理JS渲染
    2. 智能等待元素加载
    3. 反爬虫检测
    4. 截图功能
    5. Cookie管理
    """
    
    def __init__(
        self,
        headless: bool = True,
        use_undetected: bool = True,
        window_size: str = "1920,1080",
        page_load_timeout: int = 30
    ):
        """
        初始化Selenium爬虫
        
        Args:
            headless: 是否无头模式(不显示浏览器窗口)
            use_undetected: 是否使用反检测ChromeDriver
            window_size: 窗口大小
            page_load_timeout: 页面加载超时时间
        """
        self.headless = headless
        self.use_undetected = use_undetected
        
        # 配置Chrome选项
        self.options = self._configure_options(window_size)
        
        # 创建WebDriver
        self.driver = self._create_driver()
        
        # 设置超时
        self.driver.set_page_load_timeout(page_load_timeout)
        self.driver.implicitly_wait(10)
        
        # 统计信息
        self.stats = {
            'total_requests': 0,
            'success_requests': 0,
            'failed_requests': 0
        }
        
        logger.info(
            f"[Selenium爬虫] 初始化完成 | "
            f"无头模式: {headless} | "
            f"反检测: {use_undetected}"
        )
    
    def _configure_options(self, window_size: str) -> Options:
        """
        配置Chrome选项
        
        关键配置:
        1. 禁用图片加载(提速)
        2. 禁用CSS(提速)
        3. 禁用GPU(稳定性)
        4. 反检测配置
        
        Args:
            window_size: 窗口大小 "宽,高"
            
        Returns:
            配置好的Options对象
        """
        options = Options()
        
        # 基础配置
        if self.headless:
            options.add_argument('--headless')
        
        options.add_argument(f'--window-size={window_size}')
        options.add_argument('--no-sandbox')
        options.add_argument('--disable-dev-shm-usage')
        options.add_argument('--disable-gpu')
        
        # 性能优化
        # 禁用图片(加载速度提升50%)
        prefs = {
            'profile.managed_default_content_settings.images': 2,  # 禁用图片
            'profile.default_content_setting_values.notifications': 2,  # 禁用通知
        }
        options.add_experimental_option('prefs', prefs)
        
        # 反检测配置
        options.add_argument('--disable-blink-features=AutomationControlled')
        options.add_experimental_option('excludeSwitches', ['enable-automation'])
        options.add_experimental_option('useAutomationExtension', False)
        
        # User-Agent(模拟真实浏览器)
        options.add_argument(
            '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'
        )
        
        return options
    
    def _create_driver(self) -> webdriver.Chrome:
        """
        创建WebDriver
        
        Returns:
            WebDriver实例
        """
        try:
            if self.use_undetected:
                # 使用undetected_chromedriver(绕过检测)
                driver = uc.Chrome(
                    options=self.options,
                    version_main=None  # 自动检测Chrome版本
                )
                
                logger.info("[WebDriver] 使用undetected_chromedriver")
            else:
                # 使用普通ChromeDriver
                service = Service(ChromeDriverManager().install())
                driver = webdriver.Chrome(
                    service=service,
                    options=self.options
                )
                
                logger.info("[WebDriver] 使用标准ChromeDriver")
            
            # 执行反检测脚本
            driver.execute_cdp_cmd('Page.addScriptToEvaluateOnNewDocument', {
                'source': '''
                    // 覆盖navigator.webdriver属性
                    Object.defineProperty(navigator, 'webdriver', {
                        get: () => undefined
                    });
                    
                    // 覆盖Chrome对象
                    window.chrome = {
                        runtime: {}
                    };
                    
                    // 覆盖Permissions
                    const originalQuery = window.navigator.permissions.query;
                    window.navigator.permissions.query = (parameters) => (
                        parameters.name === 'notifications' ?
                        Promise.resolve({ state: Notification.permission }) :
                        originalQuery(parameters)
                    );
                '''
            })
            
            return driver
            
        except Exception as e:
            logger.error(f"[WebDriver创建失败] {e}")
            raise
    
    def fetch(
        self,
        url: str,
        wait_time: int = 3,
        wait_for_element: Optional[str] = None,
        scroll_to_bottom: bool = False
    ) -> Optional[str]:
        """
        获取页面HTML
        
        Args:
            url: 目标URL
            wait_time: 等待时间(秒)
            wait_for_element: 等待特定元素出现(CSS选择器)
            scroll_to_bottom: 是否滚动到底部(加载懒加载内容)
            
        Returns:
            渲染后的HTML,失败返回None
        """
        self.stats['total_requests'] += 1
        
        logger.debug(f"[请求] {url}")
        
        try:
            # 访问页面
            self.driver.get(url)
            
            # 策略1: 等待固定时间(简单但不精确)
            if wait_time > 0:
                time.sleep(wait_time)
            
            # 策略2: 等待特定元素出现(更精确)
            if wait_for_element:
                try:
                    WebDriverWait(self.driver, 10).until(
                        EC.presence_of_element_located((By.CSS_SELECTOR, wait_for_element))
                    )
                    logger.debug(f"✅ 元素已加载: {wait_for_element}")
                except TimeoutException:
                    logger.warning(f"⚠️  等待元素超时: {wait_for_element}")
            
            # 策略3: 滚动到底部(触发懒加载)
            if scroll_to_bottom:
                self._scroll_to_bottom()
            
            # 获取页面源代码
            html = self.driver.page_source
            
            self.stats['success_requests'] += 1
            
            logger.debug(f"[成功] 长度: {len(html)} 字符")
            
            return html
            
        except TimeoutException:
            logger.error(f"[页面加载超时] {url}")
            self.stats['failed_requests'] += 1
            return None
            
        except WebDriverException as e:
            logger.error(f"[WebDriver错误] {e}")
            self.stats['failed_requests'] += 1
            return None
            
        except Exception as e:
            logger.error(f"[未知错误] {type(e).__name__}: {e}")
            self.stats['failed_requests'] += 1
            return None
    
    def _scroll_to_bottom(self, pause_time: float = 1.0):
        """
        滚动到页面底部
        
        用途:
        - 触发懒加载(lazy load)
        - 加载无限滚动内容
        
        Args:
            pause_time: 每次滚动后的暂停时间
        """
        logger.debug("[滚动] 开始滚动到底部")
        
        # 获取当前页面高度
        last_height = self.driver.execute_script(
            "return document.body.scrollHeight"
        )
        
        while True:
            # 滚动到底部
            self.driver.execute_script(
                "window.scrollTo(0, document.body.scrollHeight);"
            )
            
            # 等待新内容加载
            time.sleep(pause_time)
            
            # 计算新的页面高度
            new_height = self.driver.execute_script(
                "return document.body.scrollHeight"
            )
            
            # 如果高度没有变化,说明已经到底了
            if new_height == last_height:
                break
            
            last_height = new_height
        
        logger.debug("[滚动] 已滚动到底部")
    
    def click_element(
        self,
        selector: str,
        by: str = By.CSS_SELECTOR,
        wait_time: int = 10
    ) -> bool:
        """
        点击元素
        
        Args:
            selector: 元素选择器
            by: 选择器类型(By.CSS_SELECTOR / By.XPATH / By.ID等)
            wait_time: 等待时间
            
        Returns:
            成功返回True
        """
        try:
            # 等待元素可点击
            element = WebDriverWait(self.driver, wait_time).until(
                EC.element_to_be_clickable((by, selector))
            )
            
            # 点击
            element.click()
            
            logger.debug(f"✅ 点击成功: {selector}")
            
            return True
            
        except TimeoutException:
            logger.error(f"[点击失败] 元素不可点击: {selector}")
            return False
        
        except Exception as e:
            logger.error(f"[点击错误] {e}")
            return False
    
    def input_text(
        self,
        selector: str,
        text: str,
        by: str = By.CSS_SELECTOR,
        clear_first: bool = True
    ) -> bool:
        """
        输入文本
        
        Args:
            selector: 元素选择器
            text: 要输入的文本
            by: 选择器类型
            clear_first: 是否先清空输入框
            
        Returns:
            成功返回True
        """
        try:
            # 找到输入框
            element = self.driver.find_element(by, selector)
            
            # 清空
            if clear_first:
                element.clear()
            
            # 输入
            element.send_keys(text)
            
            logger.debug(f"✅ 输入成功: {selector} = {text}")
            
            return True
            
        except Exception as e:
            logger.error(f"[输入错误] {e}")
            return False
    
    def screenshot(self, filename: str = 'screenshot.png') -> bool:
        """
        截图
        
        Args:
            filename: 保存文件名
            
        Returns:
            成功返回True
        """
        try:
            self.driver.save_screenshot(filename)
            logger.info(f"📸 截图已保存: {filename}")
            return True
        except Exception as e:
            logger.error(f"[截图失败] {e}")
            return False
    
    def get_cookies(self) -> List[dict]:
        """获取所有Cookie"""
        return self.driver.get_cookies()
    
    def add_cookie(self, cookie: dict):
        """添加Cookie"""
        self.driver.add_cookie(cookie)
    
    def delete_all_cookies(self):
        """删除所有Cookie"""
        self.driver.delete_all_cookies()
    
    def get_stats(self) -> dict:
        """获取统计信息"""
        stats = self.stats.copy()
        
        if stats['total_requests'] > 0:
            stats['success_rate'] = (
                stats['success_requests'] / stats['total_requests']
            )
        else:
            stats['success_rate'] = 0
        
        return stats
    
    def close(self):
        """关闭浏览器"""
        if self.driver:
            self.driver.quit()
            logger.info("[Selenium爬虫] 已关闭")


 

### 3. 爬虫引擎统一接口

```python
# core/crawler_engine.py
"""
爬虫引擎

职责:
1. 统一不同爬虫的接口
2. 自动选择合适的爬虫
3. 失败时自动降级
4. 管理爬虫生命周期
"""

from typing import Optional, Dict
from .crawlers.requests_crawler import RequestsCrawler
from .crawlers.selenium_crawler import SeleniumCrawler
from loguru import logger

class CrawlerEngine:
    """
    爬虫引擎
    
    使用示例:
        engine = CrawlerEngine()
        
        # 自动选择方法
        html = engine.fetch('https://hospital.com/schedule')
        
        # 指定方法
        html = engine.fetch('https://hospital.com/schedule', method='selenium')
    """
    
    def __init__(self):
        """初始化爬虫引擎"""
        # 创建爬虫实例
        self.requests_crawler = RequestsCrawler()
        self.selenium_crawler = None  # 延迟创建(节省资源)
        
        logger.info("[爬虫引擎] 初始化完成")
    
    def fetch(
        self,
        url: str,
        method: str = 'auto',
        **kwargs
    ) -> Optional[str]:
        """
        获取页面HTML
        
        Args:
            url: 目标URL
            method: 爬取方法
                - 'auto': 自动选择(先requests,失败则selenium)
                - 'requests': 使用requests
                - 'selenium': 使用selenium
            **kwargs: 传递给具体爬虫的参数
            
        Returns:
            HTML内容
        """
        if method == 'auto':
            # 自动模式:先requests,失败则selenium
            return self._fetch_auto(url, **kwargs)
        
        elif method == 'requests':
            # 强制使用requests
            return self.requests_crawler.fetch(url, **kwargs)
        
        elif method == 'selenium':
            # 强制使用selenium
            return self._fetch_selenium(url, **kwargs)
        
        else:
            raise ValueError(f"不支持的方法: {method}")
    
    def _fetch_auto(self, url: str, **kwargs) -> Optional[str]:
        """
        自动模式:智能选择爬虫
        
        策略:
        1. 先尝试requests(快)
        2. 如果失败或内容不完整,使用selenium(慢但可靠)
        
        Args:
            url: 目标URL
            **kwargs: 参数
            
        Returns:
            HTML内容
        """
        logger.info(f"[自动模式] 开始爬取: {url}")
        
        # 尝试requests
        logger.info("  步骤1: 尝试requests")
        html = self.requests_crawler.fetch(url, **kwargs)
        
        if html and self._is_complete(html):
            logger.info("  ✅ requests成功")
            return html
        
        # requests失败,降级为selenium
        logger.warning("  ⚠️  requests失败或内容不完整,降级为selenium")
        logger.info("  步骤2: 使用selenium")
        
        return self._fetch_selenium(url, **kwargs)
    
    def _fetch_selenium(self, url: str, **kwargs) -> Optional[str]:
        """
        使用Selenium爬取
        
        延迟创建:只有在需要时才创建Selenium实例
        
        Args:
            url: 目标URL
            **kwargs: 参数
            
        Returns:
            HTML内容
        """
        # 延迟创建(节省资源)
        if self.selenium_crawler is None:
            logger.info("  [创建Selenium实例]")
            self.selenium_crawler = SeleniumCrawler()
        
        return self.selenium_crawler.fetch(url, **kwargs)
    
    def _is_complete(self, html: str) -> bool:
        """
        判断HTML是否完整
        
        检查标准:
        1. 长度 > 1000字符(太短可能是错误页面)
        2. 包含排班关键词
        
        Args:
            html: HTML内容
            
        Returns:
            True表示完整
        """
        if not html:
            return False
        
        # 检查长度
        if len(html) < 1000:
            return False
        
        # 检查关键词
        keywords = ['医生', '出诊', '排班', '门诊']
        keyword_count = sum(1 for k in keywords if k in html)
        
        # 至少包含2个关键词
        return keyword_count >= 2
    
    def get_stats(self) -> Dict:
        """获取所有爬虫的统计信息"""
        stats = {
            'requests': self.requests_crawler.get_stats()
        }
        
        if self.selenium_crawler:
            stats['selenium'] = self.selenium_crawler.get_stats()
        
        return stats
    
    def close(self):
        """关闭所有爬虫"""
        self.requests_crawler.close()
        
        if self.selenium_crawler:
            self.selenium_crawler.close()
        
        logger.info("[爬虫引擎] 已关闭")

🛡️ 反爬虫对抗技术(Anti-Spider Techniques)

常见反爬虫机制及应对方法

反爬虫机制 检测原理 应对方法 成功率
User-Agent检测 检查UA是否为爬虫 使用fake-useragent轮换 95%
IP限制 同一IP请求频率过高 使用代理池 90%
Cookie检测 检查Cookie是否存在 保持Session/Cookie 98%
验证码 人机验证 OCR识别或打码平台 70%
JS混淆 加密关键数据 使用Selenium执行JS 85%
请求头检测 检查Referer等字段 模拟完整的请求头 92%
行为检测 鼠标移动、页面停留时间 使用Selenium模拟人类行为 80%
蜜罐陷阱 隐藏链接诱导爬虫 只爬取可见元素 99%

1. 验证码识别

python 复制代码
# core/anti_spider/captcha_solver.py
"""
验证码识别

支持类型:
1. 数字验证码(4-6位)
2. 字母验证码
3. 数字+字母混合
4. 简单的算术验证码(1+1=?)

使用示例:
    solver = CaptchaSolver()
    
    # 识别图片验证码
    code = solver.recognize('captcha.png')
    print(f"识别结果: {code}")
"""

import ddddocr
from PIL import Image
import requests
from io import BytesIO
from loguru import logger

class CaptchaSolver:
    """
    验证码识别器
    
    使用ddddocr库(深度学习OCR)
    
    优点:
    - 无需训练模型
    - 识别率高(80%+)
    - 支持多种验证码
    
    缺点:
    - 不支持滑块验证码
    - 不支持点选验证码
    """
    
    def __init__(self):
        """初始化OCR引擎"""
        try:
            # 初始化OCR
            self.ocr = ddddocr.DdddOcr(show_ad=False)
            
            logger.info("[验证码识别] 初始化成功")
            
        except Exception as e:
            logger.error(f"[验证码识别] 初始化失败: {e}")
            self.ocr = None
    
    def recognize(self, image_source) -> str:
        """
        识别验证码
        
        Args:
            image_source: 图片来源
                - 文件路径: 'captcha.png'
                - URL: 'https://example.com/captcha'
                - PIL Image对象
                - bytes数据
                
        Returns:
            识别结果字符串
        """
        if not self.ocr:
            logger.error("[验证码识别] OCR引擎未初始化")
            return ''
        
        try:
            # 加载图片
            image_bytes = self._load_image(image_source)
            
            if not image_bytes:
                return ''
            
            # 识别
            result = self.ocr.classification(image_bytes)
            
            logger.info(f"[验证码识别] 结果: {result}")
            
            return result
            
        except Exception as e:
            logger.error(f"[验证码识别失败] {e}")
            return ''
    
    def _load_image(self, image_source) -> bytes:
        """
        加载图片
        
        Args:
            image_source: 图片来源
            
        Returns:
            图片的bytes数据
        """
        # 情况1: 文件路径
        if isinstance(image_source, str):
            # 判断是URL还是本地文件
            if image_source.startswith('http'):
                # URL
                response = requests.get(image_source, timeout=10)
                return response.content
            else:
                # 本地文件
                with open(image_source, 'rb') as f:
                    return f.read()
        
        # 情况2: PIL Image对象
        elif isinstance(image_source, Image.Image):
            # 转换为bytes
            buffer = BytesIO()
            image_source.save(buffer, format='PNG')
            return buffer.getvalue()
        
        # 情况3: bytes数据
        elif isinstance(image_source, bytes):
            return image_source
        
        else:
            logger.error(f"[不支持的图片格式] {type(image_source)}")
            return b''

2. 代理池管理

python 复制代码
# core/anti_spider/proxy_pool.py
"""
代理池管理

功能:
1. 从代理服务商获取代理
2. 验证代理可用性
3. 自动剔除失效代理
4. 智能分配代理

使用示例:
    pool = ProxyPool()
    
    # 获取可用代理
    proxy = pool.get_proxy()
    
    # 使用代理发送请求
    response = requests.get(url, proxies=proxy)
    
    # 标记代理可用/失效
    pool.mark_success(proxy)
    pool.mark_failure(proxy)
"""

import requests
import time
from typing import List, Dict, Optional
from collections import defaultdict
from loguru import logger

class ProxyPool:
    """
    代理池管理器
    
    特性:
    1. 自动获取代理
    2. 健康检查
    3. 负载均衡
    4. 失败重试
    """
    
    def __init__(
        self,
        api_url: Optional[str] = None,
        max_size: int = 100,
        check_interval: int = 300
    ):
        """
        初始化代理池
        
        Args:
            api_url: 代理API地址(如果None,使用免费代理)
            max_size: 代理池最大容量
            check_interval: 健康检查间隔(秒)
        """
        self.api_url = api_url
        self.max_size = max_size
        self.check_interval = check_interval
        
        # 代理列表
        self.proxies: List[Dict] = []
        
        # 代理统计(成功次数、失败次数)
        self.proxy_stats = defaultdict(lambda: {'success': 0, 'failure': 0})
        
        # 上次检查时间
        self.last_check_time = 0
        
        # 初始化代理池
        self._refresh_proxies()
        
        logger.info(f"[代理池] 初始化完成 | 代理数量: {len(self.proxies)}")
    
    def get_proxy(self) -> Optional[Dict]:
        """
        获取一个可用代理
        
        策略:
        1. 定期刷新代理池
        2. 优先选择成功率高的代理
        3. 如果代理池为空,立即刷新
        
        Returns:
            代理字典 {'http': 'http://...', 'https': 'http://...'}
        """
        # 检查是否需要刷新
        if time.time() - self.last_check_time > self.check_interval:
            self._refresh_proxies()
        
        # 如果没有可用代理,立即刷新
        if not self.proxies:
            logger.warning("[代理池] 无可用代理,立即刷新")
            self._refresh_proxies()
        
        # 仍然没有代理,返回None
        if not self.proxies:
            logger.error("[代理池] 刷新失败,无可用代理")
            return None
        
        # 选择成功率最高的代理
        best_proxy = max(
            self.proxies,
            key=lambda p: self._calculate_score(p)
        )
        
        return best_proxy
    
    def mark_success(self, proxy: Dict):
        """
        标记代理成功
        
        Args:
            proxy: 代理字典
        """
        proxy_key = self._get_proxy_key(proxy)
        self.proxy_stats[proxy_key]['success'] += 1
    
    def mark_failure(self, proxy: Dict):
        """
        标记代理失败
        
        Args:
            proxy: 代理字典
        """
        proxy_key = self._get_proxy_key(proxy)
        self.proxy_stats[proxy_key]['failure'] += 1
        
        # 如果失败次数过多,移除代理
        if self.proxy_stats[proxy_key]['failure'] > 5:
            self._remove_proxy(proxy)
            logger.warning(f"[代理池] 移除失效代理: {proxy_key}")
    
    def _refresh_proxies(self):
        """
        刷新代理池
        
        从API获取新代理,验证后添加到池中
        """
        logger.info("[代理池] 开始刷新")
        
        # 获取代理列表
        new_proxies = self._fetch_proxies()
        
        # 验证代理
        valid_proxies = []
        
        for proxy in new_proxies:
            if self._validate_proxy(proxy):
                valid_proxies.append(proxy)
            
            # 达到容量限制
            if len(valid_proxies) >= self.max_size:
                break
        
        self.proxies = valid_proxies
        self.last_check_time = time.time()
        
        logger.info(f"[代理池] 刷新完成 | 有效代理: {len(valid_proxies)}")
    
    def _fetch_proxies(self) -> List[Dict]:
        """
        从API获取代理列表
        
        Returns:
            代理列表
        """
        if self.api_url:
            # 从付费API获取
            try:
                response = requests.get(self.api_url, timeout=10)
                data = response.json()
                
                # 假设API返回格式: [{"ip": "1.2.3.4", "port": 8080}, ...]
                proxies = []
                
                for item in data:
                    proxy = {
                        'http': f"http://{item['ip']}:{item['port']}",
                        'https': f"http://{item['ip']}:{item['port']}"
                    }
                    proxies.append(proxy)
                
                return proxies
                
            except Exception as e:
                logger.error(f"[获取代理失败] {e}")
                return []
        else:
            # 使用免费代理(仅供测试,不推荐生产环境)
            logger.warning("[代理池] 使用免费代理(仅供测试)")
            
            # TODO: 从免费代理网站爬取
            # 这里返回示例代理
            return []
    
    def _validate_proxy(self, proxy: Dict) -> bool:
        """
        验证代理是否可用
        
        方法:使用代理访问测试URL
        
        Args:
            proxy: 代理字典
            
        Returns:
            True表示可用
        """
        test_url = 'https://httpbin.org/ip'
        
        try:
            response = requests.get(
                test_url,
                proxies=proxy,
                timeout=5
            )
            
            if response.status_code == 200:
                logger.debug(f"✅ 代理有效: {self._get_proxy_key(proxy)}")
                return True
            else:
                return False
                
        except:
            return False
    
    def _calculate_score(self, proxy: Dict) -> float:
        """
        计算代理得分
        
        得分 = 成功率
        
        Args:
            proxy: 代理字典
            
        Returns:
            得分(0-1)
        """
        proxy_key = self._get_proxy_key(proxy)
        stats = self.proxy_stats[proxy_key]
        
        total = stats['success'] + stats['failure']
        
        if total == 0:
            return 0.5  # 新代理给予中等分数
        
        return stats['success'] / total
    
    def _get_proxy_key(self, proxy: Dict) -> str:
        """
        获取代理的唯一标识
        
        Args:
            proxy: 代理字典
            
        Returns:
            唯一标识字符串
        """
        return proxy.get('http', '')
    
    def _remove_proxy(self, proxy: Dict):
        """
        移除代理
        
        Args:
            proxy: 代理字典
        """
        if proxy in self.proxies:
            self.proxies.remove(proxy)

🧩 解析层实现(Parsers)

1. 静态HTML解析器

针对HTML结构规整的医院网站,使用XPath或CSS选择器快速提取数据。

python 复制代码
# core/parsers/html_parser.py
"""
HTML解析器

职责:
1. 提取排班数据
2. 提取医生信息
3. 处理HTML表格
"""

from typing import List, Dict, Optional
from bs4 import BeautifulSoup
from lxml import etree
import re
from loguru import logger

class HospitalHTMLParser:
    """
    通用HTML解析器
    
    使用示例:
        parser = HospitalHTMLParser()
        schedules = parser.parse_schedule(html)
    """
    
    def parse_schedule(self, html: str, rules: Dict) -> List[Dict]:
        """
        解析排班表
        
        Args:
            html: HTML内容
            rules: 解析规则字典(XPath或CSS)
            
        Returns:
            排班数据列表
        """
        if not html:
            return []
        
        tree = etree.HTML(html)
        schedules = []
        
        # 1. 定位排班表格/列表
        # XPath示例: //table[@class="schedule-table"]//tr
        rows = tree.xpath(rules.get('rows_xpath', '//tr'))
        
        if not rows:
            logger.warning("[解析] 未找到排班行")
            return []
        
        logger.info(f"[解析] 找到 {len(rows)} 行数据")
        
        # 2. 遍历行提取数据
        for row in rows:
            try:
                item = self._extract_row_data(row, rules['fields'])
                
                # 验证数据完整性
                if self._validate_schedule(item):
                    schedules.append(item)
                    
            except Exception as e:
                logger.error(f"[解析错误] {e}")
                continue
        
        return schedules
    
    def _extract_row_data(self, element, field_rules: Dict) -> Dict:
        """
        提取单行数据
        
        Args:
            element: lxml元素对象
            field_rules: 字段解析规则
            
        Returns:
            提取的数据字典
        """
        data = {}
        
        for field, xpath in field_rules.items():
            # 执行XPath查询
            result = element.xpath(xpath)
            
            # 提取文本
            if result:
                # 列表转字符串
                text = ''.join([str(r).strip() for r in result if r])
                data[field] = text
            else:
                data[field] = None
        
        return data
    
    def _validate_schedule(self, item: Dict) -> bool:
        """
        验证排班数据有效性
        
        Args:
            item: 排班数据
            
        Returns:
            True表示有效
        """
        # 必须包含医生姓名和出诊时间
        if not item.get('doctor_name'):
            return False
        
        if not item.get('time_range') and not item.get('period'):
            return False
            
        return True

2. 动态内容解析策略

对于Selenium渲染后的页面,解析逻辑与静态HTML类似,但可能需要处理Shadow DOM或Iframe。

python 复制代码
# core/parsers/dynamic_parser.py
# (略:逻辑与HTMLParser类似,主要区别在于输入源是Selenium的page_source)

🧹 数据清洗与标准化(Data Cleaning)

医疗数据的非结构化程度很高,必须进行严格的清洗。

python 复制代码
# core/processors/cleaner.py
"""
数据清洗器

职责:
1. 医生姓名标准化
2. 职称归一化
3. 日期格式转换
4. 出诊时段规范化
"""

import re
from datetime import datetime, timedelta
from typing import Dict, Optional
from loguru import logger

class DataCleaner:
    """
    数据清洗器
    """
    
    def clean_schedule(self, item: Dict) -> Dict:
        """
        清洗排班记录
        
        Args:
            item: 原始数据
            
        Returns:
            清洗后的数据
        """
        cleaned = item.copy()
        
        # 1. 医生姓名:去除职称、空格
        if 'doctor_name' in cleaned:
            cleaned['doctor_name'] = self._clean_name(cleaned['doctor_name'])
            
        # 2. 职称:统一映射
        if 'title' in cleaned:
            cleaned['title'] = self._normalize_title(cleaned['title'])
            
        # 3. 日期:转为YYYY-MM-DD
        if 'date' in cleaned:
            cleaned['date'] = self._parse_date(cleaned['date'])
            
        # 4. 时段:上午/下午/夜间
        if 'period' in cleaned:
            cleaned['period'] = self._normalize_period(cleaned['period'])
            
        # 5. 费用:提取数字
        if 'price' in cleaned:
            cleaned['price'] = self._extract_price(cleaned['price'])
            
        return cleaned
    
    def _clean_name(self, text: str) -> str:
        """清洗医生姓名"""
        if not text:
            return ""
            
        # 去除常见称呼
        text = re.sub(r'(医生|主任|教授|大夫|医师)', '', text)
        
        # 去除特殊字符
        text = re.sub(r'[^\u4e00-\u9fa5a-zA-Z]', '', text)
        
        return text
    
    def _normalize_title(self, text: str) -> str:
        """职称归一化"""
        if not text:
            return "未知"
            
        text = text.strip()
        
        # 映射表
        mapping = {
            '主任医师': ['正高', '教授', '主任医师'],
            '副主任医师': ['副高', '副教授', '副主任医师'],
            '主治医师': ['主治', '讲师', '主治医师'],
            '住院医师': ['住院', '助教', '住院医师']
        }
        
        for std_title, keywords in mapping.items():
            if any(k in text for k in keywords):
                return std_title
                
        return text  # 无法映射则保留原值
    
    def _parse_date(self, text: str) -> Optional[str]:
        """
        解析日期
        
        支持:
        - 2025-02-03
        - 2025年2月3日
        - 2月3日
        - 明天
        - 周三
        """
        if not text:
            return None
            
        try:
            # 简单日期转换
            # TODO: 实现复杂的相对日期解析("明天", "下周一")
            # 这里简化处理标准格式
            text = text.replace('年', '-').replace('月', '-').replace('日', '')
            dt = datetime.strptime(text, '%Y-%m-%d')
            return dt.strftime('%Y-%m-%d')
        except:
            return text  # 解析失败保留原值
    
    def _normalize_period(self, text: str) -> str:
        """时段标准化"""
        if not text:
            return "全天"
            
        if "上" in text or "早" in text:
            return "上午"
        elif "下" in text:
            return "下午"
        elif "晚" in text or "夜" in text:
            return "夜间"
        else:
            return "全天"
            
    def _extract_price(self, text: str) -> float:
        """提取价格数字"""
        if not text:
            return 0.0
            
        # 提取数字
        match = re.search(r'(\d+(\.\d+)?)', str(text))
        if match:
            return float(match.group(1))
        return 0.0

⚖️ 合规性检查器(Compliance Checker)

在数据入库前,必须进行最后一道合规性检查,确保不存储敏感隐私信息。

python 复制代码
# core/processors/compliance.py
"""
合规性检查器

职责:
1. 隐私信息检测(手机号、身份证号)
2. 敏感词过滤
3. 数据脱敏
"""

import re
from typing import Dict, Any
from loguru import logger

class ComplianceChecker:
    """
    合规性检查器
    """
    
    def check(self, item: Dict) -> Dict:
        """
        检查并脱敏数据
        
        Args:
            item: 待检查数据
            
        Returns:
            合规数据(如果严重违规返回None)
        """
        # 1. 检查是否存在医生私人手机号
        # 规则:简介中出现11位手机号
        if self._has_mobile_phone(item.get('introduction', '')):
            # 脱敏处理
            item['introduction'] = self._mask_phone(item['introduction'])
            logger.warning(f"[隐私保护] 医生 {item.get('doctor_name')} 简介包含手机号,已脱敏")
            
        # 2. 检查是否有患者隐私
        # 规则:评价中出现真实姓名(这里简化处理)
        
        # 3. 检查是否有身份证号
        if self._has_id_card(item.get('introduction', '')):
            item['introduction'] = self._mask_id_card(item['introduction'])
            
        return item
    
    def _has_mobile_phone(self, text: str) -> bool:
        """检测手机号"""
        return bool(re.search(r'1[3-9]\d{9}', str(text)))
    
    def _mask_phone(self, text: str) -> str:
        """手机号脱敏: 138****1234"""
        return re.sub(r'(1[3-9]\d)\d{4}(\d{4})', r'\1****\2', str(text))
        
    def _has_id_card(self, text: str) -> bool:
        """检测身份证号"""
        return bool(re.search(r'\d{17}[\dXx]', str(text)))
        
    def _mask_id_card(self, text: str) -> str:
        """身份证脱敏"""
        return re.sub(r'(\d{6})\d{8}(\d{3}[\dXx])', r'\1********\2', str(text))

🚀 完整实战示例:爬取"某协和医院"排班

下面我们将所有模块组合起来,演示一个完整的爬取流程。

假设目标网站特征:

  • URL: https://example-hospital.com/schedule
  • 类型: 静态HTML
  • 排班表结构: table.schedule-list
python 复制代码
# main.py
"""
医院排班采集系统 - 主程序
"""

import json
import time
from core.crawler_engine import CrawlerEngine
from core.parsers.html_parser import HospitalHTMLParser
from core.processors.cleaner import DataCleaner
from core.processors.compliance import ComplianceChecker
from loguru import logger

def main():
    # 1. 初始化组件
    logger.add("logs/spider.log", rotation="10 MB")
    logger.info("启动采集系统...")
    
    engine = CrawlerEngine()
    parser = HospitalHTMLParser()
    cleaner = DataCleaner()
    checker = ComplianceChecker()
    
    # 2. 定义目标任务
    # 实际项目中应从数据库或配置文件读取
    task = {
        'hospital_name': '某协和医院',
        'url': 'https://example-hospital.com/schedule',
        'method': 'requests',  # 已知是静态页面
        'rules': {
            'rows_xpath': '//table[@class="schedule-list"]//tr[position()>1]',
            'fields': {
                'doctor_name': './td[1]/a/text()',
                'title': './td[2]/text()',
                'department': './td[3]/text()',
                'date': './td[4]/text()',
                'period': './td[5]/text()',
                'status': './td[6]/span/text()',
                'price': './td[7]/text()'
            }
        }
    }
    
    # 3. 执行采集流程
    try:
        # 3.1 获取页面
        logger.info(f"正在爬取: {task['hospital_name']}")
        html = engine.fetch(task['url'], method=task['method'])
        
        if not html:
            logger.error("采集失败:无法获取页面")
            return
            
        # 3.2 解析数据
        logger.info("正在解析数据...")
        raw_schedules = parser.parse_schedule(html, task['rules'])
        logger.info(f"提取到 {len(raw_schedules)} 条原始记录")
        
        # 3.3 清洗与合规处理
        valid_schedules = []
        for item in raw_schedules:
            # 清洗
            cleaned_item = cleaner.clean_schedule(item)
            
            # 合规检查
            safe_item = checker.check(cleaned_item)
            
            if safe_item:
                # 补充医院信息
                safe_item['hospital_name'] = task['hospital_name']
                safe_item['crawl_time'] = time.strftime('%Y-%m-%d %H:%M:%S')
                valid_schedules.append(safe_item)
        
        logger.info(f"清洗后有效数据: {len(valid_schedules)} 条")
        
        # 3.4 数据存储(模拟)
        save_to_json(valid_schedules)
        
    except Exception as e:
        logger.exception(f"任务执行异常: {e}")
    finally:
        engine.close()
        logger.info("任务结束")

def save_to_json(data):
    """保存数据到JSON文件"""
    filename = f"data/schedules_{int(time.time())}.json"
    with open(filename, 'w', encoding='utf-8') as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
    logger.info(f"数据已保存至: {filename}")

if __name__ == '__main__':
    main()

运行结果预览

控制台输出:

json 复制代码
2025-01-29 15:00:01 | INFO | 启动采集系统...
2025-01-29 15:00:01 | INFO | [Requests爬虫] 初始化完成 | 代理: 关闭
2025-01-29 15:00:01 | INFO | [爬虫引擎] 初始化完成
2025-01-29 15:00:01 | INFO | 正在爬取: 某协和医院
2025-01-29 15:00:02 | DEBUG | [成功] 状态码: 200, 长度: 45023 字符
2025-01-29 15:00:02 | INFO | 正在解析数据...
2025-01-29 15:00:02 | INFO | [解析] 找到 25 行数据
2025-01-29 15:00:02 | INFO | 提取到 25 条原始记录
2025-01-29 15:00:02 | INFO | 清洗后有效数据: 25 条
2025-01-29 15:00:02 | INFO | 数据已保存至: data/schedules_1706511602.json
2025-01-29 15:00:02 | INFO | 任务结束

JSON结果文件:

json 复制代码
[
  {
    "doctor_name": "张建国",
    "title": "主任医师",
    "department": "心内科",
    "date": "2025-02-03",
    "period": "上午",
    "status": "可预约",
    "price": 300.0,
    "hospital_name": "某协和医院",
    "crawl_time": "2025-01-29 15:00:02"
  },
  {
    "doctor_name": "李红",
    "title": "副主任医师",
    "department": "儿科",
    "date": "2025-02-03",
    "period": "下午",
    "status": "已满",
    "price": 100.0,
    "hospital_name": "某协和医院",
    "crawl_time": "2025-01-29 15:00:02"
  }
]

📈 进阶优化:打造企业级系统

1. 分布式部署(Celery + Redis)

当需要爬取几百家医院时,单机爬虫效率不足。

python 复制代码
# tasks.py (Celery任务)
from celery import Celery

app = Celery('hospital_spider', broker='redis://localhost:6379/0')

@app.task
def crawl_hospital(hospital_config):
    """
    爬取单个医院的任务
    可以分发到多个Worker节点执行
    """
    crawler = CrawlerEngine()
    # ... 执行爬取逻辑 ...
    return result

2. 智能调度与增量更新

不要每次都全量爬取,根据排班更新规律进行智能调度。

策略:

  • 排班更新检测:每小时爬取一次排班页面的Header(Etag/Last-Modified),有变化才爬取内容。
  • 热点医院:每天更新4次(早8点,中午12点,晚6点,凌晨)。
  • 冷门医院:每天更新1次(凌晨)。
  • 节假日前夕:提高更新频率。

3. 数据质量监控

建立监控大盘(Prometheus + Grafana),实时监控:

  • 采集成功率:如果某医院成功率跌破90%,立即报警(可能改版或封IP)。
  • 数据量波动:如果某天数据量暴增或暴跌,说明解析可能出错。
  • 排班连续性:检查是否缺失某天的数据。

📝 总结与延伸阅读

我们完成了什么?

  1. 构建了一个混合架构爬虫(requests + Selenium),兼顾速度与兼容性。
  2. 设计了反爬虫对抗系统,包括验证码识别、代理池和指纹伪装。
  3. 实现了完善的数据清洗与合规检查,确保数据可用且合法。
  4. 提供了一个可扩展的框架,方便新增医院。

下一步可以做什么?

  1. OCR模型训练:针对特定医院的复杂验证码训练专用模型。
  2. 自然语言处理(NLP):对医生简介进行实体抽取,提取"擅长手术"、"研究方向"等标签。
  3. 推荐算法:基于患者搜索历史和医生擅长领域,构建推荐系统。

推荐资源

  • Legal:《中华人民共和国数据安全法》全文解读
  • Book:《Python3网络爬虫开发实战》(崔庆才 著)- 经典教材
  • Tool:Scrapyd - 爬虫部署与管理工具

最后再次强调:技术无罪,但在医疗领域使用爬虫技术,必须时刻保持敬畏之心,严格遵守法律法规和道德底线,确保数据用于造福患者,而非非法牟利。

祝你的爬虫项目顺利上线!🚀

🌟 文末

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

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

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

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

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

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

✅ 互动征集

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

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


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

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

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


✅ 免责声明

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

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

  • 合法使用: 不得将本项目用于任何违法、违规或侵犯他人权益的行为,包括但不限于网络攻击、诈骗、绕过身份验证、未经授权的数据抓取等。
  • 风险自负: 任何因使用本项目而产生的法律责任、技术风险或经济损失,由使用者自行承担,项目作者不承担任何形式的责任。
  • 禁止滥用: 不得将本项目用于违法牟利、黑产活动或其他不当商业用途。
  • 使用或者参考本项目即视为同意上述条款,即 "谁使用,谁负责" 。如不同意,请立即停止使用并删除本项目。!!!
相关推荐
郝学胜-神的一滴2 小时前
贝叶斯之美:从公式到朴素贝叶斯算法的实践之旅
人工智能·python·算法·机器学习·scikit-learn
好家伙VCC2 小时前
**发散创新:用 Rust构建多智能体系统,让分布式协作更高效**在人工智能快速演进的今天,**多智能体系统(
java·人工智能·分布式·python·rust
梦幻精灵_cq2 小时前
*终端渲染天花板:文心道法解码——闲聊终端渲染状态一统江山
python
yuanmenghao2 小时前
Linux 性能实战 | 第 18 篇:ltrace 与库函数性能分析
linux·python·性能优化
ValhallaCoder2 小时前
hot100-图论
数据结构·python·算法·图论
破烂pan2 小时前
Python 实现 HTTP Client 的常见方式
开发语言·python·http
寒听雪落2 小时前
ZYNQ PS HTML服务器和客户端
python
康小庄2 小时前
Java自旋锁与读写锁
java·开发语言·spring boot·python·spring·intellij-idea
NO12122 小时前
使用paddle OCR对带文字的图片转正
python