思路分享:RPA跑网页自动化,鼠标怎么走得更像真人一点?三层方案实现随机移动轨迹+随机点击空白区域
评论
收藏

思路分享:RPA跑网页自动化,鼠标怎么走得更像真人一点?三层方案实现随机移动轨迹+随机点击空白区域

经验分享
掌心向暖
2026-05-12 09:59·浏览量:175
掌心向暖
影刀中级开发者
发布于 2026-05-12 09:59175浏览

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

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

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

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

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

结果就是,平台行为风控更偏向把前者判定为机器,后者判定为真人。所以想让RPA跑得稳,核心就一件事:尽可能让操作看起来像真人,降低风控误判的概率。(下图加速处理过,实际运行轨迹更丝滑)

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

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

第一层 扫描安全空白区域

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

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

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

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

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)
    };
}

第二层 贝塞尔曲线移动

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

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

这里用到的就是贝塞尔曲线。简单讲,用几个控制点生成平滑曲线,能批量产出无限条、不重复、自然弯曲加变速的轨迹,刚好拟合真人鼠标特征。起点终点之间随机插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 _get_dpi_multiplier():
        try:
            # 优先尝试 Windows 10+ 的原生获取系统 DPI 接口 (标准DPI为96)
            dpi = ctypes.windll.user32.GetDpiForSystem()
            return dpi / 96.0
        except Exception:
            try:
                # 兼容旧版 Windows 系统,通过获取屏幕 DC 来拿到 DPI
                hdc = ctypes.windll.user32.GetDC(0)
                dpi = ctypes.windll.gdi32.GetDeviceCaps(hdc, 88) # 88 代表 LOGPIXELSX
                ctypes.windll.user32.ReleaseDC(0, hdc)
                return dpi / 96.0
            except Exception:
                return 1.0 # 如果所有底层探测均失败,默认视为 100% 缩放

    dpi_multiplier = _get_dpi_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 _multi_point_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(num_intermediate):
        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 = _multi_point_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覆盖,各种原因都可能导致扫描坐标误判。扫的时候觉得是空白,鼠标真移过去,光标变小手了。

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

# 无需安装额外库,完全使用Python内置模块和系统API

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): 返回当前鼠标光标的状态,eg: "手掌光标" / "箭头光标"
    """
    
    # 1. 初始化 Windows user32 接口
    user32 = ctypes.windll.user32
    
    # 2. 仅获取系统标准“手掌光标”的句柄标识 (IDC_HAND = 32649)
    # 我们不再需要加载其他光标的ID,因为其余的全部归为一类
    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自动化里也一样,最好的反检测不是跟网站硬碰硬,是让它觉得你就是个普通用户,本来就该这样。

往期分享:

收藏5
全部评论1
最新
发布评论
评论