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

270 lines
8.1 KiB
TypeScript
Raw Permalink Normal View History

2026-06-21 12:50:31 +00:00
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];