6085c9e65a
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.
203 lines
6.8 KiB
TypeScript
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,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|