Files
VisionL/packages/daemon/src/stealth/human-input.ts
T
AskaEth 6085c9e65a feat(daemon): implement stealth human input simulation (Task 15)
Add humanClick, humanType, humanScroll with mouse path interpolation,
random key delays, and step-based scrolling for bot-like behavior.
Includes integration tests for event sequence verification.
2026-08-12 21:16:37 +08:00

203 lines
6.8 KiB
TypeScript

// Stealth — Human-like mouse and keyboard input simulation / 隐身 — 仿人类鼠标键盘输入模拟
import type { Page } from 'playwright';
import type { FingerprintProfile } from '@visionl/core';
// Random int in [min, max] inclusive / [min, max] 闭区间随机整数
export function randBetween(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
// Helper: sleep for a given milliseconds / 等待指定毫秒
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Moves mouse along a linear path from (x1,y1) to (x2,y2) with mousemove events.
* @param page Playwright Page
* @param x1 Start X
* @param y1 Start Y
* @param x2 End X
* @param y2 End Y
* @param delayMs Delay between each mousemove step in ms / 每步之间的延迟(毫秒)
*/
async function moveAlongPath(
page: Page,
x1: number,
y1: number,
x2: number,
y2: number,
delayMs: number,
): Promise<void> {
// Number of steps / 步数 (10ms interval → number of events = duration / 10)
const steps = Math.max(Math.round(delayMs / 10), 2);
for (let i = 1; i <= steps; i++) {
const t = i / steps;
const cx = Math.round(x1 + (x2 - x1) * t);
const cy = Math.round(y1 + (y2 - y1) * t);
await page.mouse.move(cx, cy);
await sleep(10);
}
}
/**
* Simulates a human-like mouse click on an element.
* Moves mouse from a random start position to the target center (±5px jitter),
* then dispatches mousedown → mouseup → click.
*
* @param page Playwright Page
* @param selector CSS selector for the target element / 目标元素的 CSS 选择器
* @param profile Fingerprint profile with behavior delays / 含行为延迟的指纹配置
*/
export async function humanClick(
page: Page,
selector: string,
profile: FingerprintProfile,
): Promise<void> {
const box = await page.locator(selector).boundingBox();
if (!box) {
throw new Error(`humanClick: element not found or invisible for selector "${selector}" / 未找到或不可见的元素`);
}
// Target center with jitter / 目标中心加抖动
const targetX = Math.round(box.x + box.width / 2) + randBetween(-5, 5);
const targetY = Math.round(box.y + box.height / 2) + randBetween(-5, 5);
// Random start position on the page / 页面上的随机起始位置
const viewport = page.viewportSize();
const vw = viewport ? viewport.width : 1280;
const vh = viewport ? viewport.height : 720;
const startX = randBetween(0, vw - 1);
const startY = randBetween(0, vh - 1);
// Move mouse along path / 沿路径移动鼠标
const moveDuration = randBetween(
profile.behavior.mouseMoveDelay.min,
profile.behavior.mouseMoveDelay.max,
);
await moveAlongPath(page, startX, startY, targetX, targetY, moveDuration);
// Click at target / 在目标位置点击
await page.mouse.move(targetX, targetY);
await page.mouse.down();
await sleep(randBetween(20, 60));
await page.mouse.up();
await page.mouse.click(targetX, targetY);
}
/**
* Simulates human-like typing into an element.
* Focuses the element first, then for each character dispatches:
* keydown → (random delay 50-150ms) → keypress → (10ms) → keyup
*
* @param page Playwright Page
* @param selector CSS selector for the target input element / 目标输入元素的 CSS 选择器
* @param text Text to type / 要输入的文本
* @param profile Fingerprint profile with behavior delays / 含行为延迟的指纹配置
*/
export async function humanType(
page: Page,
selector: string,
text: string,
profile: FingerprintProfile,
): Promise<void> {
// Focus the element by clicking it / 通过点击聚焦元素
const locator = page.locator(selector);
await locator.click();
// Clear existing text (optional, but common for form fields) / 清除现有文本
// We use triple-click + Backspace for natural clearing
await locator.click({ clickCount: 3 });
await page.keyboard.press('Backspace');
for (let i = 0; i < text.length; i++) {
const char = text[i];
// Keydown / 按下
await page.keyboard.down(char);
// Random delay between keydown and keypress / keydown 与 keypress 之间的随机延迟
await sleep(randBetween(50, 150));
// Keypress — use insertText for reliable character input / 使用 insertText 实现可靠输入
await page.keyboard.insertText(char);
// Small fixed delay before keyup / keyup 前的小固定延迟
await sleep(10);
// Keyup / 释放
await page.keyboard.up(char);
// Inter-character delay from profile / 字符间延迟根据配置
if (i < text.length - 1) {
await sleep(
randBetween(
profile.behavior.keyPressDelay.min,
profile.behavior.keyPressDelay.max,
),
);
}
}
}
/**
* Simulates human-like scrolling.
* If `toBottom`: scrolls in 100-200px steps with delays until the bottom is reached.
* If `deltaY`: scrolls that amount in chunks with random delays.
*
* @param page Playwright Page
* @param opts { deltaY?: number; toBottom?: boolean }
* @param profile Fingerprint profile with behavior delays / 含行为延迟的指纹配置
*/
export async function humanScroll(
page: Page,
opts: { deltaY?: number; toBottom?: boolean },
profile: FingerprintProfile,
): Promise<void> {
if (opts.toBottom) {
// Scroll to bottom in steps / 分步滚动到底部
let reachedBottom = false;
while (!reachedBottom) {
const beforeScroll = await page.evaluate(() => window.scrollY);
const step = randBetween(100, 200);
// Dispatch wheel event for realism / 分发真实的 wheel 事件
await page.mouse.wheel(0, step);
// Small delay between steps / 步间小延迟
await sleep(
randBetween(
profile.behavior.scrollStepDelay.min,
profile.behavior.scrollStepDelay.max,
),
);
const afterScroll = await page.evaluate(() => window.scrollY);
// Also check if we're at page bottom / 同时检查是否已到底
const atBottom = await page.evaluate(() => {
return window.innerHeight + window.scrollY >= document.body.scrollHeight;
});
if (afterScroll === beforeScroll || atBottom) {
reachedBottom = true;
}
}
} else if (opts.deltaY !== undefined) {
// Scroll a specific amount in chunks / 分块滚动指定距离
const total = Math.abs(opts.deltaY);
const direction = Math.sign(opts.deltaY);
let remaining = total;
while (remaining > 0) {
const chunk = Math.min(randBetween(50, 200), remaining);
await page.mouse.wheel(0, direction * chunk);
remaining -= chunk;
if (remaining > 0) {
await sleep(
randBetween(
profile.behavior.scrollStepDelay.min,
profile.behavior.scrollStepDelay.max,
),
);
}
}
}
}