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.
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
// 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user