// 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 { 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 { // 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 { 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 { // 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 { 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, ), ); } } } }