haixunMaster/lib/threads-browser/human-behavior.ts

270 lines
8.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { Page } from "playwright";
export type HumanPauseKind =
| "micro"
| "short"
| "medium"
| "long"
| "read"
| "navigate"
| "betweenPages"
| "betweenTasks";
const PAUSE_RANGES: Record<HumanPauseKind, [number, number]> = {
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<Record<HumanPauseKind, HumanPauseKind[]>> = {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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];