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,478 @@
|
||||
// Integration tests for humanClick, humanType, humanScroll / 仿人类输入模拟集成测试
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium } from 'playwright-extra';
|
||||
import StealthPlugin from 'puppeteer-extra-plugin-stealth';
|
||||
import type { Browser, Page } from 'playwright';
|
||||
import type { FingerprintProfile } from '@visionl/core';
|
||||
import { humanClick, humanType, humanScroll, randBetween } from '../stealth/human-input.js';
|
||||
|
||||
chromium.use(StealthPlugin());
|
||||
|
||||
const integration =
|
||||
process.env.VISIONL_INTEGRATION === '1' ? describe : describe.skip;
|
||||
|
||||
const testProfile: FingerprintProfile = {
|
||||
id: 'fp_human_input',
|
||||
name: 'Human Input Test',
|
||||
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
platform: 'Win32',
|
||||
languages: ['en-US', 'en'],
|
||||
acceptLanguage: 'en-US,en;q=0.9',
|
||||
screen: { width: 1920, height: 1080, colorDepth: 24, pixelRatio: 1 },
|
||||
viewport: { width: 1280, height: 720 },
|
||||
webgl: { vendor: 'Google Inc.', renderer: 'ANGLE (NVIDIA GeForce RTX 3060)' },
|
||||
timezone: 'America/New_York',
|
||||
permissions: {
|
||||
notifications: 'denied',
|
||||
geolocation: 'granted',
|
||||
camera: 'denied',
|
||||
microphone: 'denied',
|
||||
},
|
||||
behavior: {
|
||||
mouseMoveDelay: { min: 50, max: 150 },
|
||||
keyPressDelay: { min: 80, max: 200 },
|
||||
scrollStepDelay: { min: 30, max: 100 },
|
||||
},
|
||||
canvasNoise: { enabled: true, strength: 0.5 },
|
||||
};
|
||||
|
||||
async function installEventTracker(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const recorded: Array<{ type: string; timestamp: number }> = [];
|
||||
(window as any).__vlEvents = recorded;
|
||||
|
||||
const track = (e: Event) => {
|
||||
recorded.push({ type: e.type, timestamp: Date.now() });
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', track, true);
|
||||
document.addEventListener('mousedown', track, true);
|
||||
document.addEventListener('mouseup', track, true);
|
||||
document.addEventListener('click', track, true);
|
||||
document.addEventListener('keydown', track, true);
|
||||
document.addEventListener('keypress', track, true);
|
||||
document.addEventListener('keyup', track, true);
|
||||
document.addEventListener('input', track, true);
|
||||
document.addEventListener('wheel', track, true);
|
||||
document.addEventListener('scroll', track, true);
|
||||
});
|
||||
}
|
||||
|
||||
async function getRecordedEvents(page: Page): Promise<Array<{ type: string; timestamp: number }>> {
|
||||
return page.evaluate(() => (window as any).__vlEvents || []);
|
||||
}
|
||||
|
||||
// Helper: start a simple page with a button and an input / 启动带按钮和输入框的简单页面
|
||||
async function setupTestPage(page: Page): Promise<void> {
|
||||
await page.setContent(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Human Input Test</title></head>
|
||||
<body style="height:3000px;">
|
||||
<button id="target-btn" style="margin:100px;padding:20px;">Click Me</button>
|
||||
<input id="target-input" type="text" value="" style="margin:100px;padding:10px;font-size:16px;">
|
||||
<input id="target-input2" type="text" value="prefill" style="margin:100px;">
|
||||
<div id="bottom-marker" style="margin-top:2800px;">Bottom</div>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
await page.waitForSelector('#target-btn', { state: 'visible' });
|
||||
await installEventTracker(page);
|
||||
}
|
||||
|
||||
// ==================== humanClick tests / humanClick 测试 ====================
|
||||
integration('humanClick', () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
});
|
||||
|
||||
it('should dispatch mouse event sequence: mousemove → mousedown → mouseup → click', async () => {
|
||||
const context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
await setupTestPage(page);
|
||||
|
||||
await humanClick(page, '#target-btn', testProfile);
|
||||
// Give async event handling a moment / 等待异步事件处理完成
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const events = await getRecordedEvents(page);
|
||||
|
||||
// Check event types exist in order / 验证事件类型存在且顺序正确
|
||||
const eventTypes = events.map((e) => e.type);
|
||||
|
||||
const hasMouseMove = eventTypes.some((t) => t === 'mousemove');
|
||||
const hasMouseDown = eventTypes.some((t) => t === 'mousedown');
|
||||
const hasMouseUp = eventTypes.some((t) => t === 'mouseup');
|
||||
const hasClick = eventTypes.some((t) => t === 'click');
|
||||
|
||||
expect(hasMouseMove).toBe(true);
|
||||
expect(hasMouseDown).toBe(true);
|
||||
expect(hasMouseUp).toBe(true);
|
||||
expect(hasClick).toBe(true);
|
||||
|
||||
// Verify order: mousemove before mousedown, mousedown before mouseup, mouseup before click
|
||||
// 验证事件顺序
|
||||
const moveIdx = eventTypes.indexOf('mousemove');
|
||||
const downIdx = eventTypes.indexOf('mousedown');
|
||||
const upIdx = eventTypes.indexOf('mouseup');
|
||||
const clickIdx = eventTypes.indexOf('click');
|
||||
|
||||
expect(moveIdx).toBeLessThan(downIdx);
|
||||
expect(downIdx).toBeLessThan(upIdx);
|
||||
expect(upIdx).toBeLessThan(clickIdx);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
it('should produce multiple mousemove events along the path', async () => {
|
||||
const context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
await setupTestPage(page);
|
||||
|
||||
await humanClick(page, '#target-btn', testProfile);
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const events = await getRecordedEvents(page);
|
||||
const moveEvents = events.filter((e) => e.type === 'mousemove');
|
||||
|
||||
// Should have at least 2 mousemove events (start + approach) / 至少2个 mousemove 事件
|
||||
expect(moveEvents.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
it('should throw for non-existent selector / 不存在选择器应报错', async () => {
|
||||
const context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
await setupTestPage(page);
|
||||
|
||||
await expect(
|
||||
humanClick(page, '#non-existent-element', testProfile),
|
||||
).rejects.toThrow(/element not found/i);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
it('should click within element bounds / 应在元素范围内点击', async () => {
|
||||
const context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
await setupTestPage(page);
|
||||
|
||||
// Track click coordinates / 记录点击坐标
|
||||
const clickCoords = await page.evaluate(() => {
|
||||
return new Promise<{ x: number; y: number }>((resolve) => {
|
||||
const btn = document.getElementById('target-btn')!;
|
||||
btn.addEventListener('click', (e) => {
|
||||
resolve({ x: (e as MouseEvent).clientX, y: (e as MouseEvent).clientY });
|
||||
}, { once: true });
|
||||
});
|
||||
});
|
||||
|
||||
const box = await page.locator('#target-btn').boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
|
||||
// Trigger humanClick and capture coordinates / 触发点击并捕获坐标
|
||||
await humanClick(page, '#target-btn', testProfile);
|
||||
const coords = await clickCoords;
|
||||
|
||||
// Click should be within element bounds (±10px tolerance for jitter) / 点击应在元素范围内
|
||||
expect(coords.x).toBeGreaterThanOrEqual(box!.x - 10);
|
||||
expect(coords.x).toBeLessThanOrEqual(box!.x + box!.width + 10);
|
||||
expect(coords.y).toBeGreaterThanOrEqual(box!.y - 10);
|
||||
expect(coords.y).toBeLessThanOrEqual(box!.y + box!.height + 10);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== humanType tests / humanType 测试 ====================
|
||||
integration('humanType', () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
});
|
||||
|
||||
it('should type text into the input element', async () => {
|
||||
const context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
await setupTestPage(page);
|
||||
|
||||
await humanType(page, '#target-input', 'Hello', testProfile);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const value = await page.locator('#target-input').inputValue();
|
||||
expect(value).toBe('Hello');
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
it('should dispatch keydown → keypress → keyup for each character / 每个字符应触发 keydown → keypress → keyup', async () => {
|
||||
const context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
await setupTestPage(page);
|
||||
|
||||
await humanType(page, '#target-input', 'AB', testProfile);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const events = await getRecordedEvents(page);
|
||||
const eventTypes = events.map((e) => e.type);
|
||||
|
||||
const keydownCount = eventTypes.filter((t) => t === 'keydown').length;
|
||||
const keypressCount = eventTypes.filter((t) => t === 'keypress').length;
|
||||
const keyupCount = eventTypes.filter((t) => t === 'keyup').length;
|
||||
|
||||
// Each character should produce at least 1 of each event / 每个字符至少产生1个事件
|
||||
expect(keydownCount).toBeGreaterThanOrEqual(2);
|
||||
expect(keypressCount).toBeGreaterThanOrEqual(2);
|
||||
expect(keyupCount).toBeGreaterThanOrEqual(2);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
it('should clear pre-existing text before typing / 应在输入前清除已有文本', async () => {
|
||||
const context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
await setupTestPage(page);
|
||||
|
||||
// Pre-fill the input / 预填充输入框
|
||||
await page.locator('#target-input2').fill('oldvalue');
|
||||
const beforeVal = await page.locator('#target-input2').inputValue();
|
||||
expect(beforeVal).toBe('oldvalue');
|
||||
|
||||
await humanType(page, '#target-input2', 'New', testProfile);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const afterVal = await page.locator('#target-input2').inputValue();
|
||||
expect(afterVal).toBe('New');
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
it('should produce inter-character delays within configured range / 字符间延迟应在配置范围内', async () => {
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 720 } });
|
||||
page = await context.newPage();
|
||||
await setupTestPage(page);
|
||||
|
||||
// Track key events with high-res timestamps / 用高精度时间戳跟踪按键事件
|
||||
const keyTimestamps = await page.evaluate(() => {
|
||||
return new Promise<number[]>((resolve) => {
|
||||
const timestamps: number[] = [];
|
||||
const input = document.getElementById('target-input')!;
|
||||
input.addEventListener('keydown', () => {
|
||||
timestamps.push(performance.now());
|
||||
});
|
||||
// Resolve after all characters processed / 所有字符处理后结束
|
||||
const observer = new MutationObserver(() => {
|
||||
const val = (input as HTMLInputElement).value;
|
||||
if (val.length >= 3) {
|
||||
observer.disconnect();
|
||||
// Give a little more time for the last events / 给最后的事件留时间
|
||||
setTimeout(() => resolve(timestamps), 100);
|
||||
}
|
||||
});
|
||||
observer.observe(input, { attributes: true, attributeFilter: ['value'] });
|
||||
});
|
||||
});
|
||||
|
||||
// Type a short string and capture timestamps / 输入短字符串并捕获时间戳
|
||||
const typePromise = humanType(page, '#target-input', 'ABC', testProfile);
|
||||
const timestamps = await keyTimestamps;
|
||||
await typePromise;
|
||||
|
||||
// Calculate inter-key delays / 计算按键间延迟
|
||||
expect(timestamps.length).toBeGreaterThanOrEqual(3);
|
||||
|
||||
// Verify each inter-key delay is reasonable (> 0ms, < 1000ms)
|
||||
// 验证每个按键间延迟合理
|
||||
for (let i = 1; i < timestamps.length; i++) {
|
||||
const delay = timestamps[i] - timestamps[i - 1];
|
||||
expect(delay).toBeGreaterThan(0);
|
||||
// Should be within a reasonable upper bound given the profile
|
||||
// 考虑配置范围内应有合理上限
|
||||
expect(delay).toBeLessThan(1000);
|
||||
}
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
it('should handle empty string gracefully / 应优雅处理空字符串', async () => {
|
||||
const context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
await setupTestPage(page);
|
||||
|
||||
await humanType(page, '#target-input', '', testProfile);
|
||||
await page.waitForTimeout(200);
|
||||
|
||||
const value = await page.locator('#target-input').inputValue();
|
||||
// After clearing, the input should be empty / 清除后输入框应为空
|
||||
expect(value).toBe('');
|
||||
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== humanScroll tests / humanScroll 测试 ====================
|
||||
integration('humanScroll', () => {
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
});
|
||||
|
||||
it('should dispatch scroll events / 应触发 scroll 事件', async () => {
|
||||
const context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
await setupTestPage(page);
|
||||
|
||||
await humanScroll(page, { deltaY: 300 }, testProfile);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const events = await getRecordedEvents(page);
|
||||
const scrollEvents = events.filter((e) => e.type === 'scroll');
|
||||
// Should have at least one scroll event / 至少有一个 scroll 事件
|
||||
expect(scrollEvents.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
it('should change scroll position with deltaY / deltaY 应改变滚动位置', async () => {
|
||||
const context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
await setupTestPage(page);
|
||||
|
||||
const beforeY = await page.evaluate(() => window.scrollY);
|
||||
await humanScroll(page, { deltaY: 400 }, testProfile);
|
||||
await page.waitForTimeout(300);
|
||||
const afterY = await page.evaluate(() => window.scrollY);
|
||||
|
||||
// Should scroll down / 应向下滚动
|
||||
expect(afterY).toBeGreaterThan(beforeY);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
it('should scroll to bottom with toBottom option / toBottom 选项应滚动到底', async () => {
|
||||
const context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
await setupTestPage(page);
|
||||
|
||||
await humanScroll(page, { toBottom: true }, testProfile);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const atBottom = await page.evaluate(() => {
|
||||
return window.innerHeight + window.scrollY >= document.body.scrollHeight - 5;
|
||||
});
|
||||
expect(atBottom).toBe(true);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
it('should dispatch wheel events for realism / 应触发 wheel 事件以实现真实性', async () => {
|
||||
const context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
await setupTestPage(page);
|
||||
|
||||
await humanScroll(page, { deltaY: 200 }, testProfile);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const events = await getRecordedEvents(page);
|
||||
const wheelEvents = events.filter((e) => e.type === 'wheel');
|
||||
// Wheel events simulate real user scrolling / wheel 事件模拟真实用户滚动
|
||||
expect(wheelEvents.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
it('should not error when toBottom on short page / toBottom 在短页面不应报错', async () => {
|
||||
const context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
await page.setContent(`<html><body style="height:200px;"><p>Short page</p></body></html>`);
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
await installEventTracker(page);
|
||||
|
||||
// Should complete without throwing / 应无错误完成
|
||||
await expect(
|
||||
humanScroll(page, { toBottom: true }, testProfile),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
it('should scroll in multiple steps for large deltas / 大增量应分步滚动', async () => {
|
||||
const context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
await setupTestPage(page);
|
||||
|
||||
// Large delta should be broken into chunks / 大增量应分块
|
||||
await humanScroll(page, { deltaY: 800 }, testProfile);
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
const events = await getRecordedEvents(page);
|
||||
const wheelEvents = events.filter((e) => e.type === 'wheel');
|
||||
// Should have multiple wheel events for large scroll / 大滚动应有多个 wheel 事件
|
||||
expect(wheelEvents.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== randBetween unit tests / randBetween 单元测试 ====================
|
||||
describe('randBetween', () => {
|
||||
it('should return a value within [min, max] range / 应在 [min, max] 范围内返回值', () => {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const val = randBetween(10, 20);
|
||||
expect(val).toBeGreaterThanOrEqual(10);
|
||||
expect(val).toBeLessThanOrEqual(20);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return an integer / 应返回整数', () => {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const val = randBetween(0, 100);
|
||||
expect(Number.isInteger(val)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return min when min === max / min 等于 max 应返回该值', () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
expect(randBetween(5, 5)).toBe(5);
|
||||
}
|
||||
});
|
||||
|
||||
it('should produce varied values over many calls / 多次调用产生不同值', () => {
|
||||
const values = new Set<number>();
|
||||
for (let i = 0; i < 50; i++) {
|
||||
values.add(randBetween(1, 100));
|
||||
}
|
||||
// With 100 possible values and 50 calls, expect at least some variation
|
||||
// 100种可能值、50次调用,至少应有变化
|
||||
expect(values.size).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
@@ -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