192 lines
5.6 KiB
TypeScript
192 lines
5.6 KiB
TypeScript
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
|
||
);
|
||
} |