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];
|