haixunMaster/lib/threads-browser/browser.ts

192 lines
5.6 KiB
TypeScript
Raw Permalink Normal View History

2026-06-21 12:50:31 +00:00
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<Browser> {
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<BrowserContext> {
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<BrowserContext> {
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<BrowserContext> {
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<void>;
}
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<T>(
storageState: string | undefined,
fn: (context: BrowserContext) => Promise<T>,
options?: BrowserSessionOptions
): Promise<T> {
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<T>(
storageState: string | undefined,
fn: (page: import("playwright").Page) => Promise<T>,
options?: BrowserSessionOptions
): Promise<T> {
return withSharedContext(
storageState,
async (context) => {
const page = context.pages()[0] ?? (await context.newPage());
return fn(page);
},
options
);
}