ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Selenium抢票脚本进阶:DOM状态轮询与多线程并发实战

Selenium抢票脚本进阶:DOM状态轮询与多线程并发实战 简介针对大麦网、淘票票、缤玩岛等多个主流平台的演唱会抢票脚本v3.0主要面向有抢票需求并具备一定Python配置能力的用户。脚本基于Appium模拟手机端人工操作自动执行点击、滑动、输入等动作同时借助Selenium分析不同平台的页面结构实现多平台兼容支持多账户批量抢票、代理IP池动态切换以及APScheduler定时预约场次能明显提升手动抢票的成功率。压缩包共19个文件包含Python主程序、JSON账户配置、HTML辅助页面、Chrome驱动以及工程配置文件等整体体积约13.58MB目录结构清晰便于部署和二次修改。已有3400人浏览学习适合希望搭建个人抢票工具或研究自动化操作流程的开发者。读者可获得完整可运行的脚本源码、多平台适配逻辑、账户批量配置模板与驱动文件按说明填写账号和场次即可使用也可在此基础上自定义抢票策略。1. 抢票脚本没那么玄一场与页面渲染速度的竞赛做了三年爬虫回头看演唱会抢票脚本 V3.0最值得拆的不是它能抢到票而是把浏览器自动化、并发调度、IP 池管理三个老技术组合出了新问题。和 12306 抢票脚本那种硬碰硬拼验证码不同大麦、淘票票、缤玩岛这类演出票务的瓶颈在放票瞬间的页面状态切换按钮在 DOM 里能不能及时变成可点击、请求能不能在 200ms 内送达、账号会不会因为高频操作被风控。这套脚本用 Selenium 模拟手机端人工操作配合多线程多账户和定时预约本质是浏览器自动化插件思路的本地软件。适合想研究 WebDriver 状态机、多线程同步和限流对策的开发者也适合被黄牛挤掉的普通用户。2. Selenium 版本锁 4.10 与多平台 DOM 适配2.1 为什么依赖被锁在 4.10.0 以下下载的压缩包里 requirements 依赖写的是「selenium 4.10.0 以下」这不是随手写的而是踩出来的坑。WebDriver 的 API 在 4.x 里经历了大规模清理旧版find_element_by_id、find_element_by_xpath这类写法从 deprecation warning 一路走到直接移除很多抢票脚本的核心逻辑还停留在老 API 上。升到 4.10 以上版本后老代码不会编译报错但运行时会抛NoSuchElementException或者隐式等待失效开票瞬间元素定位逻辑直接断掉。安装时建议直接锁死范围避免哪天升级把行为改掉pip install selenium4.0,4.10新版本对移动端模拟的默认策略也有调整。抢演出票不能只改 UA平台页面在检测到 WebDriver 特征后会强制跳回 PC 版页面而 PC 版的购票按钮可能是浮层弹窗也可能是 iframe 内嵌定位路径完全不同。锁住依赖版本是保证页面结构探活结果可复现的前提。项目里塞了 chromedriver.exe 和 chromedriver2.exe 两个驱动文件也是同理不同 Chrome 版本需要对应驱动二进制老驱动配老 Selenium 才不容易出现 session not created 这类启动异常。2.2 用 mobileEmulation 拿到移动版 DOM抢票脚本的核心不是「速度快」而是「加载到正确的页面版本」。PC 端页面为了承载票价楼层与选座交互按钮状态变化复杂移动端页面则简单得多多数平台在放票前只提供一个置灰的「立即购买」按钮状态翻转一次就进入下单确认页。以下是初始化移动端上下文的常规写法from selenium import webdriver from selenium.webdriver.chrome.options import Options import json with open(config.json, r, encodingutf-8) as f: cfg json.load(f) opts Options() opts.add_experimental_option(mobileEmulation, { deviceName: iPhone 12 Pro }) opts.add_argument(--disable-blink-featuresAutomationControlled) driver webdriver.Chrome(optionsopts) driver.set_window_size(390, 844)deviceName告诉 Chrome DevTools 模拟指定设备的 UA 和视口平台返回的是移动版 DOM。disable-blink-features用来去掉navigator.webdriver标记虽然不能完全伪装但能降低被第一时间识别的概率。set_window_size的 390x844 是 iPhone 12 Pro 的逻辑分辨率按目标机型写即可。有一个经常被忽略的点mobileEmulation只是让页面按移动版渲染不等于真正的手机 WebDriver。部分平台会读取触控事件特征比如是否存在ontouchstart监听、是否能拿到 touch 坐标序列。Selenium 4.10 以下版本可以通过 CDP 命令开启触摸模拟但注入时机要在页面加载之前driver.execute_cdp_cmd(Emulation.setTouchEmulationEnabled, { enabled: True })这段代码的作用是让浏览器产生真实的 touch 事件序列配合移动版 DOM 还原真机手势。先开启触摸模拟再访问入口页页面上才会出现依赖滑动操作的选票控件。如果顺序反了滑动控件会直接消失下单流程走不通。项目说明里提到的 Appium 方案也值得说一句。Appium 连的是真正的 Android 模拟器触摸事件走 adb 注入DOM 特征与真机几乎一致抗检测能力比 Selenium 的 CDP 模拟强。代价是启动慢每个账户要多占 1GB 以上内存所以项目里把 Appium 放在 Selenium 之后作为互补而不是替代。2.3 三个平台的定位差异与统一封装不同平台的 App 壳页面结构差别很大需要先做页面分析。通常做法是手动登录一次把「场次列表、票档选择、立即购买按钮」三处 DOM 保存成 HTML 快照再写定位表达式。下表是项目里常见结构平台关键按钮特征常见坑大麦网class 含 buy-btndisabled 控制可点状态放票前按钮会整体重渲染旧节点被替换淘票票a.buy-link 包裹href 在开票前为空点击后要等 1-2 秒才进入选座页缤玩岛#J_BuyNow老式 ID页面加载慢按钮状态由外层 div 的 class 控制需要二级判断统一封装的核心是给每个平台写一个独立的定位路径列表而不是写一长串 if else。配置化写法如下PLATFORM_PATHS { damai: [ (xpath, //div[contains(class,buy-btn)]), (xpath, //div[classbuy-btn-wrapper]//span) ], taopiaopiao: [ (css, a.buy-link), (css, .go-buy) ], binwandao: [ (id, J_BuyNow), (css, #J_BuyNow em) ] } def click_first_available(driver, platform): for strategy, path in PLATFORM_PATHS[platform]: try: el driver.find_element(strategy, path) driver.execute_script(arguments[0].click();, el) return True except Exception: continue return False这里的关键是execute_script方式触发 click可以绕过元素被固定遮罩挡住的情况。selenium 原生 click 要求元素在视口内且至少有一个点可点击而开票瞬间页面会有 toast 提示覆盖按钮原生 click 会抛ElementClickInterceptedException。用 JS 直接调用 DOM 的 click 事件不检查可见性抢到的概率高很多。代价是它不能触发某些依赖鼠标坐标的事件所以每个平台都要准备退路路径。2.4 轮询按钮状态比固定 sleep 靠谱大多数抢票脚本写成固定sleep(1)循环然后无脑点击。这在售票压力小的时候能跑通但真正抢票时按钮状态翻转后几百毫秒内票就被锁了。正确姿势是高频轮询按钮是否从 disabled 变成 enabledfrom selenium.webdriver.support.ui import WebDriverWait def wait_until_buyable(driver, timeout20, poll0.05): def _enabled(d): try: el d.find_element(css selector, div.buy-btn) class_name el.get_attribute(class) # 注意大麦的 disabled 是 class 的一部分不是 HTML 属性 return disabled not in class_name and disable not in class_name except Exception: return False return WebDriverWait(driver, timeout, poll_frequencypoll).until(_enabled)poll_frequency0.05表示每 50 毫秒查询一次 DOM 状态这是本地脚本最合理的轮询粒度。再快没有意义因为 DOM 查询往返也要十几毫秒而且太快会拖累主线程。WebDriverWait.until会把当前 driver 传进条件函数所以条件函数里直接用d操作即可。调用时注意顺序if wait_until_buyable(driver): click_first_available(driver, cfg[target_platform])放票瞬间按钮如果有多个例如「立即购买」和「去选座」同时出现click_first_available里的路径顺序就决定了成功率。把主按钮放第一位备选放后面是这套脚本调优的重心之一。3. 多账户并发与 APScheduler 定时预约3.1 config.json 里该放什么项目里的 config.json 承担了账户、场次、策略三个职责。抢票脚本和普通爬虫不同它的并发对象是「会话」不是「请求」。多账户的登录状态、当前页面 URL、点击进度都要隔离所以配置结构必须和线程模型一一对应。以下是一个可用的最小结构{ accounts: [ { username: test_001, password: pwd_123, platform: damai, session: 20250308, ticket_price: 580 } ], show: { open_time: 2025-03-08 10:00:00, advance_seconds: 3, retry_interval: 0.15, max_retry: 30 } }username/password是登录凭证platform决定走哪套 DOM 定位逻辑session字段对应场次批次有的演出分下午场和晚场同一个账号要分别处理。show.open_time是平台开票时间advance_seconds表示提前多少秒进入等待循环这个值设太长会在开票前反复刷新触发限流设太短页面可能还没加载完。各字段的建议值如下字段类型作用建议值open_timestring开票时间平台公告时间advance_secondsint提前进入轮询的秒数3retry_intervalfloat点击后的重试间隔秒0.15max_retryint最大重试次数30配置里的字段不应该在代码里写死默认值所有分支读取同一份 config.json便于切换平台时只改文件不碰代码。3.2 用 Barrier 让多账户在同一帧起跑多账户抢票有个常见误区写一个 for 循环依次为每个账户启动线程认为这就是并发。实际上线程创建有开销账户 A 的线程先跑起来账户 B 还在启动 driver等 B 就绪时 A 已经发出多次请求两个账户的请求时间差可能超过 500ms。放票瞬间这 500ms 就是有票和无票的差别。这里没选 asyncio 也是同样的原因Selenium 的调用是阻塞式 IO用异步仍然要包线程池反而增加复杂度。项目里用的threading.Barrier就是为了解决「同时起跑」的问题它会让所有线程阻塞在同一个屏障点直到参与方全部到达才同时放行import threading from datetime import datetime import time barrier threading.Barrier(len(cfg[accounts])) def worker(account): driver init_driver(account) try: login_and_goto(driver, account) # 所有账户都到达这里之后才会一起往下走 barrier.wait(timeout15) except threading.BrokenBarrierError: return open_dt datetime.strptime(cfg[show][open_time], %Y-%m-%d %H:%M:%S) while datetime.now() open_dt: time.sleep(0.01) for _ in range(cfg[show][max_retry]): if click_checkout(driver, account): break time.sleep(cfg[show][retry_interval])Barrier.wait会阻塞到所有账户线程到达。timeout15的意义是防止某个账户登录异常导致其他线程无限等待。如果超时Barrier 会进入 broken 状态其他线程抛出BrokenBarrierError由 try 捕获后直接终止该线程。注意不能把barrier.wait放在登录前因为登录耗时差异大先登录完的线程在屏障前空等没问题放在登录前会让未登录的请求先发出去。开票前的while循环用sleep(0.01)是忙等待目的是让所有线程对齐在同一个系统时钟附近然后进入轮询。实际效果比 Barrier 放行后立即抢要好因为 driver 初始化完成后页面加载仍然有几十毫秒的差异。3.3 APScheduler 的 CronTrigger 参数与 misfire有人会问都用 Barrier 对齐线程了为什么还需要 APScheduler因为在真实场景里脚本不会一直开着往往是提前一小时启动、在开票前几分钟进入「待命」状态。定时任务负责把「待命」这个动作精确触发到秒级。APScheduler 在项目里的典型用法是 BlockingScheduler 加 CronTriggerfrom apscheduler.schedulers.blocking import BlockingScheduler from apscheduler.triggers.cron import CronTrigger def start_battle(): barrier threading.Barrier(len(cfg[accounts])) threads [threading.Thread(targetworker, args(acc,)) for acc in cfg[accounts]] for t in threads: t.start() for t in threads: t.join() scheduler BlockingScheduler(timezoneAsia/Shanghai) scheduler.add_job( start_battle, CronTrigger(hour10, minute0, second0), misfire_grace_time5, coalesceTrue, ) scheduler.start()CronTrigger 支持到秒级调度这在 APScheduler 里算比较特殊的能力因为系统 crontab 通常只到分钟。second0表示整分触发。misfire_grace_time5的含义是如果任务因为主线程阻塞或系统休眠导致没在 10:00:00 执行只要在 5 秒内补上就算数超过 5 秒直接跳过。对抢票脚本来说开票后第 6 秒才去抢基本没意义跳过比误操作好。coalesceTrue是防止积压如果过去一段时间内有多条同样的任务被跳过只会补执行一次。最后注意时区CronTrigger默认使用调度器的 timezone 字段这里显式声明为Asia/Shanghai。如果服务器是 UTC 时区不写时区就会在 16:00 触发而不是 10:00抢票脚本直接变废脚本。注意BlockingScheduler 会占用主线程脚本里它之后的代码不会被执行所有抢票逻辑必须放在任务函数内部。4. IP 池轮换与限流规避4.1 高频限流为什么是首要敌人抢票脚本的瓶颈不全在按钮点击还有请求频控。一个账号在 10 秒内发出几十次页面刷新请求平台侧的风控模型会把这个会话标记为「异常流量」。触发风控的表现不只是滑块验证码很多时候是页面正常返回但下单接口返回「操作频繁」或「票已售罄」。这里要区分两种限流第一种针对账号本身刷频过多会短期禁用该账号的下单接口第二种针对 IP 维度平台记录了来自同一 IP 的所有请求次数超过阈值后对该 IP 上的所有账号做延迟处理。第一种只能靠控制刷频节奏解决第二种才需要 IP 池。项目里提到的「支持 IP 池用 Scrapy 和 ProxyPool 实现动态切换」指的就是第二种。ProxyPool 是爬虫生态里常用的 IP 池管理组件先把一批可用 IP 存入 Redis再统一验证连通性和可用性对外提供取 IP 的接口。和抢票脚本结合时不需要手动逐个配而是把 IP 池的接口地址写进配置由脚本启动时拉取一次后面按需补充。4.2 把 IP 池接进抢票循环IP 池组件一般自带抓取、验证、存储三个模块启动后会维护一个 Redis 集合里面是已经验证可用的 IP。脚本侧要做的事很简单从这些 IP 里挑一个注入到 WebDriver 的启动参数里。这里有一个关键决策WebDriver 和 IP 是绑定的换 IP 必须重建 driver成本很高。所以脚本的常规策略是「低频验证、按需重建」而不是每次请求都换import requests import queue ip_pool queue.Queue() def load_pool(): # 本地 IP 池服务地址默认 5010 端口/get_all 返回已验证 IP 列表 resp requests.get(http://127.0.0.1:5010/get_all, timeout2) for item in resp.json(): ip_pool.put(item[ip]) def take_ip(): if ip_pool.empty(): load_pool() try: return ip_pool.get_nowait() except queue.Empty: # 没有可用 IP 时退化为直连不要卡死主流程 return None def init_driver_with_ip(account): ip take_ip() opts Options() if ip: # 将 ip 绑定到启动参数不同版本的 driver 传参位置不同 opts.add_argument(f--fixed-ip{ip}) # 示意写法实际按 driver 版本决定 return webdriver.Chrome(optionsopts)注意上面--fixed-ip是示意写法实际不同 ChromeDriver 版本的参数不一致常见有启动参数和 DesiredCapabilities 两条路网上资料很多照着对应版本写就行。重要的是设计思路惰性加载 IP、队列弹 IP、没有可用节点就直连降级保证主流程不被 IP 池服务拖死。在抢票循环里的判断是如果连续 3 次点击后页面 URL 带有 captcha 或 verify 关键字就认为当前 IP 触发风控记录日志并重建 driver。重建后需要重新登录整个过程约 2 到 3 秒所以只能作为兜底不能频繁触发。4.3 多账号场景下的 IP 分配策略多账户并发时IP 分配有个常见坑不要按账户轮流取 IP而要按「会话」分配。一个账户一次抢票过程占用一个 IP下次抢票时再换。原因是登录状态和 IP 存在绑定中途换 IP 会导致部分平台强制重新登录反而拉低速度。分配代码for acc in cfg[accounts]: acc[assigned_ip] take_ip()如果账户数多于 IP 池容量就只给最先启动的 N 个账户分配 IP其余用直连。直连账户的请求频率要调低避免整个出口 IP 被平台记录。这里的核心参数是 retry_interval按场景区分场景retry_interval切换 IP 频率单个账号独占 IP0.1s每场一次直连账号0.3s不切换同一 IP 多账号0.5s 以上出现风控才切同一 IP 下挂多个账户时全部账户的请求间隔都要加大否则等于替平台做关联分析。另外 IP 池验证时用的目标地址也值得调如果验证目标是抢手平台响应会偏慢IP 可能被误判为不可用。常见做法是让验证模块用高可用站点做连通性测试抢票脚本侧只关心「IP 能不能通」不关心目标站点响应。5. 日志、截图与抢票失败定位技巧5.1 抢票失败时先看这三样抢票脚本最讨厌的错误类型是「点击了但没反应」。此时先不要猜打开日志按顺序确认三件事登录态是否过期、按钮是否真的出现了、点击后 URL 是否变化。大多数脚本把日志打得太细反而看不出主流程。我一般只在四个节点记录日志进入页面、按钮状态变化、点击动作发出、下单请求完成import logging logging.basicConfig( levellogging.INFO, format%(asctime)s [%(threadName)s] %(levelname)s %(message)s ) log logging.getLogger(ticket) def click_checkout(driver, account): before driver.current_url log.info(click btn | account%s, account[username]) ok click_first_available(driver, account[platform]) log.info(click fired | result%s | url%s, ok, driver.current_url) return ok and driver.current_url ! before这里把current_url变化作为下单动作成功的判断依据。原因是「点击后仍在原页」几乎所有情况下都意味着失败要么按钮被禁用要么弹窗遮挡要么 JS 报错。URL 变化虽然不能证明锁单成功但能证明页面状态机推进了一步。5.2 失败时自动落盘现场只打日志不够。开票瞬间的 DOM 状态是唯一的破案线索错过就没有了。脚本里要写自动化落盘函数在 catch 到异常或检测到风控页面时把截图和 HTML 源码都存下来import time from pathlib import Path DEBUG_DIR Path(debug) def dump_page(driver, tag): DEBUG_DIR.mkdir(exist_okTrue) ts int(time.time() * 1000) driver.save_screenshot(str(DEBUG_DIR / f{tag}_{ts}.png)) with open(DEBUG_DIR / f{tag}_{ts}.html, w, encodingutf-8) as f: f.write(driver.page_source)存 HTML 比截图更有价值截图只能看到页面长什么样HTML 能告诉你是哪个元素挡住了按钮、disabled class 是什么时候加上的、平台有没有植入检测脚本。排查滑块问题时对比三张截图的时间戳能看到滑块出现的完整路径。5.3 几个容易被忽略的校验点第一个是本地时间与服务器时间偏差。脚本按datetime.now()对齐开票时间但本地时钟可能和平台服务器差出几秒。常见做法是提前用平台接口返回的 Date 响应头校准一次把偏差写进 config.json 的 time_offset 字段。第二个是按钮文案变化。大麦的按钮从「即将开抢」变「立即购买」通常意味着放票而这时 class 可能还没更新所以轮询条件里应该同时判断文本和 class。第三个是线程内不要共享 driver 实例两个线程共用一个 WebDriver 会互相重置上下文出现莫名其妙的 stale element每个线程自己初始化 driver结束时各自 quit。最后提一句支付环节脚本能帮你锁单但一般不能代付。锁单后通常有 15 到 30 分钟支付窗口最好把支付二维码截图推送到手机人工完成付款。这套 V3.0 的多平台适配、多账户并发、定时调度、IP 池轮换都是围绕「锁单」设计的真正的验证码对抗和支付确认并不在脚本能力范围内最后的付款动作建议放在人工侧避免触发平台的风控校验。本文还有配套的精品资源点击获取
返回列表