1. 抢票脚本的真实边界先搞清楚它能做什么、不能做什么每年假期临近后台总会收到类似的问题“有没有那种一键抢票、百分百成功的脚本”说实话每次看到“100%成功”这几个字我的第一反应都是——要么是标题党要么是没真正跑过抢票脚本的人写的。12306 的票务系统不是静态页面它背后是一整套动态放票、候补排队、风控识别、验证码校验的机制。任何声称“100%成功”的方案要么是在特定时间窗口内抢到了某趟冷门车次要么就是压根没考虑真实并发场景。我写抢票脚本断断续续有几年了从最早的纯requests模拟请求到后来用selenium驱动浏览器再到研究候补接口的调用逻辑踩过的坑比抢到的票多得多。这篇文章不打算给你画饼而是把整个脚本的构建思路、核心模块、验证码处理策略、以及实际运行中会遇到的各种意外情况掰开揉碎讲清楚。适合有一定 Python 基础、了解selenium基本用法、想自己动手写一个可用抢票工具的人。如果你指望复制粘贴就能躺着抢到票那可能会失望——但如果你愿意花时间理解背后的逻辑至少能写出一个在放票瞬间帮你提高成功率的工具。先明确几个前提。第一12306 的页面结构会变今天能用的选择器明天可能就失效所以脚本的维护成本不低。第二验证码是绕不过去的坎目前主流方案是人工介入或者调用第三方识别服务纯自动化的识别率并不稳定。第三也是最关键的——脚本的作用是“帮你更快地完成重复操作”而不是“突破系统限制”。理解这三点后面的内容才有意义。2. 环境搭建从 Python 安装到 WebDriver 版本匹配的完整链路2.1 Python 环境与依赖库的选型逻辑很多人卡在第一步不是技术问题而是环境问题。我见过太多人用着系统自带的 Python 2.7或者装了 Python 但 pip 指向了错误的路径。我的建议很直接去 Python 官网下载 3.10 以上的版本安装时勾选“Add Python to PATH”这一步能省掉后面 80% 的环境问题。装完之后在命令行敲python --version确认输出的是你刚装的版本而不是系统自带的旧版本。依赖库方面核心就三个selenium负责驱动浏览器requests用于后续可能的接口调用Pillow用来处理验证码截图。安装命令很简单pip install selenium requests Pillow但这里有个细节——如果你用的是虚拟环境确保在虚拟环境激活的状态下安装。我习惯用venv创建独立环境避免和系统包冲突python -m venv ticket_env ticket_env\Scripts\activate # Windows source ticket_env/bin/activate # macOS/Linux2.2 WebDriver 版本匹配为什么你的脚本总是启动失败这是新手最容易踩的坑。selenium4.x 之后WebDriver 的管理方式有了变化但很多人还在用旧教程里的webdriver.Chrome(executable_path...)结果报错说参数不对。更常见的问题是 Chrome 浏览器自动更新后WebDriver 版本不匹配脚本直接抛异常。我的做法是先确认 Chrome 的版本号在浏览器地址栏输入chrome://version/就能看到。然后去对应的 WebDriver 下载页面找到匹配的版本。比如你的 Chrome 是 138.0.7204.169那就找 138.0.7204.x 系列的 WebDriver。注意WebDriver 的小版本号不需要完全一致只要大版本对得上就行。如果你不想手动管理可以用webdriver-manager这个库它会自动检测浏览器版本并下载对应的驱动from selenium import webdriver from webdriver_manager.chrome import ChromeDriverManager from selenium.webdriver.chrome.service import Service service Service(ChromeDriverManager().install()) driver webdriver.Chrome(serviceservice)但我要提醒一句webdriver-manager在某些网络环境下下载会很慢甚至失败所以最好还是手动下载一次把驱动放在项目目录里用相对路径引用。这样脚本的可移植性更强换台机器也能跑。2.3 浏览器启动参数的调优默认的selenium启动的 Chrome 窗口会带有“Chrome 正受到自动测试软件的控制”提示而且窗口大小、加载策略都会影响脚本的执行效率。我通常会在启动时加几个参数options webdriver.ChromeOptions() options.add_argument(--disable-blink-featuresAutomationControlled) options.add_argument(--start-maximized) options.add_experimental_option(excludeSwitches, [enable-automation]) options.add_experimental_option(useAutomationExtension, False)--disable-blink-featuresAutomationControlled这个参数的作用是隐藏navigator.webdriver属性降低被识别为自动化工具的概率。虽然不能完全绕过检测但至少能让页面加载更顺畅。另外--start-maximized确保窗口最大化避免某些元素因为窗口太小而不可见导致点击失败。还有一个容易被忽略的点页面加载策略。默认是normal即等待所有资源加载完成。但 12306 的页面有很多异步请求等全部加载完太慢了。我一般设为eager即 DOM 结构就绪后就继续执行options.page_load_strategy eager这个改动能让脚本的响应速度提升不少尤其是在查询车票的环节。3. 登录环节的自动化扫码、短信与滑块验证码的应对策略3.1 为什么我不推荐纯自动登录登录是抢票脚本的第一道门槛。12306 的登录方式有账号密码登录和扫码登录两种。账号密码登录会触发滑块验证码扫码登录则需要手机确认。很多人想用selenium自动输入账号密码然后破解滑块但实际做下来会发现两个问题一是滑块的轨迹检测越来越严格简单的匀速拖动会被识别二是即使登录成功后续的查询和下单环节还可能触发二次验证。我的建议是扫码登录 人工确认。脚本打开登录页面后自动切换到扫码模式然后暂停执行等你用手机扫码并确认登录。登录成功后脚本再继续执行后续操作。这样做的好处是稳定、不易触发风控而且省去了处理滑块的麻烦。具体实现上可以用input()阻塞脚本等你确认后再继续driver.get(https://kyfw.12306.cn/otn/resources/login.html) # 切换到扫码登录 driver.find_element(By.CLASS_NAME, login-hd-account).click() input(请扫码登录登录完成后按回车继续...)3.2 验证码识别的现实方案如果你非要走账号密码登录的路子那就得面对验证码。12306 的验证码经历过好几代变化从最初的数字字母到后来的图形点选再到现在的滑块和短信验证。目前比较现实的方案有三种第一种是人工介入。脚本截图验证码弹出一个窗口让你手动输入然后脚本继续。这种方式识别率 100%但需要你盯着屏幕。适合抢票时间点你正好有空的情况。第二种是调用第三方 OCR 服务。市面上有一些专门做验证码识别的 API按次收费。你把验证码图片传过去它返回识别结果。这种方式的识别率取决于验证码的复杂度对于简单的数字字母验证码效果不错但遇到滑块或点选就无能为力了。第三种是本地 OCR 模型。比如用ddddocr这个库它专门针对各类验证码做了优化对数字字母和简单算术题的识别率很高。安装和使用都很简单import ddddocr ocr ddddocr.DdddOcr() with open(captcha.png, rb) as f: img_bytes f.read() result ocr.classification(img_bytes) print(result)但要注意ddddocr对滑块验证码的识别效果有限它主要解决的是文字类验证码。如果你的场景是滑块那还是得回到人工或者更复杂的轨迹模拟方案。3.3 登录后的会话保持登录成功后selenium的会话默认是保持在浏览器里的。但如果你中途关闭了浏览器或者脚本异常退出下次就得重新登录。为了避免重复登录可以把浏览器的用户数据目录持久化options.add_argument(--user-data-dir./user_data)这样每次启动都会加载之前的会话信息只要 12306 的登录状态没过期就不用重新扫码。这个技巧在调试阶段特别有用能省下大量重复登录的时间。4. 车票查询与下单从页面解析到订单提交的完整流程4.1 查询条件的构造与提交登录之后下一步是查询车票。12306 的查询页面需要填写出发地、目的地、出发日期等信息。用selenium操作时难点在于这些输入框不是标准的input元素而是带有自动补全功能的组合框。你不能直接send_keys而是要先点击输入框等下拉列表出现后再点击对应的选项。我的做法是封装一个函数处理这种“输入 选择”的操作def select_station(driver, input_id, station_name): input_box driver.find_element(By.ID, input_id) input_box.clear() input_box.send_keys(station_name) time.sleep(1) # 等待下拉列表出现 # 点击第一个匹配的选项 option driver.find_element(By.XPATH, f//div[id{input_id}_list]//a[contains(text(), {station_name})]) option.click()这里time.sleep(1)是必要的因为下拉列表的加载需要时间。虽然可以用WebDriverWait替代但在这种场景下简单的等待反而更稳定因为下拉列表的出现时机不太确定。日期选择相对简单12306 的日期输入框通常是只读的你需要通过点击日期控件来选择。或者直接用 JavaScript 修改输入框的值driver.execute_script(fdocument.getElementById(train_date).value {date})但这种方式有时会被页面的校验逻辑拦截所以更稳妥的做法还是模拟点击。4.2 解析车次列表与筛选目标车次查询结果是一个表格每一行代表一个车次。你需要遍历这些行找到符合你要求的车次。关键字段包括车次号、出发时间、到达时间、历时、各席别余票状态。解析表格时我习惯用find_elements获取所有行然后逐行提取文本rows driver.find_elements(By.XPATH, //table[idqueryLeftTable]//tr[contains(id, ticket_)]) for row in rows: train_no row.find_element(By.CLASS_NAME, number).text # 提取其他字段...注意12306 的表格中有些行是隐藏的比如用于展开席别详情的行所以要用contains(id, ticket_)来过滤。另外余票状态可能是“有”、“无”、“候补”或者具体数字你需要根据这些状态决定是否点击预订按钮。筛选逻辑可以根据自己的需求定制。比如我只想要高铁二等座那就过滤掉所有非 G 字头车次并且检查二等座是否有票。如果没票但有候补也可以考虑提交候补订单。4.3 提交订单与处理确认弹窗点击预订按钮后会弹出一个确认对话框显示车次信息和乘客选择。这一步需要你提前在 12306 中添加好乘客信息脚本只需要勾选对应的乘客即可。# 勾选乘客 passenger_checkbox driver.find_element(By.XPATH, f//label[contains(text(), {passenger_name})]//preceding-sibling::input) if not passenger_checkbox.is_selected(): passenger_checkbox.click()然后选择席别点击提交订单。提交后可能会遇到“排队中”的提示这时候脚本需要等待直到出现支付页面或者订单确认页面。如果遇到“系统繁忙”之类的错误需要重试。这里有个经验不要频繁点击提交按钮。12306 对频繁提交有风控点太快反而容易被拦截。我一般设置 2-3 秒的间隔如果失败就重新查询再提交。5. 抢票策略与风控规避让脚本跑得更稳的几个关键点5.1 放票时间点的精准把控12306 的放票时间不是统一的不同车站的起售时间不同。你需要提前查好目标车站的放票时间然后在那个时间点前后启动脚本。一般来说放票前 5 分钟开始查询放票瞬间刷新成功率最高。但要注意脚本的查询频率不能太高。我试过每秒查询一次结果触发了风控IP 被临时限制。后来改成每 3-5 秒查询一次反而更稳定。因为 12306 的余票数据有缓存查太快也没意义。5.2 候补订单的自动提交如果目标车次没票候补是另一个选择。候补的逻辑是提交候补订单后系统会在有退票或余票时按排队顺序分配。脚本可以自动检测候补按钮是否可用如果可用就提交候补。候补的难点在于你需要提前设置好候补的截止时间和车次范围。这些在脚本里可以通过读取页面元素来动态判断。但候补的成功率取决于排队位置和退票情况脚本能做的就是尽早提交剩下的交给系统。5.3 异常处理与日志记录抢票过程中会遇到各种异常页面加载超时、元素找不到、网络中断、验证码弹出等等。如果没有完善的异常处理脚本很容易崩溃。我的做法是用try-except包裹每个关键步骤出错时截图保存现场并记录日志import logging logging.basicConfig(filenameticket.log, levellogging.INFO, format%(asctime)s - %(message)s) try: # 关键操作 pass except Exception as e: driver.save_screenshot(ferror_{int(time.time())}.png) logging.error(f操作失败: {e})截图能帮你快速定位问题比如是页面结构变了还是验证码没处理。日志则能让你回顾整个抢票过程分析失败原因。5.4 关于“100%成功”的理性认知最后说点实在的。抢票脚本的本质是自动化重复操作它不能创造票源也不能突破系统的排队机制。所谓“100%成功”要么是抢到了没人要的冷门车次要么是在放票瞬间恰好网络和手速都跟上了。真正决定成败的因素是你的网络延迟、脚本的响应速度、目标车次的竞争激烈程度以及一点点运气。我自己的经验是用脚本抢票的成功率大概比手动高 30%-50%主要体现在放票瞬间的响应速度上。但如果你目标车次是热门线路的黄金时段那脚本也帮不了太多——该抢不到还是抢不到。所以合理设置预期把脚本当作辅助工具而不是万能钥匙心态会好很多。6. 脚本维护与迭代页面改版后的快速修复思路6.1 定位元素失效的排查方法12306 的页面结构每隔一段时间就会调整可能是 class 名变了也可能是 DOM 层级变了。当脚本突然报“元素找不到”时第一步是打开浏览器的开发者工具检查对应元素的当前属性。然后对比脚本里的选择器看看哪里对不上。我习惯用相对稳定的属性来定位元素比如id和name而不是依赖 class 名。因为 class 名经常变而id通常比较稳定。如果必须用 class那就用contains来匹配部分文本而不是完全匹配。6.2 用 Page Object 模式提升可维护性如果你的脚本功能比较多建议用 Page Object 模式来组织代码。把每个页面封装成一个类页面元素作为类的属性操作作为方法。这样当页面改版时只需要修改对应的类而不需要到处找选择器。class LoginPage: def __init__(self, driver): self.driver driver self.qr_code_tab (By.CLASS_NAME, login-hd-account) def switch_to_qr(self): self.driver.find_element(*self.qr_code_tab).click()这种写法虽然前期麻烦一点但后期维护成本低很多。尤其是当你需要同时维护多个版本的脚本时优势更明显。6.3 定期测试与灰度更新每次 12306 有大的页面调整我都会先用小号测试一遍完整流程确认没问题后再用主号跑。测试的时候可以把关键步骤的截图保存下来方便对比。另外脚本的更新不要一次性全改而是逐步替换模块这样出问题时容易回滚。还有一点不要把所有的鸡蛋放在一个篮子里。如果你有多台机器或者多个网络环境可以同时跑不同的脚本版本哪个先成功就用哪个。这种“冗余”策略在实际抢票中往往比优化单脚本更有效。说到底抢票脚本是一个需要持续维护的工具没有一劳永逸的方案。但只要你理解了它的工作原理掌握了排查问题的方法就能在每次页面改版后快速修复让它继续为你服务。我在实际使用中发现最耗时的不是写脚本而是调试和应对各种意外情况。所以耐心和细心比技术本身更重要。
