脱敏说明:本文将实际业务平台和验证供应商分别称为"业务系统 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。
这个现象不能推广成"所有浏览器都会如此",但它提醒我:
- 文件字节相同,不代表不同绘制路径的 RGBA 输出必然相同;
- 哈希不同只说明数据不同,不能直接断定切片算法错了;
- 精确比较需要尽量统一解码、绘制和像素读取条件。
另外,页面画布在这里只用于事后校验;定位算法的输入仍然是原始图片,不是直接从画布读取一个现成答案。
四、缺口定位:差分筛选,纹理确认
背景复原后,先计算完整背景与缺口背景的像素差异:
D(x, y) = max(
|R完整 - R缺口|,
|G完整 - G缺口|,
|B完整 - B缺口|
)
差异较大的区域可以帮助定位缺口,但不能只取差异区域最左侧作为答案——压缩噪声、阴影、描边和透明边距都会带来偏移。因此我采用了两阶段处理。
第一步:筛选差异区域。 对差异图设置阈值,提取主要连通区域,用于观察和核对缺口范围;候选匹配位置还需要覆盖足够多的显著差异像素。本次样本使用差异阈值 40、候选覆盖比例 70%。这些是样本相关的工程参数,不是适用于所有图像的标准。
第二步:匹配拼图内部纹理。 拼图边缘通常包含透明像素和亮色描边,直接比较整个矩形容易产生误差,所以先构造掩码:
- 保留
alpha > 250的像素; - 使用
7 × 7方形邻域腐蚀掩码,进一步去掉边缘; - 只对剩余内部像素,计算其与完整背景候选区域的均方误差。
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、请求头、密钥、验证令牌 | 不展示,不附原始请求日志 |
| 实际版本标识、固定排列参数、哈希 | 省略具体值,保留校验方法与结论 |
| 页面截图和本机路径 | 不引用,避免泄露头像、组织信息和文件位置 |
| 通用技术名称、公式与测试参数 | 保留,用于解释实现和复核方法 |
总结
这次实践的核心,不是一条"更像人"的轨迹,而是一条能够解释、核对和停止的执行链:
- 用原始资源降低截图测量的不确定性;
- 用实际渲染规则复原图片,并逐像素证明结果正确;
- 用差分、掩码和坐标换算得到可解释的位移;
- 用非贝塞尔的最小跃度曲线改善起停和计时行为;
- 用一次性回调、明确的未知状态和分层测试控制副作用。
能拖到正确位置只是起点。知道为什么正确、何时必须停止,以及哪些结论尚未验证,才是完整的工程实现。