大麦抢票.user.js:从用户脚本到状态机的前端自动化实践

发布时间:2026/9/15 17:57:02
大麦抢票.user.js:从用户脚本到状态机的前端自动化实践 简介压缩包内共包含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 对结果的实际影响参数调优就有据可依了。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询