

用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自动化里也一样,最好的反检测不是跟网站硬碰硬,是让它觉得你就是个普通用户,本来就该这样。

往期分享: