Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions apps/web/public/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,9 @@ Usage:
tron snapshot Structured, ref-tagged page snapshot (--json)
tron click <ref> Click a snapshot ref, e.g. @e3
tron fill <ref> <val> Fill an input by ref, e.g. tron fill @e4 "hi@x.com"
tron extract <mode> Extract text|links|forms|tables|main (JSON)
tron screenshot <p> Save a PNG of the current page (--full-page)
tron headless <url> One-shot: --snapshot | --screenshot <p> | --extract <mode>
tron upgrade Update to the latest release
tron remove Uninstall TronBrowser (keeps your profile data)
tron version Print the installed version
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
66 changes: 66 additions & 0 deletions docs/headless-and-extraction.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Headless and extraction (M3.3)

## One-shot headless

`tron headless <url>` 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 <main>/<article>

# 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.
211 changes: 185 additions & 26 deletions packages/browser-core/src/automate-cli.ts
Original file line number Diff line number Diff line change
@@ -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 <ref>
* tron fill <ref> <value>
* tron click <ref> | fill <ref> <value>
* tron extract <text|links|forms|tables|main|selector> [--field n=sel[@attr]]
* tron screenshot <path> [--full-page] | tron pdf <path>
* tron headless <url> [--snapshot|--screenshot <path>|--pdf <path>|--extract <mode>] [--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 = {
Expand All @@ -43,6 +52,9 @@ export interface CliDeps {
loadDescriptor(path: string): Promise<SessionDescriptor>;
fetchTargets(listUrl: string): Promise<CdpTarget[]>;
connect(wsUrl: string): Promise<CdpConnection>;
launchHeadless(dataDir: string): Promise<void>;
closeSession(dataDir: string): Promise<void>;
writeBytes(path: string, bytes: Uint8Array): Promise<void>;
out(text: string): void;
err(text: string): void;
}
Expand All @@ -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<CdpConnection> {
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<CdpConnection> {
let descriptor: SessionDescriptor;
try {
descriptor = await deps.loadDescriptor(descriptorPath(dataDir));
Expand All @@ -76,30 +102,107 @@ async function attach(deps: CliDeps): Promise<CdpConnection> {
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<void> {
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 <ref> | fill <ref> <value> | ' +
'extract <text|links|forms|tables|main|selector> [--field n=sel] | ' +
'screenshot <path> [--full-page] | pdf <path> | ' +
'headless <url> [--snapshot|--screenshot <path>|--pdf <path>|--extract <mode>] [--json]';

export async function run(argv: string[], overrides: Partial<CliDeps> = {}): Promise<number> {
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 <ref> | fill <ref> <value>');
deps.out(USAGE);
return EXIT.ok;
}

let conn: CdpConnection | undefined;
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': {
Expand All @@ -109,8 +212,7 @@ export async function run(argv: string[], overrides: Partial<CliDeps> = {}): 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': {
Expand All @@ -121,10 +223,67 @@ export async function run(argv: string[], overrides: Partial<CliDeps> = {}): 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 <text|links|forms|tables|main|selector> [--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 <path> [--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 <path>');
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 <url> [--snapshot|--screenshot <path>|--pdf <path>|--extract <mode>] [--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;
Expand Down
Loading
Loading