| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| import type { Page, Locator } from "playwright"; |
| import { humanClick } from "./lib/mouse-cursor.js"; |
|
|
| export interface HumanTypingOptions { |
| |
| baseDelay?: number; |
| |
| jitter?: number; |
| |
| typoRate?: number; |
| |
| thinkRate?: number; |
| |
| speed?: number; |
| |
| |
| |
| |
| |
| |
| beforeKey?: () => void | Promise<void>; |
| } |
|
|
| const DEFAULTS: Required<Omit<HumanTypingOptions, "beforeKey">> = { |
| baseDelay: 55, |
| jitter: 85, |
| typoRate: 0.025, |
| thinkRate: 0.18, |
| speed: 1, |
| }; |
|
|
| function rand(min: number, max: number): number { |
| return min + Math.random() * (max - min); |
| } |
|
|
| function sleep(ms: number): Promise<void> { |
| return new Promise((r) => setTimeout(r, ms)); |
| } |
|
|
| |
| const TYPO_MAP: Record<string, string> = { |
| a: "s", b: "v", c: "v", d: "s", e: "r", f: "d", g: "h", h: "j", |
| i: "o", j: "k", k: "l", l: "k", m: "n", n: "m", o: "p", p: "o", |
| q: "w", r: "t", s: "a", t: "y", u: "i", v: "b", w: "e", x: "c", |
| y: "u", z: "x", |
| }; |
|
|
| function typoFor(ch: string): string | null { |
| const lower = ch.toLowerCase(); |
| const neighbor = TYPO_MAP[lower]; |
| if (!neighbor) return null; |
| return ch === lower ? neighbor : neighbor.toUpperCase(); |
| } |
|
|
| |
| |
| |
| |
| |
| export async function humanType( |
| page: Page, |
| text: string, |
| options: HumanTypingOptions = {} |
| ): Promise<void> { |
| const { baseDelay, jitter, typoRate, thinkRate, speed } = { ...DEFAULTS, ...options }; |
| const beforeKey = options.beforeKey; |
| const scale = 1 / speed; |
|
|
| |
| |
| |
| |
| const tap = async (fn: () => Promise<void>) => { |
| if (beforeKey) await beforeKey(); |
| await fn(); |
| }; |
|
|
| const tokens = text.split(/(\s+)/); |
|
|
| for (let t = 0; t < tokens.length; t++) { |
| const token = tokens[t]; |
| if (!token) continue; |
|
|
| if (/^\s+$/.test(token)) { |
| |
| |
| |
| for (const ws of token) { |
| await tap(() => page.keyboard.type(ws, { delay: 0 })); |
| } |
| if (Math.random() < thinkRate) { |
| await sleep(rand(180, 520) * scale); |
| } |
| continue; |
| } |
|
|
| const wordSpeed = token.length > 7 ? rand(1.0, 1.3) : rand(0.7, 1.0); |
|
|
| for (let i = 0; i < token.length; i++) { |
| const ch = token[i]; |
| const delay = (baseDelay + Math.random() * jitter) * wordSpeed * scale; |
|
|
| const shouldTypo = Math.random() < typoRate && /[a-zA-Z]/.test(ch); |
| if (shouldTypo) { |
| const wrong = typoFor(ch); |
| if (wrong) { |
| await tap(() => page.keyboard.type(wrong, { delay })); |
| await sleep(rand(90, 260) * scale); |
| await tap(() => page.keyboard.press("Backspace")); |
| await sleep(rand(60, 180) * scale); |
| } |
| } |
|
|
| await tap(() => page.keyboard.type(ch, { delay })); |
|
|
| if (/[,;:]/.test(ch)) { |
| await sleep(rand(280, 700) * scale); |
| } else if (/[.!?]/.test(ch)) { |
| await sleep(rand(600, 1400) * scale); |
| } |
| } |
| } |
| } |
|
|
| |
| |
| |
| export async function humanTypeInto( |
| page: Page, |
| target: Locator, |
| text: string, |
| options: HumanTypingOptions = {} |
| ): Promise<void> { |
| await target.waitFor({ state: "visible" }); |
| await humanClick(page, target); |
| await sleep(rand(250, 550)); |
| await humanType(page, text, options); |
| } |
|
|
| |
| |
| |
| export function pause(min: number, max: number = min): Promise<void> { |
| return sleep(rand(min, max)); |
| } |
|
|