import type { Page } from "playwright"; export type HumanPauseKind = | "micro" | "short" | "medium" | "long" | "read" | "navigate" | "betweenPages" | "betweenTasks"; const PAUSE_RANGES: Record = { micro: [120, 420], short: [700, 1800], medium: [1800, 4200], long: [3500, 7500], read: [2800, 9000], navigate: [2200, 5500], betweenPages: [5000, 14000], betweenTasks: [10000, 24000], }; const PAUSE_NEIGHBORS: Partial> = { micro: ["short"], short: ["micro", "medium"], medium: ["short", "long", "read"], long: ["medium", "read"], read: ["medium", "long"], navigate: ["medium", "short"], betweenPages: ["long", "betweenTasks"], betweenTasks: ["betweenPages", "long"], }; /** 每次執行略有不同,避免整段任務節奏固定。 */ let sessionDrift = 0.82 + Math.random() * 0.38; function delayMultiplier(): number { const raw = process.env.THREADS_HUMAN_DELAY_MULTIPLIER; if (!raw) return 1; const parsed = Number(raw); return Number.isFinite(parsed) && parsed > 0 ? Math.max(0.75, Math.min(parsed, 3)) : 1; } function randomBetween(min: number, max: number): number { return Math.floor(Math.random() * (max - min + 1)) + min; } /** 近似常態分佈,比均勻亂數更像真人節奏。 */ function gaussianBetween(min: number, max: number): number { const span = max - min; const u = (randomBetween(0, 1000) + randomBetween(0, 1000) + randomBetween(0, 1000)) / 3000; return Math.round(min + u * span); } function maybeRefreshDrift(): void { if (Math.random() < 0.1) { sessionDrift = 0.82 + Math.random() * 0.38; } } /** 在基準毫秒上加減百分比抖動。 */ export function jitterMs(baseMs: number, spread = 0.3): number { maybeRefreshDrift(); const mult = delayMultiplier() * sessionDrift; const factor = 1 + (Math.random() * 2 - 1) * spread; return Math.max(60, Math.round(baseMs * factor * mult)); } function pickPauseKind(preferred: HumanPauseKind): HumanPauseKind { if (Math.random() < 0.18) { const neighbors = PAUSE_NEIGHBORS[preferred]; if (neighbors?.length) { return neighbors[randomBetween(0, neighbors.length - 1)]; } } return preferred; } function pauseRangeMs(kind: HumanPauseKind): number { const [min, max] = PAUSE_RANGES[kind]; // jitterMs 已經套用全域倍率與 session drift;不要在這裡重複乘一次。 return jitterMs(gaussianBetween(min, max), 0.12); } async function sleep(ms: number): Promise { await new Promise((resolve) => setTimeout(resolve, ms)); } /** 任務間隔:每次呼叫都重新抽樣,不會固定在同一秒數。 */ export function computeStaggerMs(range: [number, number]): number { const base = gaussianBetween(range[0], range[1]); let ms = jitterMs(base, 0.28); if (Math.random() < 0.14) { ms += jitterMs(randomBetween(1200, 4800), 0.35); } if (Math.random() < 0.06) { ms = Math.round(ms * (0.55 + Math.random() * 0.25)); } return ms; } /** 隨機停留,模擬真人閱讀/思考;節奏每次不同。 */ export async function humanPause(kind: HumanPauseKind = "medium"): Promise { maybeRefreshDrift(); const resolved = pickPauseKind(kind); let ms = pauseRangeMs(resolved); const distractChance = 0.08 + Math.random() * 0.12; if (Math.random() < distractChance) { ms += pauseRangeMs(Math.random() < 0.5 ? "short" : "medium"); } if (Math.random() < 0.16) { const first = Math.round(ms * (0.35 + Math.random() * 0.3)); const second = ms - first + jitterMs(randomBetween(80, 320), 0.4); await sleep(first); await sleep(Math.max(60, second)); return; } await sleep(ms); } async function humanMouseWander(page: Page, moves = 1): Promise { const viewport = page.viewportSize() ?? { width: 1280, height: 900 }; const count = Math.max(1, moves); for (let m = 0; m < count; m++) { const xSpread = 0.12 + Math.random() * 0.2; const ySpread = 0.12 + Math.random() * 0.22; await page.mouse.move( randomBetween( Math.floor(viewport.width * (0.15 + Math.random() * xSpread)), Math.floor(viewport.width * (0.55 + Math.random() * xSpread)) ), randomBetween( Math.floor(viewport.height * (0.12 + Math.random() * ySpread)), Math.floor(viewport.height * (0.5 + Math.random() * ySpread)) ), { steps: randomBetween(4, 14) } ); if (m < count - 1) await humanPause("micro"); } } /** 進入新頁面後先「站一下」再開始操作;每次動作組合不同。 */ export async function humanLandingPause(page: Page): Promise { maybeRefreshDrift(); if (Math.random() < 0.22) { await humanPause("short"); await humanPause("navigate"); } else { await humanPause("navigate"); } if (Math.random() < 0.18) { const nudge = randomBetween(40, 180) * (Math.random() < 0.5 ? 1 : -1); await page.mouse.wheel(0, nudge); await humanPause("micro"); } const moveChance = 0.28 + Math.random() * 0.42; if (Math.random() < moveChance) { const moves = Math.random() < 0.25 ? 2 : 1; await humanMouseWander(page, moves); await humanPause("micro"); } if (Math.random() < 0.1) { await humanPause(Math.random() < 0.5 ? "medium" : "short"); } } function pickScrollPauseKind(): HumanPauseKind { const pool: HumanPauseKind[] = ["micro", "short", "medium", "read"]; const weights = [0.15, 0.35, 0.32, 0.18]; const roll = Math.random(); let acc = 0; for (let i = 0; i < pool.length; i++) { acc += weights[i]; if (roll < acc) return pool[i]; } return "short"; } async function humanWheelStep(page: Page, deltaY: number): Promise { const chunks = randomBetween(1, 4); let remaining = deltaY; for (let c = 0; c < chunks; c++) { const portion = c === chunks - 1 ? remaining : Math.round((remaining / (chunks - c)) * (0.55 + Math.random() * 0.55)); remaining -= portion; await page.mouse.wheel( Math.random() < 0.12 ? randomBetween(-25, 25) : 0, portion ); if (c < chunks - 1 || Math.random() < 0.3) { await humanPause("micro"); } } } /** 不規則捲動+停留,每次路徑與節奏都不同。 */ export async function humanScrollPage( page: Page, options?: { minPasses?: number; maxPasses?: number } ): Promise { maybeRefreshDrift(); const minPasses = options?.minPasses ?? 2; const maxPasses = options?.maxPasses ?? 5; const passes = randomBetween(minPasses, maxPasses + randomBetween(0, 1)); const viewport = page.viewportSize() ?? { width: 1280, height: 900 }; if (Math.random() < 0.2) { await humanMouseWander(page, randomBetween(1, 2)); } for (let i = 0; i < passes; i++) { if (i > 0 && Math.random() < 0.12) { await humanPause(pickScrollPauseKind()); } const scrollAmount = randomBetween(180, 1100); const scrollUp = i > 0 && Math.random() < (0.12 + Math.random() * 0.14); await humanWheelStep(page, scrollUp ? -scrollAmount : scrollAmount); if (Math.random() < 0.28 + Math.random() * 0.22) { await humanMouseWander(page, Math.random() < 0.2 ? 2 : 1); } if (Math.random() < 0.08) { await page.mouse.move( randomBetween(Math.floor(viewport.width * 0.1), Math.floor(viewport.width * 0.9)), randomBetween(Math.floor(viewport.height * 0.15), Math.floor(viewport.height * 0.85)), { steps: randomBetween(3, 10) } ); } await humanPause(pickScrollPauseKind()); if (Math.random() < 0.09) { await humanWheelStep(page, randomBetween(60, 240) * (Math.random() < 0.4 ? -1 : 1)); await humanPause("micro"); } } } /** 平行海巡上限:預設安全雙工,可用環境變數降回 1;硬上限 2。 */ export function getBrowserConcurrency(): number { const parsed = Number.parseInt(process.env.THREADS_BROWSER_CONCURRENCY ?? "2", 10); return Number.isFinite(parsed) ? Math.max(1, Math.min(parsed, 2)) : 2; } export function getReplyFetchConcurrency(): number { return 1; } export const DEFAULT_STAGGER_MS: [number, number] = [3500, 9000]; export const DEFAULT_TASK_STAGGER_MS: [number, number] = [3500, 7500];