270 lines
8.1 KiB
TypeScript
270 lines
8.1 KiB
TypeScript
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];
|