diff --git a/apps/web/public/install.sh b/apps/web/public/install.sh index b28ea20..62dad87 100755 --- a/apps/web/public/install.sh +++ b/apps/web/public/install.sh @@ -87,6 +87,9 @@ Usage: tron snapshot Structured, ref-tagged page snapshot (--json) tron click Click a snapshot ref, e.g. @e3 tron fill Fill an input by ref, e.g. tron fill @e4 "hi@x.com" + tron extract Extract text|links|forms|tables|main (JSON) + tron screenshot

Save a PNG of the current page (--full-page) + tron headless One-shot: --snapshot | --screenshot

| --extract tron upgrade Update to the latest release tron remove Uninstall TronBrowser (keeps your profile data) tron version Print the installed version @@ -141,11 +144,12 @@ automate_entry() { } # Route a CDP automation subcommand to the Node runtime, or explain what's missing. +# TRON_SESSION_BIN lets `tron headless` launch/close its own one-shot session. run_automation() { ENTRY="$(automate_entry)" command -v node >/dev/null 2>&1 || { echo "tron $1 needs Node.js (>=22) on PATH." >&2; exit 1; } [ -f "$ENTRY" ] || { echo "This TronBrowser build lacks the automation runtime. Run: tron upgrade" >&2; exit 1; } - exec node "$ENTRY" "$@" + exec env TRON_SESSION_BIN="$(session_bin)" node "$ENTRY" "$@" } case "${1:-}" in @@ -168,8 +172,11 @@ case "${1:-}" in SESSION="$(session_bin)" [ -x "$SESSION" ] || { echo "This TronBrowser build has no managed-session support (missing tron-session). Run: tron upgrade" >&2; exit 1; } exec "$SESSION" browser "$@" ;; - snapshot|click|fill|type) - # CDP automation on the managed session's current page (PRD M3.2). + snapshot|click|fill|type|extract|screenshot|pdf) + # CDP automation on the managed session's current page (PRD M3.2/M3.3). + run_automation "$@" ;; + headless) + # One-shot: launch a headless ephemeral session, navigate, act, tear down. run_automation "$@" ;; restart) # Force-quit any running TronBrowser, then launch fresh. Chromium forwards a diff --git a/docs/headless-and-extraction.md b/docs/headless-and-extraction.md new file mode 100644 index 0000000..549a111 --- /dev/null +++ b/docs/headless-and-extraction.md @@ -0,0 +1,66 @@ +# Headless and extraction (M3.3) + +## One-shot headless + +`tron headless ` launches a **headless, ephemeral** managed session, +navigates to the URL, performs one operation, and tears everything down +(profile included) — ideal for CI and agents. + +```sh +tron headless https://example.com --snapshot # structured snapshot (--json) +tron headless https://example.com --screenshot out.png # PNG (--full-page) +tron headless https://example.com --pdf out.pdf # PDF +tron headless https://example.com --extract links # extraction JSON +``` + +It uses its own isolated data dir (a temp `TRONBROWSER_DATA`), so it never +touches an interactive `tron browser` session you may have running, and no +persistent profile is used. + +## Extraction + +`tron extract` reads structured data from the managed session's current page as +deterministic JSON (relative `href`/`src` resolved to absolute): + +```sh +tron extract text # main text content +tron extract links # [{ text, href }] +tron extract forms # [{ name, action, method, fields: [{ name, type, label, required, value }] }] +tron extract tables # [{ headers, rows }] +tron extract main # { text } of

/
+ +# Custom selector + fields (name=selector[@attr]); @href/@src come back absolute: +tron extract '.product-card' \ + --field title='.title' \ + --field price='.price' \ + --field url='a@href' +``` + +Password field values are never included. `forms` omits hidden inputs. + +## Screenshots and PDF + +```sh +tron screenshot page.png # viewport PNG of the current page +tron screenshot page.png --full-page +tron pdf page.pdf # headless only +``` + +## How it works + +- `extract`, `screenshot`, `pdf`, and `headless` are Node subcommands the shell + `tron` dispatcher delegates to (see [snapshots-and-refs.md](./snapshots-and-refs.md)). +- Extraction runs one in-page script via CDP `Runtime.evaluate`; capture uses + `Page.captureScreenshot` / `Page.printToPDF`. +- `headless` orchestrates in Node: it shells out to the `tron-session` engine + (`TRON_SESSION_BIN`) to launch/close a headless session, then drives + navigate + op over CDP, and always cleans up (even on failure). + +## Scope / limitations + +- Requires Node.js (>= 22). PDF requires headless. +- macOS headless is limited by the detached-launch model (see + [managed-sessions.md](./managed-sessions.md)); Linux native/flatpak is primary. +- Contracts + DOM logic unit-tested in `packages/browser-core` + (`automation/extract-script`, `capture`, `automate-*.test.ts`), plus an + end-to-end run over the real HTTP+WebSocket transport. diff --git a/packages/browser-core/src/automate-cli.ts b/packages/browser-core/src/automate-cli.ts index f49299f..e935a07 100644 --- a/packages/browser-core/src/automate-cli.ts +++ b/packages/browser-core/src/automate-cli.ts @@ -1,33 +1,42 @@ /** * `tron-automate` — Node entrypoint for the CDP-driven automation subcommands - * the shell `tron` dispatcher delegates to (PRD M3.2): + * the shell `tron` dispatcher delegates to (PRD M3.2 + M3.3): * * tron snapshot [--json] [--include-hidden] - * tron click - * tron fill + * tron click | fill + * tron extract [--field n=sel[@attr]] + * tron screenshot [--full-page] | tron pdf + * tron headless [--snapshot|--screenshot |--pdf |--extract ] [--json] * * It attaches to the M3.1-managed session via its descriptor + the page target's - * webSocketDebuggerUrl. Dependencies (descriptor read, target fetch, CDP connect) - * are injectable so the command layer is testable without a real browser. + * webSocketDebuggerUrl. Dependencies (descriptor read, target fetch, CDP connect, + * one-shot session launch/close, byte writes) are injectable so the command layer + * is testable without a real browser. */ -import { readFile } from 'node:fs/promises'; +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; import { CdpClient, type CdpConnection } from './automation/cdp-client.js'; import { cdpListUrl } from './automation/cdp.js'; -import { - descriptorPath, - parseDescriptor, - resolveDataDir, -} from './automation/descriptor.js'; +import { descriptorPath, parseDescriptor, resolveDataDir } from './automation/descriptor.js'; +import { printPdf, screenshotPng } from './automation/capture.js'; +import { extractExpression, parseFieldSpec, type FieldSpec } from './automation/extract-script.js'; import { captureSnapshot, clickRef, enableRuntime, + extract, fillRef, formatSnapshotText, + goto, StaleRefError, } from './automation/page.js'; import { resolvePageWsUrl } from './automation/page-target.js'; -import type { SessionDescriptor, CdpTarget } from './automation/types.js'; +import type { CdpTarget, SessionDescriptor } from './automation/types.js'; + +const execFileP = promisify(execFile); /** Process exit codes shared with the shell dispatcher. */ export const EXIT = { @@ -43,6 +52,9 @@ export interface CliDeps { loadDescriptor(path: string): Promise; fetchTargets(listUrl: string): Promise; connect(wsUrl: string): Promise; + launchHeadless(dataDir: string): Promise; + closeSession(dataDir: string): Promise; + writeBytes(path: string, bytes: Uint8Array): Promise; out(text: string): void; err(text: string): void; } @@ -58,13 +70,27 @@ const defaultDeps: CliDeps = { return (await res.json()) as CdpTarget[]; }, connect: (wsUrl) => CdpClient.connect(wsUrl), + async launchHeadless(dataDir) { + const bin = process.env.TRON_SESSION_BIN; + if (!bin) throw new Error('headless needs the session engine (TRON_SESSION_BIN unset)'); + await execFileP(bin, ['browser', 'launch', '--headless'], { + env: { ...process.env, TRONBROWSER_DATA: dataDir }, + }); + }, + async closeSession(dataDir) { + const bin = process.env.TRON_SESSION_BIN; + if (!bin) return; + await execFileP(bin, ['browser', 'close'], { + env: { ...process.env, TRONBROWSER_DATA: dataDir }, + }); + }, + writeBytes: (path, bytes) => writeFile(path, bytes), out: (t) => process.stdout.write(t + '\n'), err: (t) => process.stderr.write(t + '\n'), }; -/** Attach to the current page of the managed session, or throw a coded error. */ -async function attach(deps: CliDeps): Promise { - const dataDir = resolveDataDir(deps.env); +/** Attach to the current page of a managed session, or throw a coded error. */ +async function attach(deps: CliDeps, dataDir = resolveDataDir(deps.env)): Promise { let descriptor: SessionDescriptor; try { descriptor = await deps.loadDescriptor(descriptorPath(dataDir)); @@ -76,18 +102,94 @@ async function attach(deps: CliDeps): Promise { const targets = await deps.fetchTargets( cdpListUrl({ host: descriptor.host, port: descriptor.port }), ); - const wsUrl = resolvePageWsUrl(targets, descriptor.activeTabId); - const conn = await deps.connect(wsUrl); + const conn = await deps.connect(resolvePageWsUrl(targets, descriptor.activeTabId)); await enableRuntime(conn); return conn; } +/** Collect all `--field name=selector[@attr]` specs from an arg list. */ +function collectFields(args: string[]): FieldSpec[] { + const specs: FieldSpec[] = []; + for (let i = 0; i < args.length; i += 1) { + const a = args[i]; + if (a === '--field' && args[i + 1] !== undefined) { + specs.push(parseFieldSpec(args[i + 1]!)); + i += 1; + } else if (a?.startsWith('--field=')) { + specs.push(parseFieldSpec(a.slice('--field='.length))); + } + } + return specs; +} + +/** Value that follows a flag, e.g. valueAfter(args, '--screenshot'). */ +function valueAfter(args: string[], flag: string): string | undefined { + const i = args.indexOf(flag); + return i >= 0 ? args[i + 1] : undefined; +} + +type HeadlessOp = + | { kind: 'snapshot'; json: boolean } + | { kind: 'extract'; target: string; fields: FieldSpec[] } + | { kind: 'screenshot'; path: string; fullPage: boolean } + | { kind: 'pdf'; path: string }; + +function parseHeadlessOp(args: string[]): HeadlessOp | { error: string } { + if (args.includes('--screenshot')) { + const path = valueAfter(args, '--screenshot'); + if (!path) return { error: '--screenshot needs a path' }; + return { kind: 'screenshot', path, fullPage: args.includes('--full-page') }; + } + if (args.includes('--pdf')) { + const path = valueAfter(args, '--pdf'); + if (!path) return { error: '--pdf needs a path' }; + return { kind: 'pdf', path }; + } + if (args.includes('--extract')) { + const target = valueAfter(args, '--extract'); + if (!target) return { error: '--extract needs a mode or selector' }; + return { kind: 'extract', target, fields: collectFields(args) }; + } + return { kind: 'snapshot', json: args.includes('--json') }; +} + +async function runOp(deps: CliDeps, conn: CdpConnection, op: HeadlessOp): Promise { + switch (op.kind) { + case 'snapshot': { + const snap = await captureSnapshot(conn); + deps.out(op.json ? JSON.stringify(snap, null, 2) : formatSnapshotText(snap)); + return; + } + case 'extract': { + const data = await extract(conn, extractExpression(op.target, op.fields)); + deps.out(JSON.stringify(data, null, 2)); + return; + } + case 'screenshot': { + await deps.writeBytes(op.path, await screenshotPng(conn, { fullPage: op.fullPage })); + deps.out(`screenshot -> ${op.path}`); + return; + } + case 'pdf': { + await deps.writeBytes(op.path, await printPdf(conn)); + deps.out(`pdf -> ${op.path}`); + return; + } + } +} + +const USAGE = + 'usage: tron snapshot [--json] | click | fill | ' + + 'extract [--field n=sel] | ' + + 'screenshot [--full-page] | pdf | ' + + 'headless [--snapshot|--screenshot |--pdf |--extract ] [--json]'; + export async function run(argv: string[], overrides: Partial = {}): Promise { const deps: CliDeps = { ...defaultDeps, ...overrides }; const [command, ...rest] = argv; if (command === undefined || command === 'help' || command === '--help') { - deps.out('usage: tron snapshot [--json] [--include-hidden] | click | fill '); + deps.out(USAGE); return EXIT.ok; } @@ -95,11 +197,12 @@ export async function run(argv: string[], overrides: Partial = {}): Pro try { switch (command) { case 'snapshot': { - const json = rest.includes('--json'); - const includeHidden = rest.includes('--include-hidden'); conn = await attach(deps); - const snap = await captureSnapshot(conn, includeHidden ? { includeHidden } : {}); - deps.out(json ? JSON.stringify(snap, null, 2) : formatSnapshotText(snap)); + const snap = await captureSnapshot( + conn, + rest.includes('--include-hidden') ? { includeHidden: true } : {}, + ); + deps.out(rest.includes('--json') ? JSON.stringify(snap, null, 2) : formatSnapshotText(snap)); return EXIT.ok; } case 'click': { @@ -109,8 +212,7 @@ export async function run(argv: string[], overrides: Partial = {}): Pro return EXIT.usage; } conn = await attach(deps); - const res = await clickRef(conn, ref); - deps.out(`clicked ${res.ref}`); + deps.out(`clicked ${(await clickRef(conn, ref)).ref}`); return EXIT.ok; } case 'fill': { @@ -121,10 +223,67 @@ export async function run(argv: string[], overrides: Partial = {}): Pro return EXIT.usage; } conn = await attach(deps); - const res = await fillRef(conn, ref, value); - deps.out(`filled ${res.ref}`); + deps.out(`filled ${(await fillRef(conn, ref, value)).ref}`); + return EXIT.ok; + } + case 'extract': { + const target = rest.find((a) => !a.startsWith('--')); + if (!target) { + deps.err('usage: tron extract [--field n=sel] [--json]'); + return EXIT.usage; + } + conn = await attach(deps); + const data = await extract(conn, extractExpression(target, collectFields(rest))); + deps.out(JSON.stringify(data, null, 2)); + return EXIT.ok; + } + case 'screenshot': { + const path = rest.find((a) => !a.startsWith('--')); + if (!path) { + deps.err('usage: tron screenshot [--full-page]'); + return EXIT.usage; + } + conn = await attach(deps); + await deps.writeBytes(path, await screenshotPng(conn, { fullPage: rest.includes('--full-page') })); + deps.out(`screenshot -> ${path}`); return EXIT.ok; } + case 'pdf': { + const path = rest.find((a) => !a.startsWith('--')); + if (!path) { + deps.err('usage: tron pdf '); + return EXIT.usage; + } + conn = await attach(deps); + await deps.writeBytes(path, await printPdf(conn)); + deps.out(`pdf -> ${path}`); + return EXIT.ok; + } + case 'headless': { + const url = rest.find((a) => !a.startsWith('--')); + if (!url) { + deps.err('usage: tron headless [--snapshot|--screenshot |--pdf |--extract ] [--json]'); + return EXIT.usage; + } + const op = parseHeadlessOp(rest); + if ('error' in op) { + deps.err(`usage: ${op.error}`); + return EXIT.usage; + } + const dataDir = await mkdtemp(join(tmpdir(), 'tron-headless-')); + try { + await deps.launchHeadless(dataDir); + conn = await attach(deps, dataDir); + await goto(conn, url); + await runOp(deps, conn, op); + return EXIT.ok; + } finally { + conn?.close(); + conn = undefined; + await deps.closeSession(dataDir).catch(() => {}); + await rm(dataDir, { recursive: true, force: true }).catch(() => {}); + } + } default: deps.err(`unknown automation command: ${command}`); return EXIT.usage; diff --git a/packages/browser-core/src/automate-e2e.test.ts b/packages/browser-core/src/automate-e2e.test.ts index bae561b..151448b 100644 --- a/packages/browser-core/src/automate-e2e.test.ts +++ b/packages/browser-core/src/automate-e2e.test.ts @@ -44,8 +44,13 @@ interface Mock { close: () => Promise; } -/** Mock DevTools server: HTTP /json/list + a page WS that answers Runtime.*. */ -async function startMock(evaluate: (expression: string) => unknown): Promise { +/** Mock DevTools server: HTTP /json/list + a page WS that answers Runtime.*. + * `evaluate` supplies Runtime.evaluate values; `methodResults` supplies command + * results for other CDP methods (e.g. Page.captureScreenshot). */ +async function startMock( + evaluate: (expression: string) => unknown, + methodResults: Record = {}, +): Promise { const sockets = new Set(); const server: Server = createServer((req, res) => { if (req.url === '/json/list') { @@ -73,10 +78,12 @@ async function startMock(evaluate: (expression: string) => unknown): Promise, exceptionDetails?}}. - const result = - msg.method === 'Runtime.evaluate' - ? { result: { result: { value: evaluate(msg.params?.expression ?? '') } } } - : {}; + let result: Record = {}; + if (msg.method === 'Runtime.evaluate') { + result = { result: { result: { value: evaluate(msg.params?.expression ?? '') } } }; + } else if (msg.method in methodResults) { + result = { result: methodResults[msg.method] }; + } socket.write(encodeFrame(JSON.stringify({ id: msg.id, ...result }))); }); socket.on('error', () => {}); @@ -149,4 +156,30 @@ describe('automation CLI end-to-end over HTTP + WebSocket', () => { expect(code).toBe(EXIT.staleRef); expect(err.join('\n')).toMatch(/stale/i); }); + + it('extracts links end-to-end', async () => { + mock = await startMock(() => [{ text: 'More', href: 'https://example.com/more' }]); + writeDescriptor(mock.port); + const out: string[] = []; + const code = await run(['extract', 'links'], { env: { TRONBROWSER_DATA: dataDir }, out: (t) => out.push(t) }); + expect(code).toBe(EXIT.ok); + expect(JSON.parse(out.join('\n'))[0].href).toBe('https://example.com/more'); + }); + + it('captures a screenshot end-to-end', async () => { + mock = await startMock(() => null, { + 'Page.captureScreenshot': { data: Buffer.from('PNGBYTES').toString('base64') }, + }); + writeDescriptor(mock.port); + let written: Uint8Array | undefined; + const code = await run(['screenshot', 'out.png'], { + env: { TRONBROWSER_DATA: dataDir }, + out: () => {}, + writeBytes: async (_p, bytes) => { + written = bytes; + }, + }); + expect(code).toBe(EXIT.ok); + expect(Buffer.from(written!).toString()).toBe('PNGBYTES'); + }); }); diff --git a/packages/browser-core/src/automate-headless.test.ts b/packages/browser-core/src/automate-headless.test.ts new file mode 100644 index 0000000..a6fa026 --- /dev/null +++ b/packages/browser-core/src/automate-headless.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from 'vitest'; +import { EXIT, run, type CliDeps } from './automate-cli.js'; +import type { CdpConnection } from './automation/cdp-client.js'; +import type { SessionDescriptor } from './automation/types.js'; + +const descriptor: SessionDescriptor = { + version: 1, pid: 1, host: '127.0.0.1', port: 9222, profileDir: '/x', + profileName: 'agent', headless: false, ephemeral: false, + createdAt: '2026-07-04T00:00:00.000Z', activeTabId: 'p1', +}; + +/** Fake connection with per-method canned results; simulates page load on navigate. */ +function connWith(handlers: Record): CdpConnection { + const evs: Record void> = {}; + return { + send: (async (method: string) => { + if (method === 'Page.navigate') { + queueMicrotask(() => evs['Page.loadEventFired']?.({})); + return {}; + } + return handlers[method] ?? {}; + }) as CdpConnection['send'], + on: (m: string, h: (p: unknown) => void) => { + evs[m] = h; + }, + close: vi.fn(), + }; +} + +function harness(handlers: Record, overrides: Partial = {}) { + const out: string[] = []; + const err: string[] = []; + const writes: Array<{ path: string; bytes: Uint8Array }> = []; + const calls: string[] = []; + const deps: Partial = { + env: {}, + loadDescriptor: async () => descriptor, + fetchTargets: async () => [ + { id: 'p1', type: 'page', url: 'https://example.com', webSocketDebuggerUrl: 'ws://x/p1' }, + ], + connect: async () => connWith(handlers), + launchHeadless: async () => { + calls.push('launch'); + }, + closeSession: async () => { + calls.push('close'); + }, + writeBytes: async (path, bytes) => { + writes.push({ path, bytes }); + }, + out: (t) => out.push(t), + err: (t) => err.push(t), + ...overrides, + }; + return { deps, out, err, writes, calls }; +} + +const evalResult = (value: unknown) => ({ 'Runtime.evaluate': { result: { value } } }); +const png = { 'Page.captureScreenshot': { data: Buffer.from('PNGDATA').toString('base64') } }; +const pdf = { 'Page.printToPDF': { data: Buffer.from('PDFDATA').toString('base64') } }; + +describe('extract command', () => { + it('prints extraction JSON', async () => { + const { deps, out } = harness(evalResult([{ text: 'A', href: 'https://x/a' }])); + const code = await run(['extract', 'links'], deps); + expect(code).toBe(EXIT.ok); + expect(JSON.parse(out.join('\n'))).toEqual([{ text: 'A', href: 'https://x/a' }]); + }); + + it('rejects missing target', async () => { + const { deps } = harness({}); + expect(await run(['extract'], deps)).toBe(EXIT.usage); + }); +}); + +describe('screenshot / pdf commands', () => { + it('writes a screenshot to the given path', async () => { + const { deps, writes, out } = harness(png); + const code = await run(['screenshot', 'shot.png'], deps); + expect(code).toBe(EXIT.ok); + expect(writes[0].path).toBe('shot.png'); + expect(Buffer.from(writes[0].bytes).toString()).toBe('PNGDATA'); + expect(out.join('\n')).toContain('screenshot -> shot.png'); + }); + + it('writes a pdf', async () => { + const { deps, writes } = harness(pdf); + expect(await run(['pdf', 'out.pdf'], deps)).toBe(EXIT.ok); + expect(Buffer.from(writes[0].bytes).toString()).toBe('PDFDATA'); + }); + + it('rejects screenshot without a path', async () => { + const { deps } = harness({}); + expect(await run(['screenshot'], deps)).toBe(EXIT.usage); + }); +}); + +describe('headless one-shot', () => { + it('launches, navigates, snapshots, and always closes', async () => { + const snap = { url: 'u', title: 'T', timestamp: 't', elements: [] }; + const { deps, out, calls } = harness(evalResult(snap)); + const code = await run(['headless', 'https://example.com', '--snapshot', '--json'], deps); + expect(code).toBe(EXIT.ok); + expect(calls).toEqual(['launch', 'close']); + expect(JSON.parse(out.join('\n')).title).toBe('T'); + }); + + it('captures a screenshot in headless mode', async () => { + const { deps, writes, calls } = harness(png); + const code = await run(['headless', 'https://example.com', '--screenshot', 'h.png'], deps); + expect(code).toBe(EXIT.ok); + expect(writes[0].path).toBe('h.png'); + expect(calls).toContain('close'); + }); + + it('closes the session even when the op fails', async () => { + const { deps, calls } = harness(png, { + writeBytes: async () => { + throw new Error('disk full'); + }, + }); + const code = await run(['headless', 'https://example.com', '--screenshot', 'h.png'], deps); + expect(code).toBe(EXIT.failed); + expect(calls).toEqual(['launch', 'close']); // cleanup still ran + }); + + it('rejects headless without a url', async () => { + const { deps } = harness({}); + expect(await run(['headless'], deps)).toBe(EXIT.usage); + }); +}); diff --git a/packages/browser-core/src/automation/capture.ts b/packages/browser-core/src/automation/capture.ts new file mode 100644 index 0000000..0e291b2 --- /dev/null +++ b/packages/browser-core/src/automation/capture.ts @@ -0,0 +1,42 @@ +/** + * Screenshot and PDF capture over CDP (PRD M3.3). Returns raw bytes; the CLI + * writes them to the requested path. PDF requires headless Chromium. + */ +import type { CdpConnection } from './cdp-client.js'; + +export interface ScreenshotOptions { + fullPage?: boolean; +} + +interface LayoutMetrics { + cssContentSize?: { width: number; height: number }; + contentSize?: { width: number; height: number }; +} + +/** Capture a PNG screenshot of the current page. */ +export async function screenshotPng( + conn: CdpConnection, + options: ScreenshotOptions = {}, +): Promise { + await conn.send('Page.enable'); + const params: Record = { + format: 'png', + captureBeyondViewport: options.fullPage === true, + }; + if (options.fullPage) { + const metrics = await conn.send('Page.getLayoutMetrics'); + const size = metrics.cssContentSize ?? metrics.contentSize; + if (size) { + params.clip = { x: 0, y: 0, width: size.width, height: size.height, scale: 1 }; + } + } + const res = await conn.send<{ data: string }>('Page.captureScreenshot', params); + return Buffer.from(res.data, 'base64'); +} + +/** Print the current page to PDF (headless only). */ +export async function printPdf(conn: CdpConnection): Promise { + await conn.send('Page.enable'); + const res = await conn.send<{ data: string }>('Page.printToPDF', { printBackground: true }); + return Buffer.from(res.data, 'base64'); +} diff --git a/packages/browser-core/src/automation/extract-script.test.ts b/packages/browser-core/src/automation/extract-script.test.ts new file mode 100644 index 0000000..91b1204 --- /dev/null +++ b/packages/browser-core/src/automation/extract-script.test.ts @@ -0,0 +1,109 @@ +// @vitest-environment happy-dom +import { beforeEach, describe, expect, it } from 'vitest'; +import { extractExpression, isExtractMode, parseFieldSpec } from './extract-script.js'; + +function run(expr: string): T { + return new Function('return ' + expr)() as T; +} + +beforeEach(() => { + document.head.innerHTML = ''; + document.body.innerHTML = ''; +}); + +describe('parseFieldSpec', () => { + it('parses name=selector', () => { + expect(parseFieldSpec('title=.t')).toEqual({ name: 'title', selector: '.t' }); + }); + it('parses name=selector@attr', () => { + expect(parseFieldSpec('url=a@href')).toEqual({ name: 'url', selector: 'a', attr: 'href' }); + }); + it('rejects a malformed spec', () => { + expect(() => parseFieldSpec('nope')).toThrow(/name=selector/); + }); +}); + +describe('isExtractMode', () => { + it('recognizes built-in modes', () => { + expect(isExtractMode('links')).toBe(true); + expect(isExtractMode('.card')).toBe(false); + }); +}); + +describe('extract links', () => { + it('returns text + absolute href, resolving relatives', () => { + document.body.innerHTML = ` + Abs + Rel`; + const links = run>(extractExpression('links')); + expect(links[0]).toEqual({ text: 'Abs', href: 'https://other.com/x' }); + // The relative href is resolved to an absolute URL (host is env-dependent). + expect(links[1].href).toMatch(/^https?:\/\/.+\/page$/); + }); +}); + +describe('extract forms', () => { + it('maps fields with labels, required, and omits password values', () => { + document.body.innerHTML = ` +
+ + + +
`; + const forms = run>>(extractExpression('forms')); + expect(forms).toHaveLength(1); + const f = forms[0] as { name: string; method: string; fields: Array> }; + expect(f.name).toBe('contact'); + expect(f.method).toBe('post'); + // hidden excluded; email + password only + expect(f.fields.map((x) => x.name)).toEqual(['email', 'pw']); + const email = f.fields[0]; + expect(email.label).toBe('Email'); + expect(email.required).toBe(true); + expect(email.value).toBe('a@b.com'); + expect(f.fields[1]).not.toHaveProperty('value'); // password value omitted + }); +}); + +describe('extract tables', () => { + it('returns headers and rows', () => { + document.body.innerHTML = ` + + + + + + +
NamePrice
Apple$1
Pear$2
`; + const tables = run>(extractExpression('tables')); + expect(tables[0].headers).toEqual(['Name', 'Price']); + expect(tables[0].rows).toEqual([ + ['Apple', '$1'], + ['Pear', '$2'], + ]); + }); +}); + +describe('extract custom selector + fields', () => { + it('maps each match to the requested fields with absolute urls', () => { + document.body.innerHTML = ` +
OneA
+
TwoB
`; + const rows = run>( + extractExpression('.card', [ + { name: 'title', selector: '.t' }, + { name: 'url', selector: 'a', attr: 'href' }, + ]), + ); + expect(rows.map((r) => r.title)).toEqual(['One', 'Two']); + expect(rows[0].url).toMatch(/^https?:\/\/.+\/a$/); + }); +}); + +describe('extract text / main', () => { + it('extracts main content text', () => { + document.body.innerHTML = `
Hello world
`; + const res = run<{ text: string }>(extractExpression('main')); + expect(res.text).toContain('Hello world'); + }); +}); diff --git a/packages/browser-core/src/automation/extract-script.ts b/packages/browser-core/src/automation/extract-script.ts new file mode 100644 index 0000000..1323a04 --- /dev/null +++ b/packages/browser-core/src/automation/extract-script.ts @@ -0,0 +1,118 @@ +/** + * Structured page extraction (PRD M3.3): built-in modes (text/links/forms/ + * tables/main) plus a custom CSS selector with `--field name=selector[@attr]`. + * + * `extractExpression` returns an IIFE evaluated in the page via CDP. Output is + * deterministic JSON with stable field names; relative URLs (href/src) resolve + * to absolute so callers never see page-relative links. + */ + +/** Built-in extraction modes. */ +export const EXTRACT_MODES = ['text', 'links', 'forms', 'tables', 'main'] as const; +export type ExtractMode = (typeof EXTRACT_MODES)[number]; + +export function isExtractMode(value: string): value is ExtractMode { + return (EXTRACT_MODES as readonly string[]).includes(value); +} + +/** A `--field name=selector` or `--field name=selector@attr` mapping. */ +export interface FieldSpec { + name: string; + selector: string; + attr?: string; +} + +/** Parse one `name=selector[@attr]` field spec. */ +export function parseFieldSpec(spec: string): FieldSpec { + const eq = spec.indexOf('='); + if (eq <= 0) throw new Error(`Bad --field (expected name=selector[@attr]): "${spec}"`); + const name = spec.slice(0, eq).trim(); + let selectorPart = spec.slice(eq + 1).trim(); + let attr: string | undefined; + const at = selectorPart.lastIndexOf('@'); + if (at >= 0) { + attr = selectorPart.slice(at + 1).trim(); + selectorPart = selectorPart.slice(0, at).trim(); + } + if (!name || !selectorPart) throw new Error(`Bad --field: "${spec}"`); + return attr ? { name, selector: selectorPart, attr } : { name, selector: selectorPart }; +} + +/** + * Build the in-page extraction expression. + * `target` is a built-in mode, or a CSS selector when `fields` are provided + * (or when it isn't a known mode). + */ +export function extractExpression(target: string, fields: FieldSpec[] = []): string { + return `(() => { + const abs = (el, attr) => { + if (attr === 'href' || attr === 'src') { const v = el[attr]; if (typeof v === 'string' && v) return v; } + return el.getAttribute(attr); + }; + const clean = (s) => (s || '').replace(/\\s+/g, ' ').trim(); + const labelFor = (el) => { + try { if (el.id) { const l = document.querySelector('label[for="' + (typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(el.id) : el.id) + '"]'); if (l) return clean(l.textContent); } } catch (_) {} + const w = el.closest('label'); return w ? clean(w.textContent) : ''; + }; + + const links = () => [...document.querySelectorAll('a[href]')].map((a) => ({ text: clean(a.textContent), href: a.href })); + + const forms = () => [...document.querySelectorAll('form')].map((f) => ({ + name: f.getAttribute('name') || f.id || null, + action: f.action || null, + method: (f.getAttribute('method') || 'get').toLowerCase(), + fields: [...f.querySelectorAll('input, select, textarea')] + .filter((el) => (el.getAttribute('type') || '') !== 'hidden') + .map((el) => { + const type = el.tagName.toLowerCase() === 'input' ? (el.getAttribute('type') || 'text').toLowerCase() : el.tagName.toLowerCase(); + const out = { + name: el.getAttribute('name') || el.id || null, + type, + label: labelFor(el) || el.getAttribute('aria-label') || el.getAttribute('placeholder') || '', + required: el.hasAttribute('required'), + }; + if (type !== 'password') out.value = el.value || ''; + return out; + }), + })); + + const tables = () => [...document.querySelectorAll('table')].map((t) => { + const headers = [...t.querySelectorAll('thead th, tr:first-child th')].map((th) => clean(th.textContent)); + const rows = [...t.querySelectorAll('tbody tr, tr')] + .filter((tr) => tr.querySelector('td')) + .map((tr) => [...tr.querySelectorAll('td')].map((td) => clean(td.textContent))); + return { headers, rows }; + }); + + const mainText = () => { + const m = document.querySelector('main, article, [role=main]') || document.body; + return { text: clean(m.textContent).slice(0, 20000) }; + }; + + const target = ${JSON.stringify(target)}; + const fields = ${JSON.stringify(fields)}; + + if (fields.length > 0) { + return [...document.querySelectorAll(target)].map((el) => { + const rec = {}; + for (const f of fields) { + const node = el.querySelector(f.selector) || (el.matches(f.selector) ? el : null); + if (!node) { rec[f.name] = null; continue; } + rec[f.name] = f.attr ? abs(node, f.attr) : clean(node.textContent); + } + return rec; + }); + } + + switch (target) { + case 'text': return { text: clean(document.body ? document.body.textContent : '').slice(0, 20000) }; + case 'links': return links(); + case 'forms': return forms(); + case 'tables': return tables(); + case 'main': return mainText(); + default: + // Bare selector: the text of each match. + return [...document.querySelectorAll(target)].map((el) => clean(el.textContent)); + } +})()`; +} diff --git a/packages/browser-core/src/automation/index.ts b/packages/browser-core/src/automation/index.ts index bcbb99c..594513b 100644 --- a/packages/browser-core/src/automation/index.ts +++ b/packages/browser-core/src/automation/index.ts @@ -15,3 +15,7 @@ export * from './snapshot-script.js'; export * from './action-script.js'; export * from './page-target.js'; export * from './page.js'; + +// Headless one-shot, extraction, and capture (PRD M3.3). +export * from './extract-script.js'; +export * from './capture.js'; diff --git a/packages/browser-core/src/automation/page.ts b/packages/browser-core/src/automation/page.ts index 340f972..9e5b11b 100644 --- a/packages/browser-core/src/automation/page.ts +++ b/packages/browser-core/src/automation/page.ts @@ -58,6 +58,27 @@ export async function enableRuntime(conn: CdpConnection): Promise { await conn.send('Runtime.enable'); } +/** Navigate the page to `url` and wait for load (or `timeoutMs`). */ +export async function goto( + conn: CdpConnection, + url: string, + options: { timeoutMs?: number } = {}, +): Promise { + const timeoutMs = options.timeoutMs ?? 30_000; + await conn.send('Page.enable'); + const loaded = new Promise((resolve) => conn.on('Page.loadEventFired', () => resolve())); + await conn.send('Page.navigate', { url }); + await Promise.race([ + loaded, + new Promise((resolve) => setTimeout(resolve, timeoutMs)), + ]); +} + +/** Run the extraction expression and return its deterministic JSON value. */ +export async function extract(conn: CdpConnection, expression: string): Promise { + return evaluate(conn, expression); +} + /** Capture a structured, ref-tagged snapshot of the current page. */ export async function captureSnapshot( conn: CdpConnection,