滑块验证自动化的工程实践:从原图复原到非贝塞尔轨迹

从原图复原、差分定位到最小跃度轨迹

脱敏说明:本文将实际业务平台和验证供应商分别称为"业务系统 A"和"验证组件 B"。域名、接口路径、选择器、业务字段及标识均已替换或省略。example.test 是保留的示例域名,文中的代码是简化示例,不是可直接运行的生产脚本。

本文讨论有明确授权的页面交互与测试,不涉及伪造业务状态、伪造验证令牌或绕过浏览器权限。自动化是否允许使用,仍需遵守目标系统的规则。


一、难点不只是"向右拖多少像素"

一个拼图滑块,看起来只需要三步:找到缺口、算出距离、拖过去。

真正动手实现时,问题远不止这些:

  • 网络图片可能被切片打乱,不能直接拿来匹配;
  • 拼图包含透明边距和发光描边,外接矩形不一定等于有效图案的位置;
  • 图片坐标和页面上的 CSS 坐标可能不是一比一;
  • 验证成功回调可能直接触发业务操作,没有第二次确认;
  • 页面异步更新、重复回调、超时和取消,都可能改变最终结果。

所以我把整个过程拆成了几层:

读取当前页面状态
    ↓
获取同一组原始图片
    ↓
按渲染规则复原 → 与页面画布核对
    ↓
差分定位 + 拼图内部像素匹配
    ↓
图片坐标换算为页面位移
    ↓
平滑拖动 + 终点检查
    ↓
验证结果 → 独立测试回调 / 原业务回调

这里的关键原则只有一条:每一步都要有可检查的输出,不能只凭"看起来对了"就继续往下执行。

二、从截图估算转向原始图片

截图适合快速观察,但不适合作为精确计算的唯一依据。页面缩放、设备像素比、裁剪位置、显示插值都会影响截图结果,验证图本身也可能在操作期间刷新。

更直接的办法,是先找到页面实际加载的图片资源:

const imageUrls = performance.getEntriesByType('resource')
  .filter(entry => entry.initiatorType === 'img')
  .map(entry => entry.name)
  .filter(url => url.startsWith('https://assets.example.test/challenge/'));

这里有一个容易混淆的点:Resource Timing 提供的是资源地址和时序,不是历史请求的响应正文。 找到地址后,还需要重新加载原始图片。跨域读取像素必须得到图片服务允许;如果 Canvas 被污染、无法读取,就应当停止,而不是绕过浏览器限制。

本次样本包含三类资源:

资源 用途
完整背景 提供缺口区域原本的图案
带缺口背景 与完整背景做差分
透明拼图 PNG 提供形状、边距和内部纹理

这些资源必须来自同一组验证图。只取"最近加载的三张图片"并不可靠,应按资源分组关系关联,并在拖动前再次确认验证图没有更换。

三、图片复原:复现渲染规则,而不是猜排列

这一阶段的规则来自页面实际加载的 JavaScript。我定位了两个部分:一是切片排列顺序的生成逻辑,二是把原图切片复制到目标画布的渲染逻辑。随后把它们改写成可读实现,没有完整反混淆整个组件。

本次图片布局如下:

  • 原图尺寸:312 × 160;
  • 横向 26 个切片槽,纵向两排,共 52 块;
  • 每个槽横向占 12 像素,其中有效内容宽 10 像素;
  • 每块有效内容尺寸:10 × 80;
  • 去掉间隔、重新排列后,得到 260 × 160 的背景。

设目标第 i 块对应原图第 order[i] 块,复原的核心就是:

function restoreBackground(image, order) {
  const validOrder = order.length === 52
    && new Set(order).size === 52
    && order.every(n => Number.isInteger(n) && n >= 0 && n < 52);

  if (image.naturalWidth !== 312 || image.naturalHeight !== 160 || !validOrder) {
    throw new Error('不支持的图片布局或排列参数');
  }

  const source = document.createElement('canvas');
  source.width = 312;
  source.height = 160;
  const sourceContext = source.getContext('2d');
  sourceContext.drawImage(image, 0, 0);

  const output = document.createElement('canvas');
  output.width = 260;
  output.height = 160;
  const outputContext = output.getContext('2d');

  order.forEach((sourceIndex, targetIndex) => {
    const sx = (sourceIndex % 26) * 12 + 1;
    const sy = Math.floor(sourceIndex / 26) * 80;
    const dx = (targetIndex % 26) * 10;
    const dy = Math.floor(targetIndex / 26) * 80;

    const tile = sourceContext.getImageData(sx, sy, 10, 80);
    outputContext.putImageData(tile, dx, dy);
  });

  return output;
}

为避免暴露具体供应商的版本参数,示例没有附上实际固定排列,而是把 order 作为输入。

这段方法也不是通用规则:切片尺寸、间隔和排列都必须来自对应版本的实现。组件版本变化后应重新验证,不能继续套用旧参数。

一个意外:文件相同,读回的像素却不同

复原完成后,我把结果与页面已有画布做了逐像素比较。一开始,部分环境下的哈希并不一致。

进一步对照发现:原始文件内容相同,但在本次浏览器环境中,默认 Canvas 绘制路径与启用 willReadFrequently 提示的路径,读回的像素存在细微差异。统一为原页面使用的绘制方式后,两张复原背景都达到了逐像素差异为 0。

这个现象不能推广成"所有浏览器都会如此",但它提醒我:

  1. 文件字节相同,不代表不同绘制路径的 RGBA 输出必然相同;
  2. 哈希不同只说明数据不同,不能直接断定切片算法错了;
  3. 精确比较需要尽量统一解码、绘制和像素读取条件。

另外,页面画布在这里只用于事后校验;定位算法的输入仍然是原始图片,不是直接从画布读取一个现成答案。

四、缺口定位:差分筛选,纹理确认

背景复原后,先计算完整背景与缺口背景的像素差异:

D(x, y) = max(
  |R完整 - R缺口|,
  |G完整 - G缺口|,
  |B完整 - B缺口|
)

差异较大的区域可以帮助定位缺口,但不能只取差异区域最左侧作为答案——压缩噪声、阴影、描边和透明边距都会带来偏移。因此我采用了两阶段处理。

第一步:筛选差异区域。 对差异图设置阈值,提取主要连通区域,用于观察和核对缺口范围;候选匹配位置还需要覆盖足够多的显著差异像素。本次样本使用差异阈值 40、候选覆盖比例 70%。这些是样本相关的工程参数,不是适用于所有图像的标准。

第二步:匹配拼图内部纹理。 拼图边缘通常包含透明像素和亮色描边,直接比较整个矩形容易产生误差,所以先构造掩码:

  1. 保留 alpha > 250 的像素;
  2. 使用 7 × 7 方形邻域腐蚀掩码,进一步去掉边缘;
  3. 只对剩余内部像素,计算其与完整背景候选区域的均方误差。
score(x, y) =
  掩码内所有 RGB 通道的平方误差之和
  ÷ (有效像素数量 × 3)

选择满足差异覆盖条件、且误差最小的位置。如果有效像素不足、没有候选位置或结果不唯一,就停止,不强行给出坐标。

图片坐标不能直接当鼠标坐标

最终需要的通常是:

页面位移 = (目标图像起点 - 当前图像起点)
         × 页面显示宽度 / 图片实际宽度

注意,"图像起点"与"可见图案最左边"不是一回事。可以分别计算原始拼图和当前拼图画布的有效 alpha 边界,通过两者之差推导当前放置位置。这样能把透明边距、初始偏移与页面缩放分开处理,避免靠经验加减几个像素。

五、不用贝塞尔:五次最小跃度轨迹

最初的拖动方式是固定步数、均匀增加横向位移。它能表达"移动到哪里",但起步和停止比较生硬,而且定时器延迟会改变整体节奏。

改进版采用五次最小跃度(minimum jerk)位移曲线:

u = clamp((当前时间 - 开始时间) / 总时长, 0, 1)
s(u) = 10u³ - 15u⁴ + 6u⁵
x(u) = x起点 + 总位移 × s(u)
y(u) = y起点

它是一条直接计算的五次多项式,不需要贝塞尔控制点。理论上,运动两端的速度和加速度都为 0,能够平滑地从静止出发,再回到静止。

这里优化的是水平运动的时间分布,不是把空间路径画成弧线。

function minimumJerk(progress) {
  if (!Number.isFinite(progress)) throw new Error('无效进度');
  if (progress <= 0) return 0;
  if (progress >= 1) return 1;

  // 减速段使用对称公式,减少接近终点时的浮点消减误差。
  const t = progress <= 0.5 ? progress : 1 - progress;
  const value = t * t * t * (10 + t * (-15 + 6 * t));
  return progress <= 0.5 ? value : 1 - value;
}

增加边界测试后,确实发现直接计算原多项式时,极接近终点的浮点结果可能略大于 1。上面的对称计算处理了这个问题,而不是通过放宽"无过冲"断言来掩盖它。

以真实时间推进,不按帧数累加

浏览器里的"等待 16 毫秒"并不意味着一定在 16 毫秒后恢复执行,后台节流、页面渲染和其他任务都可能造成延迟。因此每次采样都重新读取单调时钟,按已经过去的时间计算位置:

async function move(distance, durationMs, emit, checkActive) {
  if (!Number.isFinite(distance) || !Number.isFinite(durationMs) || durationMs <= 0) {
    throw new Error('无效运动参数');
  }

  const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
  checkActive();
  const start = performance.now();
  let previous = start;

  for (;;) {
    await sleep(16);
    checkActive();
    const now = performance.now();

    if (now - previous > 250) {
      throw new Error('页面停顿过久,取消拖动');
    }

    const progress = Math.min(1, (now - start) / durationMs);
    emit(progress === 1 ? distance : distance * minimumJerk(progress));
    previous = now;
    if (progress === 1) return;
  }
}

完整实现还检查时钟异常,并在取消时结束当前输入状态。本次参数选择为:

  • 主拖动计划时长随距离变化,限制在 650–1200 ms;
  • 采样目标约为 16 ms,但不补发错过的帧;
  • 如果需要小幅纠偏,也使用相同曲线,用时 140–240 ms;
  • 不添加随机抖动,不故意过冲或回拉;
  • 停顿超过 250 ms 就中止,避免恢复执行后突然跳到终点。

鼠标和触摸输入共用这套位移算法,只有事件发送方式不同。轨迹更平滑不等于验证服务一定接受,也不能直接推出通过率提高。

六、真正需要小心的是异步回调

图像算法正确,只解决了一部分问题。另一部分来自业务生命周期。

不把验证成功当成业务成功

某些页面会在验证成功回调中立即发起业务写入。因此测试时不能只关注滑块是否对齐,还必须确认回调之后会发生什么。

我把离线测试中的 SDK、业务请求和状态替换为模拟对象,真实图片仅用于算法和交互验证;独立验证测试则使用隔离的成功回调,避免把校验结果直接交给业务写入流程。

一次成功,只允许转交一次

成功回调不应被视为"必然只来一次"。在转交给业务逻辑前,需要检查:

  • 本次流程没有取消;
  • 还没有转交过成功结果;
  • 页面和选中的业务对象没有变化;
  • 当前状态仍满足执行条件。

一旦转交过,重复回调应被忽略。失败也不应进入一个可能直接提交业务操作的默认降级分支。

三个容易遗漏的边界

第一,React 的 DOM Fiber 引用可能指向旧树。 不能直接读到一份 Hooks 状态就认为它是当前状态。本次实现从根节点的 current 出发,沿已提交的 child/sibling 树查找,并自行保存祖先路径,避免旧 alternate 或过期 return 指针影响判断。这依赖于 React 内部结构;有公开状态接口时应优先使用公开接口。

第二,SDK 同步抛错,不一定能被外层 button.click() 的调用者捕获。 SDK 可能先保存回调,再抛出异常。若只在外层等待错误,晚到的成功回调仍可能继续执行。因此必须在 SDK 包装函数内部捕获启动异常,并立即关闭本次成功转交通道。

第三,成功后的晚到错误不能覆盖正在等待的业务结果。 成功已经转交、业务请求仍在途中时,迟到的验证错误不应立刻卸载响应观察器,应继续等待业务响应或明确超时,否则可能把实际成功的操作显示成验证失败。

此外,销毁组件未必会调用关闭回调。清理阶段应确保原关闭逻辑执行一次,避免页面自己的刷新计时器重新启动验证。

七、可停止、可防重,但不承诺撤回请求

工程上还需要限制自动化的作用范围:

  • 只读取页面加载的真实业务状态,不通过修改前端数据伪造执行条件;
  • 用 localStorage 保存一次性尝试记录,用同源 Web Lock 防止多标签页并发启动;
  • 失败、超时或刷新后不自动重试,需要人工确认结果后才能清除记录;
  • 请求结果不明确时显示"未知",不能把超时直接解释成失败;
  • 临时包裹的 SDK 方法、网络观察器和事件监听器都要恢复。

这里必须明确一个边界:停止后续自动化,不等于撤回原页面已经发起的请求。 请求一旦交给业务系统,结果需要由服务端状态确认。同样,本地防重复记录不能跨设备同步,也不能替代服务端幂等控制。

八、测试结果与结论边界

最终没有用真实业务写入来验证脚本,而是分层测试:

检查层次 本次结果
Node.js 单元与模拟流程测试 32 项通过
两张背景复原校验 在统一绘制条件下,与原页面画布逐像素一致
触摸输入离线集成测试 53 像素、41 个轨迹点,约 679 ms
鼠标输入离线集成复测 53 像素、41 个轨迹点,约 680 ms
轨迹性质 横向单调、无过冲、非均匀步长
每种输入模式的模拟业务请求 1 次
最终离线集成测试中的真实业务写入 0 次

测试还覆盖了中途取消、计时停顿、非法参数、重复回调、旧 React 状态、启动异常后的晚到成功,以及成功后的晚到错误。

鼠标模式曾出现提前结束,后续独立复测通过。单次通过不能证明各种调度条件下都稳定,保留停顿检测和失败停止机制,比隐藏失败更重要。

最后需要区分几种结论:

  • 图片复原正确,不等于交互事件一定被接受;
  • 独立验证成功,不等于最终业务操作成功;
  • 组件显示的耗时排名,不等于自动化成功率;
  • 离线集成通过,不等于已在扩展安装环境和生产流程中完成验证。

新版轨迹的线上接受率、真实业务提交以及最终扩展安装环境,均没有在这轮离线测试中得到验证。

九、发布前的脱敏清单

技术博客脱敏,不只是把域名替换掉,还要检查它的上下文。

信息类型 本文处理方式
企业、平台、商品、供应商名称 使用系统 A、组件 B 等泛化称呼
域名、接口、资源路径 使用保留示例域名及占位路径
业务对象、账号、会话与资源标识 删除,不保留可关联的真实编号
Cookie、请求头、密钥、验证令牌 不展示,不附原始请求日志
实际版本标识、固定排列参数、哈希 省略具体值,保留校验方法与结论
页面截图和本机路径 不引用,避免泄露头像、组织信息和文件位置
通用技术名称、公式与测试参数 保留,用于解释实现和复核方法

总结

这次实践的核心,不是一条"更像人"的轨迹,而是一条能够解释、核对和停止的执行链:

  1. 用原始资源降低截图测量的不确定性;
  2. 用实际渲染规则复原图片,并逐像素证明结果正确;
  3. 用差分、掩码和坐标换算得到可解释的位移;
  4. 用非贝塞尔的最小跃度曲线改善起停和计时行为;
  5. 用一次性回调、明确的未知状态和分层测试控制副作用。

能拖到正确位置只是起点。知道为什么正确、何时必须停止,以及哪些结论尚未验证,才是完整的工程实现。