从一段 DJI 自动购买脚本,看前端自动化的技术实现
引言
热门产品首发,拼的不是手速,是脚本。
本文分析一段 DJI 官方商城的 Tampermonkey 自动抢购用户脚本,从架构设计、反检测策略、事件模拟、状态管理等角度拆解它的技术实现,最后给出可以进一步优化的方向。
完整脚本涉及商品页 → 配件页 → 结算页三步流程,目标是在产品开售后自动完成下单。(文末查看效果图)
架构总览
脚本的核心是一个页面状态机 + 随机轮询的模型:
[商品详情页] --(点击"立即购买")--> [配件页] --(点击"立即添加")--> [结算页] --(提交订单)--> 完成
| | |
| 200-600ms 随机轮询 | 200-600ms 随机轮询 | 勾选协议 + 提交
v v v
findButtonByText('立即购买') findButtonByText('立即添加') findAndCheckCheckbox()三个阶段的判断条件是互斥的 if / else if / else,通过 URL 变化驱动状态切换。全局用 isBursting 标志位防止重复触发。
核心技术拆解
反检测:随机化是基础
const MIN_INTERVAL = 200;
const MAX_INTERVAL = 600;
function startPolling() {
function scheduleNext() {
const delay = Math.floor(Math.random() * (MAX_INTERVAL - MIN_INTERVAL + 1)) + MIN_INTERVAL;
pollingTimer = setTimeout(poll, delay);
}
function poll() {
checkPageAndClick();
if (pollingActive) scheduleNext();
}
scheduleNext();
}分析:
- 轮询间隔在 200-600ms 之间随机,避免固定周期被 WAF(Web 应用防火墙)识别为机器人
- 用
setTimeout而非setInterval,每次重新计算随机延迟,抖动效果更好 - 200ms 的下限保证了响应速度,600ms 的上限避免了过于频繁的 DOM 查询
对比固定间隔:
// ❌ 固定间隔 — 容易被识别
setInterval(checkPageAndClick, 500);
// ✅ 随机间隔 — 更接近人类行为
setTimeout(poll, Math.random() * 400 + 200);模拟人类点击:完整事件链
function safeClick(button) {
const rect = button.getBoundingClientRect();
// 检查按钮是否被禁用
if (button.disabled || button.getAttribute('aria-disabled') === 'true') {
console.log('[DJI Auto-Buy] 按钮处于禁用状态');
return false;
}
// 检查按钮是否可见
if (rect.width === 0 || rect.height === 0) {
console.log('[DJI Auto-Buy] 按钮不可见');
return false;
}
// 确保按钮在视口内
button.scrollIntoView({ block: 'center', behavior: 'smooth' });
// 在按钮区域内随机生成点击位置
const offsetX = Math.floor(Math.random() * rect.width * 0.6) + rect.width * 0.2;
const offsetY = Math.floor(Math.random() * rect.height * 0.6) + rect.height * 0.2;
const x = Math.floor(rect.left + window.scrollX + offsetX);
const y = Math.floor(rect.top + window.scrollY + offsetY);
// 生成完整的事件序列,模拟真实鼠标行为
const events = [
{ type: 'pointerdown', options: { button: 0, buttons: 1, bubbles: true, cancelable: true, clientX: x, clientY: y, pointerType: 'mouse' } },
{ type: 'mousedown', options: { button: 0, buttons: 1, bubbles: true, cancelable: true, clientX: x, clientY: y } },
{ type: 'pointerup', options: { button: 0, buttons: 0, bubbles: true, cancelable: true, clientX: x, clientY: y, pointerType: 'mouse' } },
{ type: 'mouseup', options: { button: 0, buttons: 0, bubbles: true, cancelable: true, clientX: x, clientY: y } },
{ type: 'click', options: { button: 0, buttons: 0, bubbles: true, cancelable: true, clientX: x, clientY: y } },
];
for (const event of events) {
const ev = new PointerEvent(event.type, event.options);
button.dispatchEvent(ev);
}
return true;
}分析:
这段代码有几个值得注意的点:
1. 点击位置随机化
const offsetX = Math.floor(Math.random() * rect.width * 0.6) + rect.width * 0.2;在按钮宽度的 20%-80% 范围内随机取点,不固定点击中心。高级的反机器人系统会分析点击坐标分布,如果所有点击都集中在按钮正中心几个像素内,就会被标记为异常。
2. 完整的事件序列
真实的鼠标点击会触发一连串事件:pointerdown → mousedown → pointerup → mouseup → click。很多简单脚本只触发一个 click 事件,这在现代前端框架中可能不够 — 某些组件依赖 mousedown/mouseup 来判断交互意图。
脚本同时触发了 PointerEvent 和 MouseEvent,覆盖了新旧两套事件模型:
PointerEvent (现代标准) MouseEvent (传统兼容)
pointerdown → mousedown
pointerup → mouseup
click3. 防御性检查
点击前先做三步检查:disabled 状态 → 可见性 → 滚动到视口。避免在按钮还没渲染好或还没启用的时候误触。
连点策略:burstClick
const BURST_COUNT = 3;
async function burstClick(button, label) {
for (let i = 0; i < BURST_COUNT; i++) {
const clicked = safeClick(button);
if (!clicked) break;
if (i < BURST_COUNT - 1) {
const wait = Math.floor(Math.random() * (MAX_CLICK_INTERVAL - MIN_CLICK_INTERVAL + 1)) + MIN_CLICK_INTERVAL;
await new Promise(resolve => setTimeout(resolve, wait));
}
}
}分析:
- 每次找到按钮后连续点击 3 次,应对网络延迟或首次点击未生效的情况
- 每次连点之间 150-400ms 随机间隔,不会太快触发防抖限制
- 如果某次点击失败(按钮变 disabled 或消失),立即停止后续点击
按钮查找:按文字匹配
function findButtonByText(text) {
const buttons = document.querySelectorAll('button');
for (const btn of buttons) {
const content = btn.textContent?.trim().toLowerCase();
if (content && content.includes(text.toLowerCase())) {
return btn;
}
}
return null;
}分析:
- 不依赖特定的 class 名或 id(这些经常随前端版本变化),而是按按钮文字内容匹配
textContent.includes的模糊匹配方式,对按钮文字的前后缀变化有一定容错能力- 缺点是每次调用都要遍历页面上所有 button 元素,按钮多的页面开销不小
性能优化建议: 可以用 MutationObserver 替代轮询:
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === 1 && node.textContent?.includes('立即购买')) {
// 按钮出现了,立即处理
handleButtonClick(node);
return;
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });MutationObserver 的优势是事件驱动而非轮询,按钮一出现就能响应,延迟更低且 CPU 占用更小。
状态管理与 URL 变化检测
let lastUrl = window.location.href;
function checkUrlChange() {
const currentUrl = window.location.href;
if (currentUrl !== lastUrl) {
lastUrl = currentUrl;
console.log('[DJI Auto-Buy] 页面 URL 已变化,重置状态');
isBursting = false;
updateStatusPanel();
}
}分析:
脚本用 setInterval(checkUrlChange, 500) 每 500ms 检测一次 URL 变化。URL 变化意味着页面跳转,需要重置 isBursting 状态,防止旧页面的连点操作干扰新页面。
不足: 现代 SPA(单页应用)的页面切换不一定改变 URL,或者 URL 变化时机和 DOM 更新有延迟。更可靠的方式是:
// 劫持 history.pushState
const originalPushState = history.pushState;
history.pushState = function(...args) {
originalPushState.apply(this, args);
handleUrlChange();
};
// 监听 popstate 事件
window.addEventListener('popstate', handleUrlChange);结算页:协议勾选
function findAndCheckCheckbox() {
const checkboxLabels = ['我已阅读并同意', '同意并勾选', '我已阅读'];
// 策略一:通过 label 文字查找
const allElements = document.querySelectorAll('label, span, p, div');
for (const el of allElements) {
const text = el.textContent?.trim();
if (text && checkboxLabels.some(label => text.includes(label))) {
const checkbox = el.querySelector('input[type="checkbox"]');
if (checkbox && !checkbox.checked) {
checkbox.checked = true;
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
}
}
// 策略二:通过 class 名模糊匹配兜底
const allCheckbox = document.querySelectorAll('input[type="checkbox"]');
for (const checkbox of allCheckbox) {
if (!checkbox.checked) {
if (checkbox.className && checkbox.className.includes('checkbox')) {
checkbox.checked = true;
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
return true;
}
}
}
return false;
}分析:
双重查找策略是个好思路:
- 首选策略:通过协议文本("我已阅读并同意")定位到包含复选框的容器,再找其中的 checkbox
- 兜底策略:如果文本匹配失败,通过 class 名包含 "checkbox" 来模糊匹配
checkbox.dispatchEvent(new Event('change', { bubbles: true })) 这一步很关键 — 现代框架(React/Vue)通过事件监听来同步状态,只改 checked 属性不会触发响应式更新,必须派发事件。
悬浮状态面板
function initStatusPanel() {
const panel = document.createElement('div');
panel.id = 'dji-auto-buy-status';
panel.innerHTML = `
<div style="...">
<div>🤖 DJI 自动购买助手</div>
<div id="dji-auto-buy-status-text">等待购买...</div>
<div id="dji-auto-buy-status-retry">重试: 0 次</div>
</div>
`;
document.body.appendChild(panel);
}
function updateStatusPanel() {
const textEl = document.getElementById('dji-auto-buy-status-text');
const retryEl = document.getElementById('dji-auto-buy-status-retry');
if (textEl) {
if (window.location.href.includes('osmo-pocket')) {
textEl.textContent = '等待商品页...';
} else if (isBursting) {
textEl.textContent = '🔥 正在点击...';
} else {
textEl.textContent = '✅ 已点击,等待跳转';
}
}
if (retryEl) {
retryEl.textContent = `重试: ${retryCount} 次`;
}
}分析:
面板提供了基本的运行状态反馈,但实现方式比较原始:
innerHTML硬编码样式,维护性差- 每秒通过
querySelector查找元素更新,有一定开销 - 样式写死在 JS 里,不利于主题化
改进建议: 用 CSS 类名管理样式,状态更新只改 textContent:
// CSS 中定义 .status-waiting, .status-clicking, .status-success
textEl.className = `status-${currentState}`;
textEl.textContent = stateText[currentState];不足与优化方向
轮询性能
findButtonByText 每次调用都 document.querySelectorAll('button') 遍历全量按钮,在按钮密集的电商页面(筛选、排序、分页、推荐商品都有按钮)开销不小。
优化: 如前文所述,用 MutationObserver 替代轮询。
4.2 SPA 路由检测
setInterval 轮询 URL 变化有 500ms 的延迟窗口,页面跳转后可能还在执行旧页面的点击逻辑。
优化: 劫持 history.pushState + 监听 popstate。
缺少错误处理
当前脚本没有处理以下异常场景:
- 网络超时(提交订单后服务器 502/504)
- 页面结构变化(DJI 前端改版后按钮文字或结构变了)
- 库存不足(按钮变灰或显示"已售罄")
建议: 加超时计数器,某一步连续 N 次未找到按钮就触发告警:
if (retryCount > 30) { // 约 12 秒没找到按钮
showNotification('⚠️ 长时间未找到目标按钮,可能页面结构已变化');
stopPolling();
}硬编码依赖
PRODUCT_KEYWORD = 'osmo-pocket'— 换个产品要改代码- 按钮文字全部硬编码 — 多语言场景会挂
- URL 匹配也是硬编码
建议: 抽成配置对象:
const CONFIG = {
productKeyword: 'osmo-pocket',
steps: [
{ urlPattern: '/product', buttonText: '立即购买' },
{ urlPattern: '/accessory', buttonText: '立即添加' },
{ urlPattern: '/checkout', buttonText: '提交订单' },
]
};burstClick 中未检测 URL 变化
async function burstClick(button, label) {
for (let i = 0; i < BURST_COUNT; i++) {
const clicked = safeClick(button);
// 如果点击后页面立刻跳转了,后续点击可能点到新页面的错误位置
}
}建议: 每次点击后检查 URL 是否变化:
async function burstClick(button, label) {
for (let i = 0; i < BURST_COUNT; i++) {
const urlBefore = window.location.href;
const clicked = safeClick(button);
if (!clicked) break;
await new Promise(resolve => setTimeout(resolve, 200));
if (window.location.href !== urlBefore) {
console.log('[DJI Auto-Buy] 页面已跳转,停止连点');
break;
}
}
}总结
这段脚本虽然只有 300 多行,但在反检测策略上花了不少心思:
- 随机化间隔和点击位置
- 完整的鼠标事件序列模拟
- 连点 + 状态管理 + URL 变化检测
它的适用场景很明确:在浏览器前端层面自动化抢购流程。但它也有天然的局限性:
- 依赖前端 DOM 结构,页面改版就会失效
- 无法绕过服务端限流(同一账号短时间内大量请求会被限制)
- 无法处理验证码、滑块验证等交互
对于真正的高并发抢购场景,服务端接口级别的自动化才是正解。但作为一段用户脚本,它在客户端自动化的范围内已经做得相当完整了。
本文仅供技术学习参考。

