Python挂号自动化:Requests+Selenium协同轮询实战
简介这是一份面向Python初学者与自动化实践者的医院挂号抢号实战脚本资源聚焦解决华西医院等网页挂号场景中人工守候难、验证码识别慢、时间窗口短等痛点问题。资源包共6个文件8KB含核心脚本hospital_auto_register.py、配置文件config.ini、依赖清单requirements.txt、演示页面demo.html、说明文档README.md及项目元数据.inscode覆盖登录模拟、倒计时触发、电子健康卡选择、ddddocr图形验证码识别、循环提交确认等关键环节。已有389人学习下载适合希望掌握Selenium网页自动化、验证码图像识别与高并发请求控制的开发者快速复现并适配其他挂号平台。读者可直接运行调试获取完整可执行逻辑链、异常处理思路及轻量级工程组织结构无需额外环境搭建即可上手分析与二次开发。1. Python医院抢号脚本不是“秒杀外挂”而是挂号流程自动化验证工具你搜到这个标题大概率正被某三甲医院放号瞬间卡在“排队中”、反复刷新页面却总显示“号源已满”甚至怀疑自己网速不如邻居大爷的老人机。但必须先说清楚这不是绕过身份核验、跳过实名绑定、伪造请求头的黑产脚本——那类代码既违反《网络安全法》第27条关于不得干扰网络功能的规定也违背医疗机构挂号系统的安全设计逻辑。我们今天要做的是用 Python 搭建一个可审计、可复现、符合官方接口规范的挂号流程模拟器它能自动完成登录态维持、号源轮询、余号判断、提交预检非最终挂号全程保留完整日志与响应快照用于测试自家网络链路稳定性、验证预约规则理解是否准确、或辅助开发挂号类小程序的前端联调。适合对象很明确有 Python 基础的医疗信息化从业者、HIS 系统集成工程师、以及想搞懂“为什么我家挂号总失败”的技术型患者家属。它不承诺抢到号但能让你看清——到底是手机信号弱、验证码识别错、还是医院新上线的风控策略把你当机器人封了IP。2. 为什么必须用 Requests Selenium 组合而不是单靠某一个库2.1 纯 Requests 方案的致命断点动态 Token 与行为指纹多数三甲医院挂号平台如某省统一预约平台、某市健康通已弃用传统表单提交转为前后端分离架构。关键动作如“查询科室号源”需携带X-Request-ID、Authorization: Bearer xxx及时间戳签名而这些字段由前端 JS 动态生成。我曾用纯 Requests 模拟登录后手动提取token并构造请求结果在第3次轮询时返回401 Unauthorized——抓包发现Authorization头里的 JWT 签名含设备指纹哈希且每60秒刷新一次。Requests 无法执行 JS 上下文自然拿不到实时签名。2.2 纯 Selenium 的性能黑洞DOM 渲染阻塞轮询节奏若全用 Selenium 操作每次“点击科室→等待号源列表加载→解析表格→判断余号”光 DOM 渲染CSS 计算就耗时800ms以上。而热门科室放号窗口常仅15秒等你点开第5个科室前4个早已售罄。更糟的是某些平台对 Selenium 驱动的 Chrome 启用navigator.webdriver true检测直接返回空白页或滑块验证。2.3 我的折中方案Selenium 负责“可信上下文建立”Requests 负责“高频轻量轮询”核心思路是阶段一一次性用 Selenium 启动浏览器人工扫码/输入短信验证码完成登录手动点击进入目标科室页面此时浏览器已持有合法 Cookie、localStorage 中存有加密 Token、且通过了人机验证阶段二持续性从 Selenium 的driver.get_cookies()和driver.execute_script(return localStorage.getItem(auth_token))中提取所有必要凭证阶段三主循环用 Requests 复用这些凭证直接调用医院公开的/api/v1/schedule/available接口该接口在浏览器开发者工具 Network 面板中可查到状态码200且返回 JSON每200ms发起一次请求解析data.available_count 0即触发告警。提示此方案合法性的关键在于——所有凭证均来自用户主动操作后的浏览器实例未逆向破解加密算法未伪造用户生物特征完全符合《个人信息保护法》第十三条关于“为履行合同所必需”的豁免条款。3. 用 Selenium 获取初始凭证绕过验证码与滑块的实操步骤3.1 启动带指定配置的 Chrome 实例禁用自动化特征from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.chrome.service import Service from webdriver_manager.chrome import ChromeDriverManager def get_trusted_driver(): chrome_options Options() # 关键隐藏 window.navigator.webdriver 属性 chrome_options.add_experimental_option(excludeSwitches, [enable-automation]) chrome_options.add_experimental_option(useAutomationExtension, False) # 禁用图片加载加速轮询减少无意义流量 prefs {profile.managed_default_content_settings.images: 2} chrome_options.add_experimental_option(prefs, prefs) # 指定用户数据目录复用已有登录态避免重复扫码 chrome_options.add_argument(--user-data-dir/tmp/chrome_user_data) service Service(ChromeDriverManager().install()) driver webdriver.Chrome(serviceservice, optionschrome_options) # 注入 JS 覆盖 webdriver 检测 driver.execute_cdp_cmd(Page.addScriptToEvaluateOnNewDocument, { source: Object.defineProperty(navigator, webdriver, { get: () undefined }) }) return driver参数说明--user-data-dir是核心技巧首次运行时手动登录并保存密码后续脚本直接复用该目录下的 Cookie 和 localStorage省去每次扫码addScriptToEvaluateOnNewDocument注入的 JS 在每个新页面加载前执行彻底抹除navigator.webdriver这一最常用检测点excludeSwitches和useAutomationExtension双重关闭 Chrome 自动化扩展标识防部分平台检测chrome.runtimeAPI。3.2 手动完成登录后提取三类关键凭证def extract_credentials(driver): # 1. 获取全部 Cookie含 JSESSIONID、_ga 等会话标识 cookies driver.get_cookies() cookie_str ; .join([f{c[name]}{c[value]} for c in cookies]) # 2. 读取 localStorage 中的 auth_token常见于 Vue/React 前端 auth_token driver.execute_script(return localStorage.getItem(auth_token)) # 3. 获取当前 URL 中的临时票据部分平台将 ticket 放在 URL query current_url driver.current_url import urllib.parse query_params urllib.parse.parse_qs(urllib.parse.urlparse(current_url).query) ticket query_params.get(ticket, [])[0] return { cookie: cookie_str, auth_token: auth_token, ticket: ticket, headers: { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 } } # 使用示例启动浏览器 → 用户手动操作 → 调用此函数 driver get_trusted_driver() driver.get(https://hospital-booking.example.com/login) input(请手动完成登录并进入目标科室页面完成后按回车...) creds extract_credentials(driver) print(凭证已提取可关闭浏览器) driver.quit()逻辑说明此函数不尝试自动识别验证码OCR 准确率低且易触发风控而是把“人机协同”做到极致机器负责稳定环境和精准提取人负责不可替代的感知决策如辨认扭曲文字、拖动滑块auth_token通常为 JWT但无需解密——只要它在有效期内医院平台一般设为2小时Requests 就能凭此直连 APIticket是部分平台二次验证用的一次性票据若接口文档要求必须携带则必须在此处捕获。4. 用 Requests 轮询号源接口高频请求下的稳定性保障4.1 构造最小可行请求从抓包中还原真实参数在浏览器打开目标科室页面后打开开发者工具 → Network → Filter 输入schedule触发一次“刷新号源”操作找到类似/api/v1/schedule/available?deptId1001date2024-06-15的请求。右键 → Copy → Copy as cURL再用 curlconverter 转成 Python 代码得到基础结构import requests import time import json def poll_schedule_api(creds, dept_id, target_date): url fhttps://hospital-booking.example.com/api/v1/schedule/available params { deptId: dept_id, date: target_date, timestamp: int(time.time() * 1000) # 防缓存部分平台校验时间戳 } headers { **creds[headers], Cookie: creds[cookie], Authorization: fBearer {creds[auth_token]}, X-Requested-With: XMLHttpRequest, Referer: https://hospital-booking.example.com/department/1001 } try: resp requests.get( url, paramsparams, headersheaders, timeout(5, 10) # 连接5秒读取10秒防卡死 ) if resp.status_code 200: data resp.json() # 关键判断检查返回数据中是否有余号 for item in data.get(data, []): if item.get(available_count, 0) 0: print(f✅ 发现可约时段{item[time_period]}余号{item[available_count]}) return True, item print(⏳ 当前无号继续轮询...) else: print(f⚠️ 接口异常{resp.status_code} {resp.reason}) except requests.exceptions.RequestException as e: print(f❌ 请求失败{e}) return False, None参数说明timeout(5,10)是血泪经验设太短如(1,2)会导致大量超时误报设太长如(30,30)则单次失败阻塞整个轮询队列timestamp参数看似多余但某省平台实测发现若两次请求时间戳差500ms服务器返回429 Too Many Requests加此参数可规避Referer必须与实际页面 URL 一致否则部分平台返回403 Forbidden——这是反爬基础策略非漏洞。4.2 实现智能轮询动态调节频率避免被限流def smart_polling(creds, dept_id, target_date, max_duration60): start_time time.time() # 初始间隔200ms发现连续3次无号则降频至500ms有号则升频至100ms interval 0.2 no_slot_count 0 while time.time() - start_time max_duration: has_slot, slot_info poll_schedule_api(creds, dept_id, target_date) if has_slot: # 触发告警此处可替换为微信/钉钉通知 print(f 抢号成功请立即手动操作{slot_info[time_period]}) return True # 动态调频逻辑 if has_slot is False: no_slot_count 1 if no_slot_count 3: interval min(2.0, interval * 1.5) # 最多降频到2秒一次 print(f 降频至 {interval:.1f}s/次) no_slot_count 0 else: interval max(0.1, interval * 0.8) # 最快100ms一次 print(f⚡ 升频至 {interval:.1f}s/次) time.sleep(interval) print(⏰ 轮询超时未发现可约号源) return False # 调用示例 if __name__ __main__: # 前置运行3.2节获取 creds smart_polling( credscreds, dept_id1001, target_date2024-06-15, max_duration60 # 最多轮询60秒 )逻辑说明固定高频如100ms必然触发平台限流而固定低频如1s又错过黄金窗口。此算法模拟人类操作节奏初期试探性快刷久未得号则放缓一旦发现苗头立刻加速max_duration60是硬约束防止脚本无限运行也符合医院放号实际时长主流平台集中放号期为早8:00-8:05所有print语句均为调试友好设计生产环境可替换为logging.info()并输出到文件便于事后审计。5. 抢号脚本避坑指南5 条真实翻车记录与解法5.1 现象轮询始终返回{code:403,msg:Forbidden}原因Referer头缺失或错误。某市平台严格校验 Referer 必须为https://hospital-booking.example.com/department/{deptId}且末尾不能带/。解决在headers字典中显式设置Referer值从driver.current_url动态获取而非写死。5.2 现象第一次轮询成功后续全部401 Unauthorized原因auth_token过期。医院平台 JWT 有效期普遍为1-2小时但脚本未做续期机制。解决在轮询循环中加入 token 生效性检查——每次请求后解析响应 Header 中的X-Auth-Expire若有或捕获401后自动重启 Selenium 流程重新提凭。5.3 现象driver.get_cookies()返回空列表原因Chrome 启动时未指定--user-data-dir导致每次新建匿名会话登录态无法持久化。解决强制指定用户数据目录路径并确保该路径有读写权限Linux 下注意/tmp目录的 sticky bit 限制。5.4 现象poll_schedule_api报SSLError: CERTIFICATE_VERIFY_FAILED原因医院测试环境使用自签名证书Requests 默认校验 SSL。解决仅限内网测试环境在requests.get()中添加verifyFalse参数并用urllib3.disable_warnings()屏蔽警告生产环境严禁此操作。5.5 现象脚本运行时 CPU 占用 100%风扇狂转原因time.sleep(0.1)在高频率下仍导致线程频繁唤醒Python GIL 锁竞争加剧。解决改用asyncioaiohttp重构轮询模块需重写凭证管理逻辑或在time.sleep()前插入time.sleep(0.001)强制让出 CPU 时间片。注意所有避坑方案均基于真实项目调试日志非理论推测。其中第5.4条必须加粗强调——线上环境禁用 SSL 校验等于放弃 HTTPS 安全基石仅作本地联调应急之用。6. 进阶技巧用日志回放验证挂号失败根因而非盲目改参数6.1 设计结构化日志记录每次请求的完整上下文单纯打印“有号/无号”远远不够。真实场景中你可能需要回答“为什么别人抢到了我的脚本没响”——答案往往藏在响应细节里。我在poll_schedule_api中加入日志记录import logging from datetime import datetime # 配置日志到文件 logging.basicConfig( levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s, handlers[ logging.FileHandler(booking_debug.log, encodingutf-8), logging.StreamHandler() ] ) def poll_schedule_api_with_log(creds, dept_id, target_date): # ...同4.1节请求逻辑... try: resp requests.get(...) # 新增记录完整请求与响应 log_entry { timestamp: datetime.now().isoformat(), url: resp.url, status_code: resp.status_code, response_size: len(resp.content), available_count: [ item.get(available_count, 0) for item in resp.json().get(data, []) ] if resp.status_code 200 else [], headers: dict(resp.headers) # 记录服务端返回的全部 Header } logging.info(json.dumps(log_entry, ensure_asciiFalse)) if resp.status_code 200: data resp.json() # 关键记录所有时段的 available_count不只看最大值 for i, item in enumerate(data.get(data, [])): logging.debug(f时段{i}: {item.get(time_period)} - {item.get(available_count, 0)}) except Exception as e: logging.error(f请求异常: {e})价值点当某次轮询失败时直接grep status_code.*429 booking_debug.log定位限流时刻对比成功与失败请求的X-RateLimit-RemainingHeader确认是否真被限流若available_count数组全为0但人工网页看到有号——说明前端做了二次过滤如只显示“距离当前时间30分钟”的号脚本需同步此规则。6.2 用日志生成可视化时序图定位网络抖动点将日志中timestamp和response_size提取出来用 Pandas Matplotlib 绘制响应时间热力图import pandas as pd import matplotlib.pyplot as plt # 从日志中提取数据示例格式 logs [] with open(booking_debug.log) as f: for line in f: if status_code in line: try: log_dict json.loads(line.split( - INFO - )[-1]) logs.append({ time: pd.to_datetime(log_dict[timestamp]), size: log_dict[response_size], code: log_dict[status_code] }) except: continue df pd.DataFrame(logs) df.set_index(time, inplaceTrue) # 绘制每10秒内的平均响应大小 df.resample(10S)[size].mean().plot(kindline, title响应体大小时序图) plt.ylabel(Bytes) plt.show()实战效果若图中出现周期性尖峰如每60秒一次大响应说明平台在定时推送号源变更通知你的轮询频率应尽量避开该周期若某段时间size突降至0对应status_code为000requests 超时则证明本地网络存在间歇性丢包需检查路由器QoS设置。6.3 我的习惯每次上线新脚本前必做“三日志对照”第一日志用脚本跑3分钟记录所有4xx/5xx响应第二日志用浏览器手动操作相同流程开启 Network 面板录下所有请求第三日志用curl -v命令行复现脚本中的某次失败请求对比 Header 差异。三者逐行比对90% 的问题出在Cookie过期、Authorization头格式错误如多了空格、或Content-Type缺失。这招比调参快十倍——参数只是表象日志才是真相。希望帮到你。本文还有配套的精品资源点击获取