haixunMaster/lib/threads-browser/browser.ts

192 lines
5.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
);
}