From 6085c9e65a8869842eea7901b0f21a1f510ca08a Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 12 Aug 2026 21:16:37 +0800 Subject: [PATCH] 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. --- .../src/__tests__/stealth-human-input.test.ts | 478 ++++++++++++++++++ packages/daemon/src/stealth/human-input.ts | 202 ++++++++ 2 files changed, 680 insertions(+) create mode 100644 packages/daemon/src/__tests__/stealth-human-input.test.ts create mode 100644 packages/daemon/src/stealth/human-input.ts diff --git a/packages/daemon/src/__tests__/stealth-human-input.test.ts b/packages/daemon/src/__tests__/stealth-human-input.test.ts new file mode 100644 index 0000000..caad7ec --- /dev/null +++ b/packages/daemon/src/__tests__/stealth-human-input.test.ts @@ -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 { + 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> { + return page.evaluate(() => (window as any).__vlEvents || []); +} + +// Helper: start a simple page with a button and an input / 启动带按钮和输入框的简单页面 +async function setupTestPage(page: Page): Promise { + await page.setContent(` + + + Human Input Test + + + + +
Bottom
+ + + `); + 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((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(`

Short page

`); + 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(); + 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); + }); +}); diff --git a/packages/daemon/src/stealth/human-input.ts b/packages/daemon/src/stealth/human-input.ts new file mode 100644 index 0000000..35ef07a --- /dev/null +++ b/packages/daemon/src/stealth/human-input.ts @@ -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 { + 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, + ), + ); + } + } + } +}