language: TypeScript
4 KB / 144 lines / 123 loc
import type { Source } from "@yakatak/db";
import * as fs from "fs/promises";
import { join } from "path";
import type { Browser, BrowserContext, ElementHandle, Page } from "playwright";
import { chromium } from "playwright";
async function searchPath(command: string): Promise<string | undefined> {
for (const dir of process.env.PATH?.split(":") ?? []) {
const path = join(dir, command);
try {
await fs.access(path, fs.constants.X_OK);
return path;
} catch {}
}
}
export interface CaptureDetail {
source: Source;
url: string;
title: string;
image: Buffer;
metadata: Record<string, unknown>;
}
const VIEWPORT = { width: 1024, height: 768 };
export async function createBrowserProxy() {
const executablePath = await searchPath("chromium");
if (!executablePath) throw new Error("chromium not found in PATH");
const browser = await chromium.launch({
channel: "chromium",
executablePath,
headless: false,
chromiumSandbox: true,
});
return new BrowserProxy(browser);
}
export class BrowserProxy {
protected browser: Browser;
constructor(browser: Browser) {
this.browser = browser;
}
async [Symbol.asyncDispose]() {
await this.browser.close();
}
async newContext(harPath?: string) {
const recordHar = harPath == undefined ? undefined : { path: harPath };
const context = await this.browser.newContext({ viewport: VIEWPORT, recordHar });
return new BrowserContextProxy(context, harPath);
}
}
export class BrowserContextProxy {
protected context: BrowserContext;
public readonly harPath: string | undefined;
constructor(context: BrowserContext, harPath: string | undefined) {
this.context = context;
this.harPath = harPath;
}
async [Symbol.asyncDispose]() {
await this.close();
}
async close() {
await this.context.close();
}
async launchPage(url: string): Promise<Page> {
const page = await this.context.newPage();
await page.goto(url);
return page;
}
async captureFullPage(source: Source, page: Page): Promise<CaptureDetail> {
const url = page.url();
const title = await page.title();
const image = await page.screenshot({ fullPage: true });
const metadata = { url, title };
return { source, url, title, image, metadata };
}
async captureElement(
source: Source,
elem: ElementHandle,
options: { url: string; title: string; style?: string; metadata: any },
): Promise<CaptureDetail> {
const { url, title, style, metadata } = options;
let lastError;
for (let retry = 1; retry <= 5; retry++) {
try {
const image = await elem.screenshot({ style, timeout: 10_000 });
return { source, url, title, image, metadata };
} catch (error) {
lastError = error;
console.warn(`Screenshot attempt ${retry} failed, retrying...`);
}
}
throw lastError;
}
}
export async function createCDPBrowserProxy(cdpUrl: string) {
const browser = await chromium.connectOverCDP(cdpUrl);
return new CDPBrowserProxy(browser);
}
export class CDPBrowserProxy extends BrowserProxy {
override async newContext() {
const context = this.browser.contexts()[0];
if (!context) throw new Error("Expected default browser context");
// HAR not supported for CDP.
return new CDPBrowserContextProxy(context, undefined);
}
}
class CDPBrowserContextProxy extends BrowserContextProxy {
override async launchPage(url: string): Promise<Page> {
const pages = this.context.pages();
// Reuse an existing open page.
for (const page of pages) {
if (page.url() === url) return page;
}
// A bit of a dance to open a new window.
if (!pages[0]) throw new Error("Expected existing page");
const client = await this.context.newCDPSession(pages[0]);
const [page] = await Promise.all([
this.context.waitForEvent("page"),
client.send("Target.createTarget", { url, newWindow: true }),
]);
await client.detach();
await page.waitForLoadState();
await page.setViewportSize(VIEWPORT);
return page;
}
}