cover

从一段 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 来判断交互意图。

脚本同时触发了 PointerEventMouseEvent,覆盖了新旧两套事件模型:

PointerEvent (现代标准)    MouseEvent (传统兼容)
pointerdown            →   mousedown
pointerup              →   mouseup
                              click

3. 防御性检查

点击前先做三步检查: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;
}

分析:

双重查找策略是个好思路:

  1. 首选策略:通过协议文本("我已阅读并同意")定位到包含复选框的容器,再找其中的 checkbox
  2. 兜底策略:如果文本匹配失败,通过 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 变化检测

它的适用场景很明确:在浏览器前端层面自动化抢购流程。但它也有天然的局限性:

  1. 依赖前端 DOM 结构,页面改版就会失效
  2. 无法绕过服务端限流(同一账号短时间内大量请求会被限制)
  3. 无法处理验证码、滑块验证等交互

对于真正的高并发抢购场景,服务端接口级别的自动化才是正解。但作为一段用户脚本,它在客户端自动化的范围内已经做得相当完整了。


run本文仅供技术学习参考。

DJI商城前端自动化分析
转载前请阅读本站 版权协议,文章著作权归 饼铛 所有,转载请注明出处。
评论

目录