// THE FORUM BROWSER: one persistent Chromium profile per forum host, opened // headless by the thread fetcher and the post capture, and HEADED by Connect. // // Why a persistent profile: a forum behind a browser check (Kiwi Farms' // KiwiFlare — a JavaScript proof of work — or a "just a moment" page) sets a // clearance cookie once the check passes. A real browser clears it by itself; // keeping the profile keeps the cookie, so every later page and run reuses it // instead of meeting the check again. // // Connect is the operator's way through anything the headless browser cannot // pass (a captcha, a login a members-only thread needs): a headed window on the // same profile, at the thread, which the operator clears by hand and closes. // Mirrors the X session broker (xSessionBroker.ts): the same browser choice // and launch options (xBrowser.ts — the operator's own Chromium when one is // installed, without the automation bar), the same record of which browser // wrote the profile, and the same rule that a window opens only on an explicit // operator action, never from a fetch. // // The user agent is the browser's own. Headless Chromium names itself // "HeadlessChrome" there; that one word is replaced with "Chrome" so the // headless profile presents as the browser it is. Nothing else is disguised, // and a captcha is never answered by code. // // Layout, per host: transcripts/.forum-session//profile/ (the profile), // transcripts/.forum-session//session.json (the last Connect). import path from "node:path"; import { mkdir, rm, stat } from "node:fs/promises"; import { execa } from "execa"; import type { Paths } from "../lib/paths"; import { readJsonFile, writeJsonAtomic } from "../lib/jsonFile-server"; import { importPlaywright, type BrowserContextLike, type ChromiumLike, type PageLike, } from "./playwrightRuntime"; import { buildXBrowserLaunchOptions, describeXBrowser, findXBrowser, readXBrowserRecord, recordedXBrowser, writeXBrowserRecord, type XBrowserChoice, } from "./xBrowser"; import { classifyForumPage, type ForumBlock } from "./xenforoParse"; // A host as a directory name: lowercased, nothing but [a-z0-9.-]. export function forumHostKey(host: string): string { const key = host.trim().toLowerCase().replace(/[^a-z0-9.-]/g, "_"); if (!key || key.startsWith(".")) throw new Error(`"${host}" is not a forum host`); return key; } export function forumSessionDir(paths: Pick, host: string): string { return path.join(paths.transcriptsDir, ".forum-session", forumHostKey(host)); } export function forumProfileDir(paths: Pick, host: string): string { return path.join(forumSessionDir(paths, host), "profile"); } export type ForumSessionRecord = { host: string; connectedAt: string; // The window's last look at the forum before it closed: did a thread page // show (the check was cleared), and how many cookies the host had set. cleared: boolean; cookies: number; browser?: string; }; export type ForumSessionStatus = { host: string; hasProfile: boolean; lastConnect?: ForumSessionRecord; }; export async function readForumSessionStatus( paths: Pick, host: string, ): Promise { const dir = forumSessionDir(paths, host); const hasProfile = await stat(path.join(dir, "profile")) .then(() => true) .catch(() => false); const read = await readJsonFile(path.join(dir, "session.json")); const rec = read.ok ? (read.value as ForumSessionRecord | null) : null; return { host, hasProfile, ...(rec && typeof rec.connectedAt === "string" ? { lastConnect: rec } : {}), }; } export async function clearForumSession(paths: Pick, host: string): Promise { await rm(forumSessionDir(paths, host), { recursive: true, force: true }); } function firstLine(err: unknown): string { return ((err as Error)?.message ?? String(err)).split("\n")[0]; } // --- opening the profile --------------------------------------------------------- const BUNDLED: XBrowserChoice = { kind: "bundled" }; // The headless user agent with its one tell removed, learned once per process. let normalUserAgent: string | undefined; export function presentableUserAgent(ua: string): string { return ua.replace(/HeadlessChrome/g, "Chrome"); } async function launchHeadless( chromium: ChromiumLike, profileDir: string, browser: XBrowserChoice, userAgent?: string, ): Promise { return chromium.launchPersistentContext(profileDir, { ...buildXBrowserLaunchOptions({ browser, headless: true }), viewport: { width: 1280, height: 900 }, ...(userAgent ? { userAgent } : {}), }); } // The host's profile, headless. The bundled build first; the browser the // profile records (the one Connect opened) when the bundled build cannot open // it — as launchXProfile does for X. export async function openForumProfile( profileDir: string, opts: { onLog?: (line: string) => void; chromium?: ChromiumLike } = {}, ): Promise { const log = opts.onLog ?? (() => {}); await mkdir(profileDir, { recursive: true }); const chromium = opts.chromium ?? (await importPlaywright()).chromium; let browser: XBrowserChoice = BUNDLED; let context: BrowserContextLike; try { context = await launchHeadless(chromium, profileDir, browser, normalUserAgent); } catch (err) { const record = await readXBrowserRecord(profileDir); const recorded = recordedXBrowser(record); if (!recorded) throw err; log( `Playwright's bundled Chromium could not open the forum profile (${firstLine(err)}); ` + `using ${describeXBrowser(recorded, record?.version)}, headless.`, ); browser = recorded; context = await launchHeadless(chromium, profileDir, browser, normalUserAgent); } if (normalUserAgent) return context; // First launch in this process: read the browser's own agent; if it names // itself headless, reopen once with that word replaced. try { const page = context.pages()[0] ?? (await context.newPage()); const ua = String(await page.evaluate("navigator.userAgent")); const fixed = presentableUserAgent(ua); normalUserAgent = fixed; if (fixed !== ua) { await context.close().catch(() => {}); context = await launchHeadless(chromium, profileDir, browser, fixed); } } catch (err) { log(`Could not read the browser's user agent (${firstLine(err)}); keeping its default.`); } return context; } // --- loading one page through a browser check --------------------------------------- export type ForumPageLoad = { html: string; // The response status, when the page came straight back (no check waited // out — after a check the first response's status no longer describes the // page). status?: number; url: string; // How long a browser check took to clear, when there was one. challengeMs?: number; }; export type ForumPageLoader = { load(url: string, signal: AbortSignal): Promise; close(): Promise; }; export const CHALLENGE_TIMEOUT_MS = 60_000; const CHALLENGE_POLL_MS = 2_000; const OUTER_HTML = "document.documentElement ? document.documentElement.outerHTML : ''"; type ResponseLike = { status?: () => number } | null | undefined; // Go to `url` in `page` and come back with what it shows. A browser check // (classifyForumPage → "challenge") is WAITED OUT, up to `timeoutMs`: the // check's own script runs, sets its cookie and reloads, and the poll reads // the page that follows. Nothing is clicked or solved. What the page shows at // the end — the thread, or the check still standing — is the caller's to // judge. export async function loadForumPage( page: PageLike, url: string, opts: { signal: AbortSignal; onLog?: (line: string) => void; timeoutMs?: number; pollMs?: number; now?: () => number; }, ): Promise { const now = opts.now ?? Date.now; const timeoutMs = opts.timeoutMs ?? CHALLENGE_TIMEOUT_MS; const pollMs = opts.pollMs ?? CHALLENGE_POLL_MS; let status: number | undefined; try { const res = (await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 })) as ResponseLike; status = typeof res?.status === "function" ? res.status() : undefined; } catch (err) { // A navigation the check's own reload interrupted still leaves a page to // read; anything else is a load failure. if (!/interrupted|ERR_ABORTED|frame was detached/i.test(firstLine(err))) { throw new Error(`Could not load ${url}: ${firstLine(err)}`); } } const read = async (): Promise => { try { return String(await page.evaluate(OUTER_HTML)); } catch { return null; // mid-navigation } }; let html = (await read()) ?? ""; let block: ForumBlock | null = classifyForumPage(html, status); if (block?.kind !== "challenge") { return { html, ...(status !== undefined ? { status } : {}), url: await currentUrl(page, url) }; } const started = now(); opts.onLog?.( `The forum served a browser check (${block.detail}); waiting up to ${Math.round(timeoutMs / 1000)} s for it to clear.`, ); while (now() - started < timeoutMs) { if (opts.signal.aborted) break; await page.waitForTimeout(pollMs); const next = await read(); if (next === null) continue; html = next; block = classifyForumPage(html); if (block?.kind !== "challenge") break; } const challengeMs = now() - started; if (block?.kind === "challenge") { opts.onLog?.(`The browser check had not cleared after ${Math.round(challengeMs / 1000)} s.`); } else { opts.onLog?.(`The browser check cleared in ${Math.round(challengeMs / 1000)} s.`); } return { html, url: await currentUrl(page, url), challengeMs }; } async function currentUrl(page: PageLike, fallback: string): Promise { try { const href = String(await page.evaluate("location.href")); return href && href !== "about:blank" ? href : fallback; } catch { return fallback; } } // A loader over the host's headless profile, opened on the first load and // kept for the run (one browser, one page, every page in turn). export function browserForumLoader( profileDir: string, opts: { onLog?: (line: string) => void; timeoutMs?: number; chromium?: ChromiumLike } = {}, ): ForumPageLoader & { page(): Promise } { let context: BrowserContextLike | undefined; let page: PageLike | undefined; const ensure = async (): Promise => { if (!context) { context = await openForumProfile(profileDir, { onLog: opts.onLog, chromium: opts.chromium }); opts.onLog?.("Opened the forum browser profile (headless)."); } page ??= context.pages()[0] ?? (await context.newPage()); return page; }; return { page: ensure, async load(url, signal) { const p = await ensure(); return loadForumPage(p, url, { signal, onLog: opts.onLog, timeoutMs: opts.timeoutMs }); }, async close() { await context?.close().catch(() => {}); context = undefined; page = undefined; }, }; } // --- Connect: the headed window ---------------------------------------------------------- async function browserVersion(b: XBrowserChoice): Promise { if (b.kind !== "system") return undefined; try { const res = await execa(b.executablePath, ["--version"], { reject: false, timeout: 5_000 }); const line = `${res.stdout ?? ""}`.trim().split("\n")[0]; return res.exitCode === 0 && line ? line : undefined; } catch { return undefined; } } type CookieLike = { domain?: string }; function cookiesForHost(cookies: ReadonlyArray, host: string): number { const h = host.toLowerCase(); return cookies.filter((c) => { const d = (c.domain ?? "").replace(/^\./, "").toLowerCase(); return d === h || h.endsWith(`.${d}`) || d.endsWith(`.${h}`); }).length; } // Open a HEADED browser on the host's profile at `url` (the thread), for the // operator to clear the check, answer a captcha or log in. Resolves when the // window is closed, with the window's last look at the forum. The window opens // on the display of the machine running the editor (as Connect X does). export async function connectForumSession( paths: Pick, url: string, opts: { onLog?: (line: string) => void; timeoutMs?: number; env?: Record } = {}, ): Promise { const log = opts.onLog ?? (() => {}); const host = new URL(url).hostname.toLowerCase(); const profileDir = forumProfileDir(paths, host); await mkdir(profileDir, { recursive: true }); const browser = findXBrowser({ env: opts.env }); const version = await browserVersion(browser); const label = describeXBrowser(browser, version); const { chromium } = await importPlaywright(); log(`Opening ${label} at ${host}. Clear the check (or log in), wait for the thread to show, then close the window.`); let context: BrowserContextLike; try { context = await chromium.launchPersistentContext( profileDir, buildXBrowserLaunchOptions({ browser, headless: false, sandbox: true }), ); } catch (err) { log(`The sandboxed launch failed (${firstLine(err)}); opening without the sandbox.`); context = await chromium.launchPersistentContext( profileDir, buildXBrowserLaunchOptions({ browser, headless: false, sandbox: false }), ); } await writeXBrowserRecord(profileDir, browser, version).catch((err) => { log(`Could not record the browser in the profile: ${firstLine(err)}`); }); let cleared = false; let cookies = 0; const look = async () => { try { const page = context.pages()[0]; if (page) { const html = String(await page.evaluate(OUTER_HTML)); cleared = classifyForumPage(html) === null; } cookies = cookiesForHost((await context.cookies()) as CookieLike[], host); } catch { /* closing, or mid-navigation */ } }; try { const page = context.pages()[0] ?? (await context.newPage()); await page.goto(url, { waitUntil: "domcontentloaded", timeout: 60_000 }).catch((err: unknown) => { log(`The first load did not finish (${firstLine(err)}); the window stays open.`); }); await new Promise((resolve) => { let settled = false; const timer = setInterval(() => void look(), 2_000); const finish = () => { if (settled) return; settled = true; clearInterval(timer); resolve(); }; context.on("close", finish); if (opts.timeoutMs) setTimeout(finish, opts.timeoutMs); }); } finally { await context.close().catch(() => {}); } const record: ForumSessionRecord = { host, connectedAt: new Date().toISOString(), cleared, cookies, browser: label, }; await writeJsonAtomic(path.join(forumSessionDir(paths, host), "session.json"), record, { mkdir: true }); log( cleared ? `The thread showed before the window closed; ${cookies} cookie(s) for ${host} kept in the profile.` : `The window closed without a thread page showing; ${cookies} cookie(s) for ${host} in the profile.`, ); return record; }