spb/social-runtime-crawler
Public
TypeScript 91.8%
HTML 3.2%
JavaScript 3%
SQL 1.4%
CSS 0.7%
1import path from "node:path";2import fs from "node:fs";3import { chromium, type BrowserContext, type Page, type CDPSession } from "playwright";4import { createLogger, newId, nowIso, type Platform, type SessionInfo } from "@src/shared";5import type { EventBus } from "@src/events";67const log = createLogger("browser");89export interface SessionOptions {10 platform: Platform;11 accountAlias: string;12 profilesDir: string;13 headless: boolean;14 channel?: string; // "chromium" | "chrome" | "msedge"15 bus?: EventBus;16 viewport?: { width: number; height: number };17}1819/**20 * One persistent Chromium profile per (platform, account) — §7/§8/§65.21 * Authentication is never automated: a human logs in once (`login` command); the profile keeps the cookies.22 */23export class SocialBrowserSession {24 readonly sessionId = newId("sess");25 readonly platform: Platform;26 readonly accountAlias: string;27 readonly profilePath: string;28 private context?: BrowserContext;29 private page?: Page;30 private cdp?: CDPSession;31 private info: SessionInfo;32 private readonly opts: SessionOptions;3334 constructor(opts: SessionOptions) {35 this.opts = opts;36 this.platform = opts.platform;37 this.accountAlias = opts.accountAlias;38 this.profilePath = path.join(opts.profilesDir, `${opts.platform}-${opts.accountAlias}`);39 this.info = {40 session_id: this.sessionId,41 platform: this.platform,42 account_alias: this.accountAlias,43 profile_path: this.profilePath,44 started_at: nowIso(),45 navigation_depth: 0,46 health: "starting",47 };48 }4950 get id(): string {51 return this.sessionId;52 }5354 hasProfile(): boolean {55 return fs.existsSync(path.join(this.profilePath, "Default")) || fs.existsSync(path.join(this.profilePath, "Local State"));56 }5758 async start(): Promise<void> {59 fs.mkdirSync(this.profilePath, { recursive: true });60 const channel = this.opts.channel && this.opts.channel !== "chromium" ? this.opts.channel : undefined;61 this.context = await chromium.launchPersistentContext(this.profilePath, {62 headless: this.opts.headless,63 channel,64 viewport: this.opts.viewport ?? { width: 1380, height: 900 },65 locale: "fr-CA",66 timezoneId: "America/Toronto",67 args: ["--disable-blink-features=AutomationControlled", "--autoplay-policy=no-user-gesture-required"],68 ignoreDefaultArgs: ["--enable-automation"],69 });70 this.context.setDefaultTimeout(20_000);71 this.page = this.context.pages()[0] ?? (await this.context.newPage());72 // Close extra tabs the platform may open; we keep a single-tab runtime.73 this.context.on("page", (p) => {74 if (p !== this.page) p.close().catch(() => {});75 });76 this.page.on("crash", () => {77 this.info.health = "crashed";78 log.error("page crashed", { session: this.sessionId });79 });80 this.cdp = await this.context.newCDPSession(this.page);81 await this.cdp.send("Network.enable").catch(() => {});82 this.info.health = "healthy";83 log.info("session started", { session: this.sessionId, platform: this.platform, profile: this.profilePath, headless: this.opts.headless });84 this.opts.bus?.emit({85 event_type: "SESSION_STARTED",86 platform: this.platform,87 session_id: this.sessionId,88 payload: { account_alias: this.accountAlias, profile_path: this.profilePath, headless: this.opts.headless },89 });90 }9192 async stop(): Promise<void> {93 this.info.health = "stopped";94 await this.context?.close().catch(() => {});95 this.opts.bus?.emit({ event_type: "SESSION_ENDED", platform: this.platform, session_id: this.sessionId, payload: { ...this.info } });96 log.info("session stopped", { session: this.sessionId });97 }9899 getPage(): Page {100 if (!this.page) throw new Error("session not started");101 return this.page;102 }103104 getContext(): BrowserContext {105 if (!this.context) throw new Error("session not started");106 return this.context;107 }108109 getCdp(): CDPSession | undefined {110 return this.cdp;111 }112113 getInfo(): SessionInfo {114 return { ...this.info, current_url: this.page?.url() };115 }116117 setHealth(h: SessionInfo["health"]): void {118 this.info.health = h;119 }120121 recordAction(label: string, depthDelta = 0): void {122 this.info.last_action = label;123 this.info.navigation_depth = Math.max(0, this.info.navigation_depth + depthDelta);124 }125126 async navigate(url: string): Promise<void> {127 const page = this.getPage();128 await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45_000 });129 this.info.current_url = page.url();130 }131}132