import { canUseDailyQuota, getDailyQuotaUsed, incrementDailyQuota } from "@/lib/search/quota"; import type { ScanTask } from "@/lib/services/scan-tasks"; import type { Page } from "playwright"; import { captureDebugStep, createDebugRun } from "./debug"; let crawlerBlockedUntil = 0; function blockCooldownMs(): number { return envInt("CRAWLER_BLOCK_COOLDOWN_MINUTES", 180) * 60_000; } function assertCrawlerCooldown(): void { if (Date.now() < crawlerBlockedUntil) { const minutes = Math.max(1, Math.ceil((crawlerBlockedUntil - Date.now()) / 60_000)); throw new Error(`Threads 爬蟲已進入保護冷卻,約 ${minutes} 分鐘後再試。期間可改用官方 API 或 Brave。`); } } function envInt(key: string, fallback: number): number { const value = Number.parseInt(process.env[key] ?? "", 10); return Number.isFinite(value) && value > 0 ? value : fallback; } export function getCrawlerSafetyLimits() { return { maxTasksPerScan: Math.min(envInt("CRAWLER_MAX_TASKS_PER_SCAN", 4), 6), maxPostsPerTask: Math.min(envInt("CRAWLER_MAX_POSTS_PER_TASK", 12), 15), dailyPageLimit: Math.min(envInt("CRAWLER_DAILY_PAGE_LIMIT", 40), 60), }; } export function limitCrawlerTasks(tasks: ScanTask[]): ScanTask[] { const limits = getCrawlerSafetyLimits(); return tasks.slice(0, limits.maxTasksPerScan).map((task) => ({ ...task, limit: Math.min(task.limit, limits.maxPostsPerTask), })); } export function consumeCrawlerPageQuota(accountId?: string): void { assertCrawlerCooldown(); const { dailyPageLimit } = getCrawlerSafetyLimits(); const key = `threads-crawler-${accountId || "default"}`; if (!canUseDailyQuota(key, dailyPageLimit)) { throw new Error( `今日瀏覽器海巡已達安全上限(${dailyPageLimit} 頁)。請明天再繼續,或改用 Brave 補漏。` ); } incrementDailyQuota(key); } export function crawlerQuotaStatus(accountId?: string) { const { dailyPageLimit } = getCrawlerSafetyLimits(); const key = `threads-crawler-${accountId || "default"}`; return { used: getDailyQuotaUsed(key), limit: dailyPageLimit }; } const BLOCKED_TEXT = /try again later|rate limit|too many requests|suspicious activity|checkpoint|請稍後再試|操作太頻繁|異常活動|暫時限制|驗證你的身分/i; export async function assertThreadsPageSafe( page: Page, status: number | undefined, bodyText: string ): Promise { const url = page.url(); const loginRequired = /\/login(?:[/?#]|$)/i.test(url); const blocked = status === 429 || status === 403 || BLOCKED_TEXT.test(bodyText) || /checkpoint/i.test(url); if (!blocked && !loginRequired) return; let runId: string | null = null; try { const run = await createDebugRun( loginRequired ? "threads-login-required" : "threads-blocked-or-verification", { force: true } ); runId = run?.id ?? null; await captureDebugStep(page, run, loginRequired ? "login-required" : "blocked-or-verification", { status, bodyPreview: bodyText.slice(0, 500), }); } catch (error) { console.warn("[threads-safety] failure screenshot could not be saved", error); } const screenshotHint = runId ? ` 已自動截圖,請到「瀏覽器 Debug」查看最新紀錄(${runId})。` : " 截圖儲存失敗,請手動開啟 Threads 確認狀態。"; if (blocked) { crawlerBlockedUntil = Math.max(crawlerBlockedUntil, Date.now() + blockCooldownMs()); throw new Error(`Threads 顯示限流或帳號驗證,已立即停止海巡並進入保護冷卻。${screenshotHint}`); } throw new Error(`Session 已失效,請到設定頁重新登入 Threads。${screenshotHint}`); }