SPB Git forge
7commits 1branches 0releases
229.0 KBsize
maindefault branch
12 days agolast push
TypeScript 91.8% HTML 3.2% JavaScript 3% SQL 1.4% CSS 0.7%
4.5 KB · 132 lines typescript
Raw Blame History
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