大麦抢票.user.js:从用户脚本到状态机的前端自动化实践
2026/9/15 17:56:59 网站建设 项目流程

简介:压缩包内共包含2个文件(1个txt说明文档与1个js脚本),整体仅9KB,内容紧凑。其中的“大麦抢票.user.js”是一款面向大麦网抢票场景的用户脚本,针对热门演出开售瞬间访问压力大、手动操作容易错过余票的问题,通过自动填写购票信息、模拟快速点击和定时刷新等操作,帮助用户提升抢票效率;同时需注意,使用此类脚本可能涉及平台规则与公平性风险,读者应了解并自行评估。另一份说明文档则对脚本与“仓库管理系统”标签的关联作出补充:后者通常指用于库存出入库、盘点、补货和实时监控的软件系统,其自动化、信息化思想与脚本的自动化抢票逻辑存在相通之处。资源适合对前端用户脚本开发、自动化操作或电商票务流程感兴趣的读者作为参考,已有81人学习。通过阅读说明文档和脚本源码,可以了解用户脚本的基本结构、常见自动化实现思路,以及如何将效率优化思想迁移到不同业务场景中。

1. 大麦抢票.user.js 解决的不是手速,而是把一串点击动作压缩成一次接口交互

热门演出开票那几秒,手动刷新的问题从来不是网速,而是链路太长:票档从“缺货”翻成“立即购买”的瞬间,你的眼睛要确认状态,鼠标要移动位置,点击之后还要等弹层、选份数、再点提交。这串动作叠加在一起,再快也有一秒以上的延迟。大麦抢票.user.js 是一个跑在 Tampermonkey 用户脚本管理器里的页面脚本,它的思路是把“查询库存、选中票档、提交订单”这套动作交给代码自动完成,抢票耗时从人手反应缩减为两次网络请求的往返时间。它不做任何超出普通浏览器操作范围的事情,不伪造请求,不绕过实名制,只是把你原本会在页面上手动做出的点击,变成由状态机触发的自动提交。适合已经能看懂 XHR 请求、熟悉浏览器控制台的前后端工程师阅读,跟着下文能自己搭一套可维护的用户脚本工程。

2. user.js 的加载时机与页面监听:先搞清脚本在哪个上下文里运行

2.1 元数据块有一个参数写错,脚本就会静默失效

Tampermonkey 识别脚本靠的是文件头部的注释块,这段注释不是普通说明,它决定了脚本能注入哪些页面、在页面生命周期的哪个阶段执行、可以用哪些跨域 API。拿使用 .user.js 后缀的脚本来说,下面的元数据配置是抢票脚本最常见的起始模板:

// ==UserScript== // @name 大麦抢票.user.js // @namespace local.damai.reader // @version 0.1.0 // @match https://detail.damai.cn/* // @match https://piao.damai.cn/* // @run-at document-start // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @connect mtop.damai.cn // @connect *.damai.cn // ==/UserScript== (function (global) { 'use strict'; console.log('[damai-ticket] 脚本挂载完成,等待页面投递结构'); })(window);

代码里需要重点看的是 @run-at 和 @grant 这一对配置。@run-at 用 document-start,意味着浏览器还在解析 HTML 时脚本就开始执行,目的是在票档区域的 DOM 还没被前端框架重绘之前抢先挂上监听;如果换成 document-idle,脚本要等 onload 走完才跑,开票前几百毫秒就浪费掉了。@grant 声明 GM_xmlhttpRequest,这是跨域请求能力的关键,页面原生的 fetch 受 CORS 限制,无法直接访问 mtop 网关下的接口,只有扩展授权的 GM_xmlhttpRequest 能带着当前 Cookie 跨域发请求。@connect 则是扩展侧的域名白名单,这里声明 mtop.damai.cn 和 damai.cn 域直接允许访问,不声明会返回 403 或者直接调度失败。

元数据指令作用本次脚本的取值
@run-at脚本注入时机document-start,抢时间挂监听
@match生效页面 URL 范围detail 与 piao 两个域名同时覆盖
@grant额外 API 授权GM_xmlhttpRequest 加本地存储
@connect跨域请求白名单mtop.damai.cn 主网关域

新手最容易踩的坑是 @run-at 设置了 document-start 后立刻调用 document.querySelector。这个阶段 DOM 还没构建完成,选择器返回 null,脚本后续逻辑直接断掉。所以我一般在脚本开头打印一行挂载日志,先用它确认抵达时机,再去写具体的 DOM 逻辑。

2.2 票档查询接口怎么找:打开 Network 面板看一次 XHR 就够

拿到脚本骨架之后,下一个问题是大麦页面上的票档数据从哪里来。不需要逆向前端代码,打开无痕窗口进入目标场次页面,按 F12 打开 DevTools,切到 Network 面板并筛选 XHR 与 Fetch,手动刷新一次页面或者点一次票价档位,就能看到所有异步请求。票档列表的数据往往在某个 mtop 开头的请求里,负载和响应里的字段包括场次 ID、票价档 ID、库存数量等。

在用户脚本里组装请求时,我一般会优先读取页面注入的全局状态,而不是把 itemId 硬编码写在脚本里。大麦这类服务端渲染页面会把初始数据放在 window 全局变量中,脚本拿到后直接用,这样每次打开不同场次都能自动适配。

// 从页面全局状态里取 itemId 和 skuId // 不同页面暴露的名字可能有差异,要实际打印确认 function buildQueryPayload() { const state = global.__INITIAL_STATE__ || {}; const detailData = state.detailData || state.projectInfo || {}; return { itemId: detailData.id, skuId: detailData.skuList && detailData.skuList[0]?.id, ...(detailData.performId ? { performId: detailData.performId } : {}) }; }

代码里做了三层兜底:先读INITIAL_STATE,拿不到再读 projectInfo,最后才拼请求参数。这样做的原因是前端项目不定期改版时,全局变量的字段不一定固定,多一层 fallback 能减少脚本失效概率。如果取不到 itemId,脚本应该直接抛错终止,而不是发一个缺参数的请求浪费一轮配额。

2.3 用 MutationObserver 监听票档区域,替代每秒一次的 DOM 轮询

抢票脚本的核心等待场景是“票档从不可购变成可购”的瞬间。最原始的做法是 setInterval 里不断查询选择器,但代价是每轮都要遍历 DOM 子树,开票前页面本来就有大量渲染任务,再叠一串轮询容易造成卡顿。MutationObserver 是浏览器原生 API,可以在指定容器内监听子节点的新增、删除与文本变化,事件发生时再回调,不会空转。

const skuContainerSelector = '.sku-wrapper'; const skuContainer = document.querySelector(skuContainerSelector); function onSkuChange(mutations) { for (const mutation of mutations) { if (!mutation.target.textContent) continue; const text = mutation.target.textContent; if (/\b缺货\b|\b售罄\b|\b无票\b/.test(text)) return; } // 文案里已没有缺货相关字样,通知状态机检查票档 machine.emit('SKU_CHANGED'); } if (skuContainer) { const observer = new MutationObserver(onSkuChange); observer.observe(skuContainer, { childList: true, subtree: true, characterData: true }); }

代码里的 observer.observe 参数分别控制三种事件:childList 监听子节点插入或移除,subtree 让监听覆盖容器所有后代节点,characterData 捕获文本节点内容变化。回调里的 return 放在 for 循环内层,表示只要检测到缺货文案就整轮跳过,不再继续检查后续 mutations。

但需要承认,MutationObserver 只解决“页面已经渲染出新状态”的情况,接口先返回而 DOM 还没重绘的间隙,观察器拿不到结果。因此成熟的抢票脚本都是“接口轮询为主、DOM 监听为辅”的双通道。开票前 30 秒由接口轮询主导,DOM 监听负责捕捉页面自身交互触发的提前状态变化,两路事件最终统一汇入状态机,思路保持一致才能避免重复提交。

3. 抢票脚本的状态机与参数配置:轮询不是越短越好,而是越稳越好

3.1 六个状态的定义:抢票脚本本质上是一个有限状态自动机

抢票过程会经历未开售、开售瞬间、提交中、成功、失败、登录失效这些真实情况。如果把这些分支全部写进 if 嵌套里,可能改三次页面结构就彻底不可维护。用一个显式的状态机可以把所有路径约束清楚,脚本的每个回调只负责发事件,不直接去触发下单动作。

状态名进入条件合法后续状态关键动作
INIT脚本加载完成WATCHING读取配置与历史日志
WATCHING已注册轮询与监听SUBMITTING / WATCHING等票档变化,按节奏查询
SUBMITTING发现票档可购SUCCESS / FAILED / RE_LOGIN发订单请求,防重入
SUCCESS订单返回成功终态记录订单号并停止轮询
FAILED提交失败但可重试WATCHING / SUBMITTING按退避策略决定下一步
RE_LOGIN登录状态失效INIT停止轮询,提示人工处理

WATCHING 是核心状态,它的存在让轮询从“不受控的循环”变成“有节奏的等待”。代码实现时,用一张转移表约束状态跳转,避免任意赋值:

const machine = { current: 'INIT', transitions: { INIT: ['WATCHING'], WATCHING: ['SUBMITTING', 'WATCHING'], SUBMITTING: ['SUCCESS', 'FAILED', 'RE_LOGIN'] }, emit(event) { const nextStates = this.transitions[this.current]; if (nextStates && nextStates.includes(event)) { this.current = event; return true; } console.warn('[damai-ticket] 非法状态转移:', this.current, '->', event); return false; } };

转移表里只允许 WATCHING 进 SUBMITTING,一旦进入 SUBMITTING 状态,任何回调再触发 submitOrder 都会被忽略。所有页面事件、接口返回最终只走到 emit 这一个入口,从架构上避开了重复下单的可能。

3.2 轮询间隔、超时与重试上限:参数之间是相互制约的关系

轮询间隔设得越短,发现票档变化越快,但单位时间请求量会线性上升,触发网关限流和验证码的概率同样上升。这里没有理想值,只有取舍值。我常用的一组起始参数是这样的:

const pollConfig = { baseInterval: 600, maxInterval: 3000, maxAttempts: 40, timeout: 2500 }; let lastErrorAt = 0; function calculateDelay() { const failureWindow = Date.now() - lastErrorAt < 10000 ? 1 : 0; const delay = pollConfig.baseInterval * Math.pow(1.5, failureWindow); return Math.min(Math.round(delay), pollConfig.maxInterval); }

calculateDelay 的逻辑是:最近 10 秒内发生过失败,就在基础间隔上按 1.5 倍指数放大,10 秒无恙则回到 600ms 基准。baseInterval 不选 200ms 的考虑是浏览器事件队列的处理能力有限,间隔过短会导致前一个请求还没回调,后一个请求已经排队,整体延迟反而上升。

maxAttempts 设 40 意味着开票后最多轮询 40 轮,大约 24 秒的有效等待窗口,超过后自动停止。这个参数的目的是防止票已售罄后脚本还继续空转烧请求配额。timeout 是单次请求超时,超过 2.5 秒按失败处理并进入退避路径,保证某一轮卡住时不会让整个队列停下来。

3.3 返回码归一化:把“售罄”“无票”“参数错误”映射到同一种语义

后端对“没票”的表述在字段层面并不统一,data 可能为空对象,errorCode 可能是数字也可能是一串字符,message 文案更是随时可改。脚本里必须先把返回值转换为统一的业务状态,后面的判断才干净。下面是归一化函数的骨架:

function normalizeSkuResult(payload) { const msg = (payload.message || '').toLowerCase(); if (payload.errorCode === 'SUCCESS' || payload.code === 200) { if (payload.data && payload.data.skuCount > 0) { return 'AVAILABLE'; } } if (msg.includes('售罄') || msg.includes('无票') || msg.includes('缺货')) { return 'SOLD_OUT'; } if (payload.errorCode === 'FAIL_SYS_TOKEN_EMPTY' || payload.errorCode === 'FAIL_SYS_USER_NOT_LOGIN') { return 'RE_LOGIN'; } return 'RETRYABLE_ERROR'; }

归一化的重点是把“无法确认是否可售”和“确定无票”区分开。SOLD_OUT 进入 WATCHING 的下一次轮询等下一次机会,RETRYABLE_ERROR 才进入退避重试路径。RE_LOGIN 则要立即停掉所有轮询,继续发请求只会全部失败,还可能在网关侧留下异常访问特征。

提示:签名参数、token 刷新、风控策略这些由服务端下发的内容,脚本里直接当不透明值透传,不要去逆推生成规则。改动签名只会让请求被整体拦截,对抢票结果没有任何正向帮助。

4. 大麦抢票.user.js 的跨域请求与防重入:四个必须守住的边界

4.1 页面 fetch 与 GM_xmlhttpRequest:跨域能力的差异

大麦页面与接口不在同一域下,页面原生 fetch 发请求会触发 CORS 预检,出不来结果。Tampermonkey 提供的 GM_xmlhttpRequest 跑在扩展上下文里,不遵循页面源的限制,同时会自动带上当前登录态的 Cookie。抢票脚本的请求封装几乎都以这个 API 为底座:

function requestSkuList(payload) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: buildApiUrl(payload), timeout: pollConfig.timeout, onload(response) { try { resolve(JSON.parse(response.responseText)); } catch (e) { reject(new Error('响应不是合法 JSON: ' + e.message)); } }, onerror: reject, ontimeout: () => reject(new Error('请求超时,已跳过本轮')) }); }); }

回调里用 Promise 包裹的原因是 GM_xmlhttpRequest 不直接支持 async/await,包一层之后才能用 await 串联后续逻辑。JSON.parse 放进 try/catch 是因为网关在异常情况下会返回纯文本的错误页,不解析会直接抛错中断播放,解析失败按 reject 处理则只是本轮失败,不影响下一轮轮询。

4.2 幂等锁:状态机挡不住两条回调路径同时触发下单

状态机能防住“状态已经流转到 SUBMITTING 再进入第二次提交”,但页面事件与接口回调可能在同一个 tick 内各自触发一次“发现可购”。两路事件都先走到状态判断,再调用提交函数,状态机在第一次执行后还没更新 current 时,第二次调用已经进来了。这个时候必须在提交函数内部再加一把互斥锁:

let submitLock = null; function submitOrder(payload) { if (submitLock) { console.warn('[damai-ticket] 提交锁存在,忽略本次触发'); return; } submitLock = payload.orderId + '_' + Date.now(); return requestSubmit(payload) .then((result) => { submitLock = null; return result; }) .catch((err) => { submitLock = null; throw err; }); }

锁字段 submitLock 存在内存变量里,刻意不写进 localStorage 或 cookie。原因在于锁的生命周期应该等同于页面会话:刷新页面后重新开抢,旧锁不应该残留;如果用 cookie 存锁,用户切场次或刷新页面后还得手工清数据,容易漏。锁值用 orderId 加时间戳拼接,便于后续日志里追溯是哪一次提交触发了锁定。

4.3 页面改版后的选择器维护:失效的三段式排查步骤

大麦这种高频改版的前端项目,className 带 hash 后缀、按钮文案随时调整,脚本里的任何一条选择器失效都会让整条链路停摆。而且这种停摆往往没有报错——MutationObserver 挂在一个已经不存在的容器上,回调永远不触发,脚本表现为“什么都不做但也不报错”。

我的排查顺序是固定的三步:先在 Elements 面板选中票档按钮所在的父容器,右键 Copy selector 拿到新的 CSS 路径;再把新旧选择器打印出来对比,确认是 class 名变了还是嵌套层级变了;最后在脚本里输出容器节点的 outerHTML,确认实际渲染结构。下面是一个典型的改版情况:

页面版本票档按钮的 DOM 结构示例选择器需要调整成
改版前div.buy-btn 带文本“立即购买”.buy-btn
改版后button.action-btn 带>function logPoll(result, spentMs) { console.table({ time: new Date().toTimeString().split(' ')[0], state: result.state, spendMs: spentMs, rawCode: result.rawCode || '—' }); }

输出的每一行对应一个轮询周期,开票瞬间几十行记录铺开,状态从 WATCHING 到 SUBMITTING 的切换点一眼就能看到。

5.2 用环形数组在 localStorage 里保存最近 400 条记录

console.table 只在控制台窗口里有意义,页面一旦刷新记录就没了。把日志写进 localStorage,本地保留最近 400 条即可,覆盖写的方式实现环形结构:

const MAX_LOG_SIZE = 400; const LOG_KEY = 'damai_poll_history'; function appendLog(entry) { const raw = GM_getValue(LOG_KEY, '[]'); let history; try { history = JSON.parse(raw); } catch (e) { history = []; } history.push({ ts: Date.now(), ...entry }); if (history.length > MAX_LOG_SIZE) { history.splice(0, history.length - MAX_LOG_SIZE); } GM_setValue(LOG_KEY, JSON.stringify(history)); }

appendLog 的参数是一个扁平对象,调用时把 state、耗时、错误码都传进来,每条记录用毫秒时间戳打点。四百条的容量大约能覆盖两到三次完整抢票会话,足够事后分析用。

5.3 回放日志中的时间空档:定位卡在轮询还是卡在提交

拿到日志后,用相邻两条记录的时间戳差值做一次扫描,能比较准确地切分问题阶段:

const history = JSON.parse(GM_getValue(LOG_KEY, '[]')); for (let i = 1; i < history.length; i++) { const gap = history[i].ts - history[i - 1].ts; if (gap > 60000) { console.warn('发现超过 60 秒的空档,位置在:', i, '上一状态:', history[i - 1].state); } }

如果空档出现在 WATCHING 到 SUBMITTING 之间,说明状态机没有收到可购事件,问题在接口轮询或 DOM 监听环节;如果空档出现在 SUBMITTING 之后,说明提交请求发出后没有及时拿到返回,问题在订单接口的响应速度与超时设置上。横向对比同一天不同场次的两次日志,还能看出 baseInterval 设 600ms 与 1000ms 对结果的实际影响,参数调优就有据可依了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询