RPA鼠标轨迹如何仿真?三层方案实现随机移动+随机点击

2026-05-12 11:26:29 0点赞 0收藏 0评论

用RPA做网页自动化,有一个绕不开的问题——平台风控。特别是电商平台、社交媒体这类网站,一旦发现操作过于机械鼠标直线移动、点击精准瞬间到位,轻则弹验证码,重则直接页面异常/封号。

传统RPA鼠标和真人鼠标的差异,说到底就三点。

§ 路径:RPA走绝对直线,真人走的是轻微S形或弧形,带微小随机偏移。

§ 速度:RPA全程匀速,真人是启动慢、中段快、接近目标时减速。

§ 轨迹:RPA每次完全一样,真人每次都有细微差异,肌肉记忆不是打印机。

结果就是,平台行为风控更偏向把前者判定为机器,后者判定为真人。所以想让RPA跑得稳,核心就一件事:尽可能让操作看起来像真人,降低风控误判的概率。

RPA鼠标轨迹如何仿真?三层方案实现随机移动+随机点击

那怎么做到?我花了不少时间研究这个问题,在影刀里反复测试调整,最后搞出了一套网页端的三层方案(桌面软件端也有,原理类似,这篇先聊网页端)。

RPA鼠标轨迹如何仿真?三层方案实现随机移动+随机点击

核心思路很简单,让RPA在页面的安全空白区域,用弯曲路径滑过去,确认没雷了再点

第一层 扫描安全空白区域

这是最关键的一步。拿到目标区域,你不能随手点:点到链接跳走了,点到按钮触发操作,点到输入框光标闪进去了。所以必须先「扫」一遍,找出哪些像素点是安全的。

怎么扫?JavaScript里有个API叫document.elementFromPoint(x, y)。给坐标,返回底下是什么DOM元素,像给网页拍X光片

RPA鼠标轨迹如何仿真?三层方案实现随机移动+随机点击

用30像素步长做网格扫描。每扫到一个点,沿着DOM树往上一层层查。是不是a标签?是不是button?有没有onclick?CSS的cursor是不是pointer?子树两层内有没有藏链接?全部排除后,剩下的就是安全空白点。

扫完后 Fisher-Yates 洗牌随机打乱,再跟历史点击坐标做距离过滤。保证每次点的位置不扎堆,最后取第一个返回。

JavaScript核心代码

```javascript

function (element, input) {

    // ============================================

    // 1. 确定扫描边界(优先从元素对象取矩形)

    // ============================================

    var left, top, right, bottom;

 

    if (element && element.getBoundingClientRect) {

        // 主路径:从操作目标元素获取视口相对矩形(CSS像素)

        var rect = element.getBoundingClientRect();

        left   = Math.ceil(rect.left);

        top    = Math.ceil(rect.top);

        right  = Math.floor(rect.right);

        bottom = Math.floor(rect.bottom);

 

    } else if (input && input.indexOf('Rectangle(') >= 0) {

        // 备用路径:从字符串解析(仅当没有element时使用)

        var mLeft   = input.match(/left=(d+)/);

        var mTop    = input.match(/top=(d+)/);

        var mRight  = input.match(/right=(d+)/);

        var mBottom = input.match(/bottom=(d+)/);

 

        if (!mLeft || !mTop || !mRight || !mBottom) return null;

 

        left   = parseInt(mLeft[1]);

        top    = parseInt(mTop[1]);

        right  = parseInt(mRight[1]);

        bottom = parseInt(mBottom[1]);

 

    } else {

        return null;

    }

 

    // ============================================

    // 2. 计算坐标转换参数(CSS像素 → 屏幕物理像素)

    // ============================================

    var vw = window.innerWidth || document.documentElement.clientWidth;

    var vh = window.innerHeight || document.documentElement.clientHeight;

    var scale = window.outerWidth / vw;

    var browserX = window.screenX || window.screenLeft || 0;

    var browserY = window.screenY || window.screenTop || 0;

    var toolbarHeight = window.outerHeight - vh * scale;

 

    // ============================================

    // 3. 扫描参数

    // ============================================

    var step = 30;    // 扫描步长(像素),矩形区域内可适当加密

    var margin = 8;   // 边缘留白,防止贴边

 

    var scanLeft   = left + margin;

    var scanTop    = top + margin;

    var scanRight  = right - margin;

    var scanBottom = bottom - margin;

 

    // 区域太小则放弃

    if (scanRight - scanLeft < step || scanBottom - scanTop < step) {

        return null;

    }

 

    // ============================================

    // 4. 辅助函数:检查子树内是否有链接

    // ============================================

    function hasLink(el, remaining) {

        if (!el || remaining < 0) return false;

        var tag = (el.tagName || '').toLowerCase();

        if (tag === 'a' || el.getAttribute('href')) return true;

        if (remaining === 0) return false;

        var c = el.children;

        for (var i = 0; i < c.length; i++) {

            if (hasLink(c[i], remaining - 1)) return true;

        }

        return false;

    }

 

    // ============================================

    // 5. 网格扫描:在矩形范围内逐点检测

    // ============================================

    var safePts = [];

 

    for (var y = scanTop; y <= scanBottom; y += step) {

        for (var x = scanLeft; x <= scanRight; x += step) {

 

            var el = document.elementFromPoint(x, y);

            if (!el) continue;

 

            // 5a. 文本像素检测(Firefox only,其他浏览器跳过)

            if (document.caretRangeFromPoint) {

                var r = document.caretRangeFromPoint(x, y);

                if (r && r.startContainer &&

                    r.startContainer.nodeType === 3 &&

                    r.startContainer.textContent.trim()) continue;

            }

 

            // 5b. 列表/网格容器检测:≥3个子元素共享同一首个类名 → 整块跳过

            var childs = el.children;

            if (childs.length >= 3) {

                var firstCls = (childs[0].getAttribute('class') || '').split(' ')[0];

                if (firstCls) {

                    var sameClass = 0;

                    for (var ci = 0; ci < childs.length; ci++) {

                        var cls = (childs[ci].getAttribute('class') || '').split(' ')[0];

                        if (cls === firstCls) sameClass++;

                    }

                    if (sameClass >= 3) continue;

                }

            }

 

            // 5c. 子树2层内有链接 → 跳过

            if (hasLink(el, 2)) continue;

 

            // 5d. 沿父链检查交互元素

            var ok = true;

            var node = el;

            while (node && node !== document.body) {

                var tag = (node.tagName || '').toLowerCase();

 

                if (['a','button','input','textarea','select'].indexOf(tag) >= 0) { ok = false; break; }

                if (['img','video','iframe','canvas','svg'].indexOf(tag) >= 0) { ok = false; break; }

 

                var style = window.getComputedStyle(node);

                if (node.onclick ||

                    node.getAttribute('onclick') ||

                    node.getAttribute('role') === 'button' ||

                    node.getAttribute('role') === 'link' ||

                    (node.tabIndex >= 0) ||

                    style.cursor === 'pointer') {

                    ok = false; break;

                }

 

                node = node.parentElement;

            }

            if (!ok) continue;

 

            safePts.push({ x: x, y: y });

        }

    }

 

    // ============================================

    // 6. 无安全点则返回 null

    // ============================================

    if (!safePts.length) return null;

 

    // ============================================

    // 7. Fisher-Yates 洗牌,打乱顺序

    // ============================================

    for (var i = safePts.length - 1; i > 0; i--) {

        var j = Math.floor(Math.random() * (i + 1));

        var tmp = safePts[i];

        safePts[i] = safePts[j];

        safePts[j] = tmp;

    }

 

    // ============================================

    // 8. 过滤:与历史点保持最小间距(CSS像素)

    // ============================================

    var minDist = 150;  // 最小间距,避免连续坐标簇在一起

 

    var filtered = safePts;

    if (element && element.__used && element.__used.length) {

        filtered = [];

        for (var pi = 0; pi < safePts.length; pi++) {

            var tooClose = false;

            for (var ui = 0; ui < element.__used.length; ui++) {

                var dx = safePts[pi].x - element.__used[ui].x;

                var dy = safePts[pi].y - element.__used[ui].y;

                if (Math.sqrt(dx dx + dy dy) < minDist) {

                    tooClose = true; break;

                }

            }

            if (!tooClose) filtered.push(safePts[pi]);

        }

        // 如果全被过滤了,放宽间距再试一次

        if (!filtered.length) {

            var relaxedDist = minDist / 2;

            for (var pi = 0; pi < safePts.length; pi++) {

                var tooClose = false;

                for (var ui = 0; ui < element.__used.length; ui++) {

                    var dx2 = safePts[pi].x - element.__used[ui].x;

                    var dy2 = safePts[pi].y - element.__used[ui].y;

                    if (Math.sqrt(dx2 dx2 + dy2 dy2) < relaxedDist) {

                        tooClose = true; break;

                    }

                }

                if (!tooClose) filtered.push(safePts[pi]);

            }

        }

    }

 

    // 全过滤了就用原始洗牌列表

    if (filtered.length) safePts = filtered;

 

    // 取第一个(已被洗牌随机化)

    var pt = safePts[0];

 

    // ============================================

    // 9. 记录历史(存储在元素对象上,同次循环可跨调用记住)

    // ============================================

    if (element) {

        if (!element.__used) element.__used = [];

        element.__used.push(pt);

        if (element.__used.length > 20) element.__used.shift();  // 最多记20个

    }

 

    // ============================================

    // 10. CSS像素 → 屏幕物理像素

    // ============================================

 

    return {

        "随机X坐标": Math.round((browserX + pt.x) * scale),

        "随机Y坐标": Math.round((browserY + toolbarHeight + pt.y) * scale),

        "视口左边界": Math.round((browserX + left) * scale),

        "视口上边界": Math.round((browserY + toolbarHeight + top) * scale),

        "视口右边界": Math.round((browserX + right) * scale),

        "视口下边界": Math.round((browserY + toolbarHeight + bottom) * scale)

    };

}

```

 

第二层 贝塞尔曲线移动

安全坐标有了,鼠标怎么过去?

直线肯定不行,太假。你想想自己用鼠标,手腕微转,光标走的就是微弧线。没人能把鼠标走成尺子画出来的几何纯直线。

RPA鼠标轨迹如何仿真?三层方案实现随机移动+随机点击

这里用到的就是贝塞尔曲线。简单讲,用几个控制点生成平滑曲线,能批量产出无限条、不重复、自然弯曲加变速的轨迹,刚好拟合真人鼠标特征。起点终点之间随机插1到2个中间锚点,每个偏移最多±300像素。路径是自然弧线,每次还不一样,前面说的路径和轨迹差异直接抹掉。

核心代码

```python

import random

import time

import math

import ctypes

 

from typing import *

try:

    from xbot.app.logging import trace as print

except:

    from xbot import print

from xbot import win32

 

# 尽最大努力在底层声明 DPI 感知(防止部分系统自动接管缩放)

try:

    ctypes.windll.shcore.SetProcessDpiAwareness(2)

except Exception:

    try:

        ctypes.windll.user32.SetProcessDPIAware()

    except Exception:

        pass

 

def move_mouse_to_target_bezier_auto(target_x: int, target_y: int, duration: float = 1.5) -> None:

    """

    title: 贝塞尔平滑移动至目标(自动适配DPI版)

    description: 自动侦测当前系统的显示器缩放比例,结合多点贝塞尔曲线算法,

                 平滑且精准地将鼠标移动到指定的物理坐标。

                 彻底解决不同电脑缩放不同导致的坐标漂移问题。

    inputs: 

        - target_x (int): 目标落地 X 坐标(影刀拾取到的物理坐标),eg: 800

        - target_y (int): 目标落地 Y 坐标(影刀拾取到的物理坐标),eg: 600

        - duration (float): 移动总耗时(秒),默认1.5秒,eg: 1.5

    outputs: 

        - None: 无返回值

    """

    

    # 1. 自动获取系统 DPI 缩放系数

    def getdpi_multiplier():

        try:

            dpi = ctypes.windll.user32.GetDpiForSystem()

            return dpi / 96.0

        except Exception:

            try:

                hdc = ctypes.windll.user32.GetDC(0)

                dpi = ctypes.windll.gdi32.GetDeviceCaps(hdc, 88)

                ctypes.windll.user32.ReleaseDC(0, hdc)

                return dpi / 96.0

            except Exception:

                return 1.0

 

    dpi_multiplier = getdpi_multiplier()

    print(f"系统环境侦测完成,当前系统缩放比例为: {int(dpi_multiplier * 100)}%")

 

    # 2. 内部核心算法定义

    def _erxiangshi(n, k):

        if k < 0 or k > n: return 0

        if k == 0 or k == n: return 1

        if k > n // 2: k = n - k

        num, den = 1, 1

        for i in range(1, k + 1):

            num = num * (n - i + 1)

            den = den * i

        return num // den

 

    def multipoint_bezier(points, t):

        n = len(points) - 1

        x, y = 0.0, 0.0

        for i, point in enumerate(points):

            coeff = _erxiangshi(n, i) math.pow(1 - t, n - i) math.pow(t, i)

            x += point[0] * coeff

            y += point[1] * coeff

        return x, y

 

    # 3. 路径规划:利用影刀自带接口获取精准物理起点

    curr = win32.get_mouse_position('screen')

    start_pt = [float(curr[0]), float(curr[1])]

    end_pt = [float(target_x), float(target_y)]

    

    anchor_points = [start_pt]

    num_intermediate = random.randint(1, 2)

    

    for  in range(numintermediate):

        mid_x = (start_pt[0] + end_pt[0]) / 2 + random.uniform(-300, 300)

        mid_y = (start_pt[1] + end_pt[1]) / 2 + random.uniform(-300, 300)

        anchor_points.append([mid_x, mid_y])

        

    anchor_points.append(end_pt)

 

    # 4. 执行高频插值移动

    start_time = time.perf_counter()

    last_print_time = 0

 

    while True:

        elapsed = time.perf_counter() - start_time

        if elapsed >= duration:

            break

        

        t = elapsed / duration

        cur_x, cur_y = multipoint_bezier(anchor_points, t)

        

        tx = int(cur_x * dpi_multiplier)

        ty = int(cur_y * dpi_multiplier)

        ctypes.windll.user32.SetCursorPos(tx, ty)

        

        if elapsed - last_print_time > 0.1:

            last_print_time = elapsed

 

        time.sleep(0.001)

 

    # 5. 确保精准落地并进行最终自动纠偏

    final_tx = int(target_x * dpi_multiplier)

    final_ty = int(target_y * dpi_multiplier)

    ctypes.windll.user32.SetCursorPos(final_tx, final_ty)

    print(f"移动完成!已精准降落至设定坐标: ({target_x}, {target_y})")

 

    return None

```

第三层 手掌光标兜底检测

前面两层下来该点了吧?别急。

实际应用中有些网页结构很复杂,动态加载、层级嵌套、CSS覆盖,各种原因都可能导致扫描坐标误判。扫的时候觉得是空白,鼠标真移过去,光标变小手了。

RPA鼠标轨迹如何仿真?三层方案实现随机移动+随机点击

所以加了这道兜底:点击前调用Windows底层API,实时看光标形态(手掌形态说明可交互)。是就跳过,不是就点。能救回潜在误判的发生。

```python

import ctypes

from ctypes import wintypes

from typing import *

 

try:

    from xbot.app.logging import trace as print

except:

    from xbot import print

 

# 定义 Windows API 所需的底层结构体

class CURSORINFO(ctypes.Structure):

    fields = [

        ("cbSize", wintypes.DWORD),

        ("flags", wintypes.DWORD),

        ("hCursor", wintypes.HANDLE),

        ("ptScreenPos", wintypes.POINT),

    ]

 

def check_cursor_shape() -> str:

    """

    title: 判断鼠标是否为手掌光标

    description: 调用Windows底层API实时获取当前鼠标状态。

                 如果是可点击的手掌形状,则返回"手掌光标",

                 其余所有形态均统一视为并返回"箭头光标"。

    inputs: 

        - 无参数

    outputs: 

        - cursor_state (str): "手掌光标" / "箭头光标"

    """

    

    # 1. 初始化 Windows user32 接口

    user32 = ctypes.windll.user32

    

    # 2. 仅获取系统标准"手掌光标"的句柄标识 (IDC_HAND = 32649)

    h_hand = user32.LoadCursorW(None, 32649)

    

    # 3. 准备获取当前实际光标信息

    info = CURSORINFO()

    info.cbSize = ctypes.sizeof(CURSORINFO)

    

    # 4. 调用 API 获取并进行极简二元比对

    if user32.GetCursorInfo(ctypes.byref(info)):

        current_hCursor = info.hCursor

        

        # 核心逻辑:只要是手掌光标就返回"手掌",其他一切状态统称为"箭头"

        if current_hCursor == h_hand:

            result = '手掌光标'

        else:

            result = '箭头光标'

            

        print(f"光标检测成功,当前状态判定为: {result}")

        return result

        

    # 5. 异常兜底,如果由于系统权限导致API调用彻底失败才走这里

    return '获取失败'

```

三层串起来,流程是这样的:拿元素,循环10次。每次JS扫描安全点,贝塞尔移动,等2秒,检测光标。安全就点,不安全跳过重来。

多轮测试后,这套方案有三个实打实的优势。

§ 第一,完全基于原生API,不依赖任何第三方库。

§ 第二,三层各司其职。扫描负责找安全点,曲线负责模拟真人轨迹,光标检测负责最终兜底。

§ 第三,DPI自适应让它在不同电脑上都精准运行。

另外,这套方案的部分思路也可以和影刀官方的「开启模拟真人操作」等指令配合使用,效果叠加起来会更稳。

RPA鼠标轨迹如何仿真?三层方案实现随机移动+随机点击

最好的伪装不是藏起来,是把自己变成环境的一部分。RPA自动化里也一样,最好的反检测不是跟网站硬碰硬,是让它觉得你就是个普通用户,本来就该这样。

以上,既然看到这里了,如果觉得不错,随手点个赞、在看、转发三连吧,如果想第一时间收到推送,也可以给我个星标?~

谢谢你看我的文章,我们下篇分享再见。

RPA鼠标轨迹如何仿真?三层方案实现随机移动+随机点击

-END-

· 爱练字的ISTJ型互联网人/信息整合怪/工具人/影刀高级认证工程师。

· 专注分享:RPA&AI自动化场景提效方案、效率软件安利、实用技能。"所有的生产要素都可以被构建,只有认知是壁垒",欢迎関注 

作者声明本文无利益相关,欢迎值友理性交流,和谐讨论~

展开 收起
0评论

当前文章无评论,是时候发表评论了
提示信息

取消
确认
评论举报

相关文章推荐

更多精彩文章
更多精彩文章
最新文章 热门文章
0
扫一下,分享更方便,购买更轻松