㊗️本期内容已收录至专栏《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))
-
- 常见反爬虫机制及应对方法
- [1. 验证码识别](#1. 验证码识别)
- [2. 代理池管理](#2. 代理池管理)
- [🧩 解析层实现(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 方法
混合方案的优势:
- 性能优化:90%的医院使用requests(快),10%使用Selenium(慢但必需)
- 资源节约:避免所有医院都用Selenium导致的资源浪费
- 灵活性:可以根据实际情况调整策略
医院网站类型分析
经过对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)。
- 数据量波动:如果某天数据量暴增或暴跌,说明解析可能出错。
- 排班连续性:检查是否缺失某天的数据。
📝 总结与延伸阅读
我们完成了什么?
- 构建了一个混合架构爬虫(requests + Selenium),兼顾速度与兼容性。
- 设计了反爬虫对抗系统,包括验证码识别、代理池和指纹伪装。
- 实现了完善的数据清洗与合规检查,确保数据可用且合法。
- 提供了一个可扩展的框架,方便新增医院。
下一步可以做什么?
- OCR模型训练:针对特定医院的复杂验证码训练专用模型。
- 自然语言处理(NLP):对医生简介进行实体抽取,提取"擅长手术"、"研究方向"等标签。
- 推荐算法:基于患者搜索历史和医生擅长领域,构建推荐系统。
推荐资源
- Legal:《中华人民共和国数据安全法》全文解读
- Book:《Python3网络爬虫开发实战》(崔庆才 著)- 经典教材
- Tool:Scrapyd - 爬虫部署与管理工具
最后再次强调:技术无罪,但在医疗领域使用爬虫技术,必须时刻保持敬畏之心,严格遵守法律法规和道德底线,确保数据用于造福患者,而非非法牟利。
祝你的爬虫项目顺利上线!🚀
🌟 文末
好啦~以上就是本期的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!
小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥
✅ 专栏持续更新中|建议收藏 + 订阅
墙裂推荐订阅专栏 👉 《Python爬虫实战》,本专栏秉承着以"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一期内容都做到:
✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)
📣 想系统提升的小伙伴 :强烈建议先订阅专栏 《Python爬虫实战》,再按目录大纲顺序学习,效率十倍上升~

✅ 互动征集
想让我把【某站点/某反爬/某验证码/某分布式方案】等写成某期实战?
评论区留言告诉我你的需求,我会优先安排实现(更新)哒~
⭐️ 若喜欢我,就请关注我叭~(更新不迷路)
⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)
⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)
✅ 免责声明
本文爬虫思路、相关技术和代码仅用于学习参考,对阅读本文后的进行爬虫行为的用户本作者不承担任何法律责任。
使用或者参考本项目即表示您已阅读并同意以下条款:
- 合法使用: 不得将本项目用于任何违法、违规或侵犯他人权益的行为,包括但不限于网络攻击、诈骗、绕过身份验证、未经授权的数据抓取等。
- 风险自负: 任何因使用本项目而产生的法律责任、技术风险或经济损失,由使用者自行承担,项目作者不承担任何形式的责任。
- 禁止滥用: 不得将本项目用于违法牟利、黑产活动或其他不当商业用途。
- 使用或者参考本项目即视为同意上述条款,即 "谁使用,谁负责" 。如不同意,请立即停止使用并删除本项目。!!!
