简介:基于Selenium的12306自动抢票脚本资料包,主要面向计算机相关专业的在校学生、开发者以及自动化测试初学者,用于解决12306官网手动刷新抢票效率低这一常见问题。资源完整覆盖模拟登录、查询余票、提交订单等自动化流程,适合作为课程设计、毕业设计或项目演示的基础,也便于在源码基础上进行二次开发与功能扩展。压缩包共18个文件,以7个Python脚本为核心,辅以YAML/XML配置文件和md/txt文档,分别承担参数配置、站点信息维护、邮件通知与使用说明等功能,整体仅64KB,结构轻量、修改便捷。已有129人学习浏览,资料内含可直接运行的抢票源码和详细项目文档,可帮助读者快速理解Selenium操作逻辑,并进一步拓展多线程抢票、验证码识别等高级功能,是一款兼具实战与教学价值的开源资料。
1. Selenium 自动化的边界:12306 抢票脚本到底在抢什么
网上流传的“12306 抢票脚本”,十有八九是用 Selenium 打开一个真实浏览器,把查车次、选乘客、提交订单这些动作按顺序重复执行。它并不比手快多少,Selenium 解决的只是“稳定地、按顺序地”完成操作,真正的延迟仍在请求网络的往返时间与验证码处理上。对做自动化测试的人来说,这个场景是 WebDriver 面对动态渲染页面的典型样本:元素异步出现、弹窗状态变化、会话被服务端校验。本文从 Selenium 自动化测试框架的常规用法出发,把一个购票流程拆成可复现的脚本结构,并说明每一处容易翻车的地方,适合想理解 Python Selenium 在复杂表单站点上落地方式的工程师参考。
同时要明确边界:12306 有公开用户协议,脚本不应被用于干扰正常购票秩序。下面讨论的是 Selenium 如何处理异步页面、维护会话和设计容错,属于自动化测试框架研究,而不是生产抢票工具。
2. 把 12306 购票流程拆成 Selenium 能理解的状态机
2.1 购票流程里哪些动作适合自动驱动
完整的购票链路可以拆成登录、查询、选择车次、选择乘车人、提交订单、确认支付几个阶段。这中间并不是连续的 HTTP 请求,而是页面状态切换:输入出发地后会弹出城市候选面板,选完日期后车次表格才通过异步渲染出来,点“预订”后乘车人列表可能还在加载。用 Selenium 写脚本时,最忌讳把整个过程当成一长串find_element().click()顺序执行,因为任何一步的网络抖动都会让真实页面比脚本预期晚半拍。
一个更稳妥的建模方式是把每个阶段看作状态机中的状态,进入下一个状态前必须确认当前状态的“稳定信号”。例如:
| 状态 | 常见 DOM 特征 | 适合的等待条件 |
|---|---|---|
| 首页/查询页加载 | 城市输入框可点击 | element_to_be_clickable |
| 城市候选面板出现 | 城市列表可见 | visibility_of_element_located |
| 车次表格加载完成 | 存在若干行数据 | 自定义until(lambda d: ...) |
| 乘车人列表加载完成 | 复选框可勾选 | element_to_be_clickable |
| 订单确认页 | 价格与车次信息可见 | text_to_be_present_in_element |
每个状态只认“信号”,不认固定秒数。这样页面快时脚本快,页面慢时脚本也不会误操作。
2.2 元素定位的四个层次和选择
selenium 中定位元素的方式很多,但在 12306 这类快速改版的站点上,我一般按这个优先级选择:
- 稳定的
id,例如fromStationText、toStationText; - 带语义的
CSS_SELECTOR,例如.btn72、#queryLeftTable tr; - 可读性好的
XPath,例如//span[text()='北京']; - 页面结构层级,尽可能不用,因为它一改版就失效。
还有两个细节容易被忽略。城市候选框里的可点击项通常不是 input,而是自定义渲染的div/span,直接send_keys不一定能触发联动;更常见的是先点击输入框,再用execute_script设置 value 并手动派发input事件。另一个细节是元素可能被弹窗或浮层遮挡,find_element能定位到,但click()会抛ElementClickInterceptedException,因此需要显式等待可点击状态。
2.3 显式等待是抢票脚本的第一层盾牌
很多人一上来就写time.sleep(3),这不是技术方案,是赌运气。Selenium 官方推荐的做法是WebDriverWait配合expected_conditions,把“等多久”和“等什么”交给浏览器来判断。下面是一个通用的等待函数:
from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By def wait_for(driver, locator: tuple, timeout: float = 10, state: str = "clickable"): wait = WebDriverWait(driver, timeout) if state == "clickable": return wait.until(EC.element_to_be_clickable(locator)) if state == "visible": return wait.until(EC.visibility_of_element_located(locator)) if state == "present": return wait.until(EC.presence_of_element_located(locator)) raise ValueError(f"unknown state: {state}")调用时传一个(By.ID, "fromStationText")形式的元组,避免在多个条件之间反复拼接字符串。element_to_be_clickable会同时检查元素存在、可见且未被禁用,三层判断一次完成,适合抢票流程里的绝大多数点击场景。presence_of_element_located只检查 DOM 是否存在,适合那些不需要用户交互的隐藏字段;visibility_of_element_located则适合等待异步渲染出来的列表。
2.4 登录态与验证码:不要指望一套脚本全自动
12306 的登录流程包含图片验证码、滑块验证甚至短信验证,纯 Selenium 脚本很难做到全自动,也没有必要。工程上常见的做法是“人机协作”:用脚本打开浏览器,人工完成验证码登录,然后脚本把登录态保存下来供后续使用。这样既绕开了验证码识别的不确定性,又不会因为频繁登录触发风控。
保存和恢复 Cookie 是 Selenium 会话保持的基础操作:
import json # 登录成功后保存 cookies = driver.get_cookies() with open("cookies.json", "w", encoding="utf-8") as f: json.dump(cookies, f) # 新会话恢复 driver.get("https://kyfw.12306.cn") with open("cookies.json", "r", encoding="utf-8") as f: for cookie in json.load(f): driver.add_cookie(cookie) driver.refresh()注意add_cookie前必须先访问一次目标域名,否则浏览器会因为“没有域名上下文”而拒绝写入。保存时不要过滤、不要只取name和value,expiry、path、domain这些字段直接影响恢复后的会话完整性。Cookie 本身有时效,过期后页面会跳回登录页,后续代码要能识别这种状态并停下等待人工处理。
3. 最小可用余票查询脚本:Selenium Python 从零到出结果
3.1 环境准备:Selenium 安装和驱动匹配
先装 Python 依赖。Python 版本建议 3.8 以上,pip装 Selenium:
python -m pip install seleniumSelenium 4.6 以上自带 Selenium Manager,会自动匹配并下载 ChromeDriver,省去了手动处理驱动版本的步骤;如果还在用 Selenium 3,就需要手动下载与浏览器版本匹配的chromedriver,并放到PATH中。验证安装是否成功,一行代码即可:
python -c "from selenium import webdriver; webdriver.Chrome()"如果这行命令能弹出一个空白浏览器窗口,说明驱动链接没有问题;如果报SessionNotCreatedException,基本是浏览器和驱动版本不一致,先用chrome://version查看浏览器版本,再去下载对应主版本号的驱动。
3.2 查询页面最小脚本:填车站、选日期、触发查询
下面的脚本以查询页https://kyfw.12306.cn/otn/leftTicket/init为目标,核心是把“填写出发到达城市”和“设置日期”做成可复用函数。12306 的车站输入框是自定义控件,直接send_keys后不会自动填充候选面板,需要用 JavaScript 设置值并派发事件:
import time from selenium import webdriver from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC def set_station(driver, input_id: str, station_name: str): input_el = driver.find_element(By.ID, input_id) input_el.click() input_el.clear() # 触发搜索候选列表 driver.execute_script( "arguments[0].value = arguments[1];" "arguments[0].dispatchEvent(new Event('input', {bubbles: true}));", input_el, station_name ) time.sleep(1) # 在候选列表里精确点击目标车站 option = WebDriverWait(driver, 5).until( EC.element_to_be_clickable((By.XPATH, f"//span[text()='{station_name}']")) ) option.click() driver = webdriver.Chrome() wait = WebDriverWait(driver, 10) driver.get("https://kyfw.12306.cn/otn/leftTicket/init") set_station(driver, "fromStationText", "北京") set_station(driver, "toStationText", "上海") date_input = wait.until(EC.element_to_be_clickable((By.ID, "train_date"))) driver.execute_script("arguments[0].value = '2024-01-01';", date_input) search_btn = wait.until(EC.element_to_be_clickable((By.ID, "query_ticket"))) search_btn.click()这段代码里最重要的不是点击查询,而是execute_script派发input事件。很多前端框架监听的是 JavaScript 事件而不是原生输入,直接send_keys后虽然输入框有字,但框架内部的状态还是空的,点击查询自然查不出结果。station_name要严格匹配车站全称,例如“北京”“北京南”是不同对象,不能写简称;如果有多个城市名匹配,XPath 里的text()精确定位会失效,需要改成包含匹配。
3.3 从车次表格提取余票信息并做简单过滤
查询按钮点击后,车次表格异步加载。此时不能立刻解析表格,最好等列表行数大于 1:
wait.until(lambda d: len(d.find_elements(By.CSS_SELECTOR, "#queryLeftTable tr")) > 1) rows = driver.find_elements(By.CSS_SELECTOR, "#queryLeftTable tr") matched = [] for row in rows: cells = row.find_elements(By.TAG_NAME, "td") if len(cells) < 5: continue train_no = cells[1].text.strip() second_class = cells[4].text.strip() if train_no.startswith("G1") and second_class in ("有", "10张"): matched.append((train_no, second_class)) for train_no, second_class in matched: print(f"{train_no} 二等座余票: {second_class}")列索引不是固定的,12306 改版后车次列、余票列的顺序可能变化,因此这里把它做成一个可配置项更合理。cells[1]是车次号,cells[4]是二等座余票文本,实际使用时要先手动打开页面检查“二等座”在第几列,再填到代码里。更稳定的做法是遍历th表头,定位包含“二等座”的表头列索引,再用该索引去取同行的td,这样即便拖动列顺序也不会错。
另外,表格中有时会出现隐藏的空行,row.find_elements(By.TAG_NAME, "td")返回空列表,因此代码里加了len(cells) < 5的过滤。余票文本可能是“有”“无”“10张”或“18张”,判断时不要用!= "无",因为空字符串也会混进来。
4. 抢票脚本的容错工程:元素定位、Cookie 与会话保持
4.1 元素不稳定时的自愈机制
写一点就能跑的脚本容易,写能在页面改版时多撑几天的脚本难。最常见的错误是StaleElementReferenceException:第一次定位到元素后,页面局部刷新,旧引用就失效了,再调用click()或.text就报错。
解决方案是重新查找并重试。我一般把查找动作封装成一个带重试的函数:
import time from selenium.webdriver.common.by import By from selenium.common.exceptions import StaleElementReferenceException def find_with_retry(driver, by, value, retries: int = 3): for attempt in range(retries): try: return driver.find_element(by, value) except StaleElementReferenceException: if attempt == retries - 1: raise time.sleep(0.3)使用时要清楚这只是一个兜底,不能替代显式等待。更合理的顺序是先WebDriverWait等元素可交互,再在随后的操作中捕获StaleElementReferenceException并重走“定位-操作”这个原子步骤。不要在一个元素引用上反复重试,那个引用已经失效,重试也没有意义。
4.2 Cookie 与会话保持的边界
第 2 章里保存和恢复 Cookie 的做法已经覆盖基础场景,但生产级的会话保持还需要处理三个细节:
- 恢复 Cookie 后必须刷新页面,否则
driver.get_cookies()里虽然能看到 Cookie,但页面上下文还没有带上; - 某些 Cookie 标记为 session cookie,不写
expiry,保存后如果浏览器退出,它就没了,需要重新登录; - 登录失效后页面通常会跳转到某个公共登录页,可以通过
driver.current_url判断是否包含login关键字,一旦发现就停止自动操作并告警。
如果脚本需要长时间运行,建议每次成功查询后顺手把 Cookie 重新写回文件,避免中途过期后只能白等。
4.3 频率控制与并发:不是浏览器开得越多越好
很多人直觉上觉得抢票快就是并发多开几十个浏览器窗口,但真实站点风控看的是 IP、浏览器指纹、操作节奏和行为特征。多个 Chrome 实例如果共用同一个代理 IP,高频请求会更容易触发滑块验证,反而把自己锁在外面。我一般会控制单浏览器实例的轮询频率,并把请求间隔做成随机抖动:
import random import time def human_pause(min_seconds: float = 1.0, max_seconds: float = 2.5): time.sleep(random.uniform(min_seconds, max_seconds))随机间隔不是为了“拟人”,而是避免固定的sleep(1)形成可被识别的节律。要并发,优先考虑 Selenium Grid 或容器化方案,把浏览器分散到多个节点,但每个节点的间隔仍然要控制。这里的关键不是代码能开多少线程,而是站点允许你在多长时间内完成多步操作。
4.4 日志与截图:脚本出问题最怕不知道在哪一步
Selenium 脚本报错时,只打印堆栈往往不够,因为真正的问题在页面状态,不在 Python 代码。常见的做法是在主流程里包一层异常捕获,把页面源代码和截图一起落盘:
import logging logging.basicConfig(filename="ticket.log", level=logging.INFO) def safe_step(driver, step_name: str, func): try: logging.info("start: %s", step_name) return func() except Exception: ts = int(time.time()) driver.save_screenshot(f"fail_{step_name}_{ts}.png") with open(f"fail_{step_name}_{ts}.html", "w", encoding="utf-8") as f: f.write(driver.page_source) logging.exception("failed at %s", step_name) raisefail_*.png用于肉眼确认页面卡在哪,fail_*.html用于离线分析 DOM 结构。两者配合才能判断是元素选择器失效、Cookie 过期、还是弹窗遮挡。下表是几个高频异常和最短应对路径:
| 异常 | 触发条件 | 对策 |
|---|---|---|
NoSuchElementException | 元素还没渲染或选择器失效 | 先加显式等待;无效就检查页面快照 |
StaleElementReferenceException | 页面刷新后旧引用失效 | 重新查找并重试 |
TimeoutException | 等待条件超时 | 检查当前 URL 和页面提示 |
ElementClickInterceptedException | 弹窗或浮层遮挡按钮 | 等待弹窗消失,或先关闭浮层 |
这四种异常覆盖了绝大多数脚本挂掉的情况。日志里除了记录异常,还要记录当前 URL 和最近一次操作名,方便回放。
5. 从“能跑”到“可交付”:Selenium 项目资料包的落地规范
5.1 资料包目录结构和依赖锁
如果要把脚本交出去,或者隔几个月再回来看,代码本身只是一半,另一半是依赖和配置。我一般会按这个结构组织:
project/ ├── config.yaml ├── requirements.txt ├── main.py ├── core/ │ ├── driver_factory.py │ ├── wait_utils.py │ └── ticket_query.py ├── logs/ └── README.mdrequirements.txt里不要只写包名,至少固定主版本号:
selenium>=4.10 pyyaml>=6.0config.yaml存放车站名、可配置列索引、查询间隔、Cookie 文件路径等,不要把这些参数散落在代码里。这样别人拿到资料包后,改配置就能跑,不需要读懂每一个函数。
5.2 下载文件时的下一步判断
抢票脚本不一定只解析页面,也可能需要导出结果报表或保存车次截图。Selenium 点击下载按钮后,文件是浏览器进程在下载,Python 代码不会自动等它完成。“selenium 怎样使文件下载完成之后才进行下一步”是高频搜索问题,标准做法是配置浏览器默认下载目录,然后轮询目录里是否出现目标文件,并判断.crdownload临时后缀是否消失:
import os import time def wait_for_download(download_dir: str, filename: str, timeout: float = 30): file_path = os.path.join(download_dir, filename) deadline = time.time() + timeout while time.time() < deadline: if os.path.exists(file_path) and not os.path.exists(file_path + ".crdownload"): return True time.sleep(0.5) return Falsedownload.default_directory需要在创建浏览器时设置:
options = webdriver.ChromeOptions() prefs = { "download.default_directory": "/tmp/ticket_exports", "download.prompt_for_download": False, } options.add_experimental_option("prefs", prefs)如果忽略了.crdownload后缀,代码可能会在一个写了一半的文件上做解析,结果读到残缺数据。设置download.prompt_for_download为False可以避免弹窗卡住自动化流程。
5.3 把脚本改造成可复用的自动化框架
最后一个具体技巧是把浏览器启动封装成上下文管理器,并让页面操作函数只依赖传入的driver实例。这样同一个脚本既能跑查询、又能跑测试,还能和pytest结合做日常回归:
from contextlib import contextmanager from selenium import webdriver @contextmanager def create_driver(headless: bool = True): options = webdriver.ChromeOptions() if headless: options.add_argument("--headless=new") driver = webdriver.Chrome(options=options) try: yield driver finally: driver.quit() with create_driver(headless=False) as driver: query = TicketQuery(driver) query.search("北京", "上海", "2024-01-01")把 driver 创建放在finally里,能保证脚本每次异常退出时浏览器进程都会被回收,不会留下几十个残留 Chrome。所有页面对象接受driver参数后,测试代码里替换成pytest的 fixture 就是顺手的事,这也是 Selenium 自动化测试框架沉淀资料包时最值得做的一步。
本文还有配套的精品资源,点击获取