import { existsSync } from "fs"; import { mkdir, rm } from "fs/promises"; import path from "path"; import { chromium, type Browser, type BrowserContext, type BrowserContextOptions } from "playwright"; import { shouldRunHeaded, getPlaywrightSlowMo } from "./debug"; import { humanPause } from "./human-behavior"; import { withSessionLock } from "./session-lock"; const USER_AGENT = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; export const THREADS_PROFILE_DIR = path.join(process.cwd(), "data", "threads-browser-profile"); const BROWSER_ARGS = [ "--disable-blink-features=AutomationControlled", "--no-first-run", "--no-default-browser-check", ]; const STEALTH_INIT_SCRIPT = () => { Object.defineProperty(navigator, "webdriver", { get: () => false }); }; let browserInstance: Browser | null = null; async function isHeadless() { if (process.env.PLAYWRIGHT_HEADLESS === "false") return false; if (process.env.PLAYWRIGHT_HEADLESS === "true") return true; return !(await shouldRunHeaded()); } export function hasPersistentProfile(): boolean { return ( existsSync(path.join(THREADS_PROFILE_DIR, "Default")) || existsSync(path.join(THREADS_PROFILE_DIR, "Local State")) ); } export async function ensureProfileDir() { await mkdir(THREADS_PROFILE_DIR, { recursive: true }); } /** 清除 Playwright 持久化 profile,避免舊 session 與手機/瀏覽器的 IG 登入互相踢出。 */ export async function clearBrowserProfile(): Promise<{ cleared: boolean; message: string }> { if (!hasPersistentProfile()) { return { cleared: false, message: "瀏覽器 session 本來就是空的" }; } await rm(THREADS_PROFILE_DIR, { recursive: true, force: true }); await ensureProfileDir(); return { cleared: true, message: "已清除瀏覽器 session" }; } export async function getBrowser(): Promise { if (!browserInstance || !browserInstance.isConnected()) { browserInstance = await chromium.launch({ headless: await isHeadless(), slowMo: getPlaywrightSlowMo(), args: BROWSER_ARGS, }); } return browserInstance; } export async function createContext( storageState?: string, headless?: boolean ): Promise { const resolvedHeadless = headless ?? (await isHeadless()); const browser = resolvedHeadless ? await getBrowser() : await chromium.launch({ headless: false, slowMo: getPlaywrightSlowMo(), args: BROWSER_ARGS, }); let state: BrowserContextOptions["storageState"]; if (storageState) { try { state = JSON.parse(storageState); } catch { throw new Error("瀏覽器 session 資料損毀,請到連線設定重新同步"); } } const context = await browser.newContext({ storageState: state, userAgent: USER_AGENT, viewport: { width: 1280, height: 900 }, locale: "zh-TW", timezoneId: "Asia/Taipei", }); await context.addInitScript(STEALTH_INIT_SCRIPT); return context; } export async function launchSessionContext(options?: { storageState?: string; headless?: boolean; }): Promise { const headless = options?.headless ?? (await isHeadless()); if (options?.storageState) { return createContext(options.storageState, headless); } await ensureProfileDir(); const context = await chromium.launchPersistentContext(THREADS_PROFILE_DIR, { headless, slowMo: getPlaywrightSlowMo(), args: BROWSER_ARGS, userAgent: USER_AGENT, viewport: { width: 1280, height: 900 }, locale: "zh-TW", timezoneId: "Asia/Taipei", }); await context.addInitScript(STEALTH_INIT_SCRIPT); return context; } export async function launchLoginContext(): Promise { await ensureProfileDir(); const context = await chromium.launchPersistentContext(THREADS_PROFILE_DIR, { headless: false, slowMo: getPlaywrightSlowMo(), args: BROWSER_ARGS, userAgent: USER_AGENT, viewport: { width: 1280, height: 900 }, locale: "zh-TW", timezoneId: "Asia/Taipei", }); await context.addInitScript(STEALTH_INIT_SCRIPT); return context; } export interface BrowserSessionOptions { headless?: boolean; accountId?: string; storageState?: string; persistSession?: boolean; onSessionPersisted?: (storageState: string) => void | Promise; } async function persistContextSession( context: BrowserContext, options?: BrowserSessionOptions ) { if (!options?.accountId || options.persistSession === false) return; const state = await context.storageState(); const serialized = JSON.stringify(state); await options.onSessionPersisted?.(serialized); } export async function withSharedContext( storageState: string | undefined, fn: (context: BrowserContext) => Promise, options?: BrowserSessionOptions ): Promise { return withSessionLock(async () => { const headless = options?.headless ?? (await isHeadless()); const context = await launchSessionContext({ storageState: storageState ?? options?.storageState, headless, }); try { await humanPause(Math.random() < 0.7 ? "micro" : "short"); const result = await fn(context); await persistContextSession(context, options); return result; } finally { await context.close(); } }); } export async function withPage( storageState: string | undefined, fn: (page: import("playwright").Page) => Promise, options?: BrowserSessionOptions ): Promise { return withSharedContext( storageState, async (context) => { const page = context.pages()[0] ?? (await context.newPage()); return fn(page); }, options ); }