import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { Page } from "playwright"; /** * Raw DOM candidates scanned in-page (§13–§15). Generic: relies on links, landmarks, ARIA roles, * media elements and text density — never on generated class names. Adapters refine these later. * * The in-page code lives in `snapshot.page.js` (plain JS) because TypeScript transpilers inject * helpers such as `__name` into function bodies, which do not exist inside the page. */ export interface DomCandidate { kind: "link" | "article" | "video" | "image" | "heading" | "search" | "button"; href?: string; text: string; // visible text (trimmed) aria_label?: string; role?: string; region: string; // main / feed / sidebar / dialog / header / navigation / comments / unknown index: number; // document order among candidates visible: boolean; // within viewport top: number; // bounding rect top (for feed position) meta?: Record; // duration, views, author text found in the same card locator_hint: string; // a stable-ish CSS selector path we can use to click (prefers href / aria-label) } export interface DomSnapshot { url: string; title: string; lang?: string; h1?: string; landmarks: string[]; candidates: DomCandidate[]; video_elements: { src?: string; current_src?: string; duration?: number; width: number; height: number; playing: boolean; poster?: string }[]; text_excerpt: string; // first ~600 chars of main text scroll: { y: number; height: number; viewport: number }; has_login_form: boolean; dialog_open: boolean; } const here = path.dirname(fileURLToPath(import.meta.url)); const SNAPSHOT_SOURCE = fs .readFileSync(path.join(here, "snapshot.page.js"), "utf8") .replace(/^\s*\/\/.*$/gm, "") // strip comment lines so the string is a bare function expression .trim(); export async function snapshotDom(page: Page, opts: { maxCandidates?: number } = {}): Promise { const max = opts.maxCandidates ?? 250; return (await page.evaluate(`(${SNAPSHOT_SOURCE})(${max})`)) as DomSnapshot; }