| name | pandascript |
|---|---|
| description | Write PandaScript agent scripts (.js) — Lightpanda's replayable browser-automation format, run token-free with `lightpanda agent script.js`. |
Run with:
./lightpanda agent script.jsThe script runs in its own V8 context — neither the page nor Node.js:
Pageis the only global.new Page()makes a page andawait page.goto(url)navigates it; every other primitive is a method on that page:const page = new Page(); await page.goto(url); page.extract({...}); page.click(sel);.- No
window,document, DOM,localStorage— read pages withpage.extract(...), run page-side JS only viapage.evaluate("..."). - No
require,process,fs, npm. Standard ECMAScript built-ins only (JSON,Map, template literals, …). page.goto(...)is async — alwaysawaitit. Page methods are synchronous:const data = page.extract({...}), neverawait page.extract(...). The script body runs as an async function, so top-levelawaitis allowed.- Re-navigating reuses the same page:
await page.goto(url2)keepspagevalid and points it at the new URL, discarding the old page — read it before navigating away. Independent URLs don't share a page: make anew Page()for each and load them in parallel (fan-out, best practice 2). - Page
evaluate("...")cannot see script variables — interpolate values into the string. Script code cannot see page variables. - Variables persist across navigations within one run, so cross-page aggregation is plain JS.
return <value>is the script's output, printed automatically (objects/arrays as JSON). End withreturn page.extract({...});orreturn results;. A bare trailing expression is NOT printed; neither isconsole.log(JSON.stringify(...)).
Page is the only global; new Page() makes a page and everything else is a method on it.
| Call | Notes |
|---|---|
new Page() |
Makes a page object. No navigation yet — call page.goto(url) before any other method. Make several to navigate in parallel (fan-out, best practice 2). |
page.close() |
Marks the page done; later method calls on it error. The page is otherwise reclaimed at script end. |
await page.goto(url[, { timeout }]) |
Async — must be awaited. Navigates the page (re-navigating reuses the same object). Waits for load. Rejects on navigation failure; a timeout does NOT reject (the page may still be usable). Default timeout 10000 ms. |
page.evaluate(script[, { url, timeout, save }]) |
Page-side JS escape hatch; returns text (JSON for objects/arrays). |
page.extract(schema) |
The only primitive returning a real JS value (object/array). The schema is its only argument. See schema below. |
page.click(selector) |
|
page.fill(selector, value) |
|
page.scroll([{ x, y }]) |
|
page.waitForSelector(selector[, { timeout }]) |
waitFor* default timeout 5000 ms. |
page.waitForScript(script[, { timeout }]) |
Re-evaluates page JS until truthy. |
page.waitForState(state[, { timeout }]) |
state: one of "load", "domcontentloaded", "networkalmostidle", "networkidle", "done". |
page.hover(selector) |
|
page.press(selector, key) |
Selector first! page.press("Enter") binds "Enter" to selector and fails — use page.press(null, "Enter") or page.press({ key: "Enter" }). |
page.selectOption(selector, value) |
|
page.setChecked(selector[, checked]) |
checked defaults to true. |
Options (the trailing { … } object; every option may be omitted):
goto:timeout— Optional timeout in milliseconds. Defaults to 10000.evaluate:url— Optional URL to navigate to before evaluating.timeout— Optional timeout in milliseconds. Defaults to 10000.save— Optional bridge-store key. The evaluate's return value is stored under this name and re-exposed aslp.<name>to subsequent evaluates. Objects, arrays, and strings are serialized automatically — no JSON.stringify needed.scroll:x— Optional: The horizontal scroll offset.y— Optional: The vertical scroll offset.waitForSelector:timeout— Optional timeout in milliseconds. Defaults to 5000.waitForScript:timeout— Optional timeout in milliseconds. Defaults to 5000.waitForState:timeout— Optional timeout in milliseconds. Defaults to 5000.
Calling convention: leading positionals + optional trailing options object, or one object with everything (page.waitForSelector("#row", { timeout: 2000 }) ≡ page.waitForSelector({ selector: "#row", timeout: 2000 })). A bare option positional (page.waitForSelector("#row", 2000)) and a field passed both ways are invalid arguments. null skips a positional. Arguments must be JSON-serializable.
CSS selectors only — backendNodeIds don't exist here. Standard CSS only: no jQuery :contains() or Playwright :has-text().
Keys = output field names; values pick what to lift (not a JSON Schema):
const { stories } = page.extract({
stories: [{
selector: "tr.athing", // one record per match
limit: 5,
fields: { // resolved relative to each match
title: ".titleline > a", // first match's text (null if missing)
url: { selector: ".titleline > a", attr: "href" },
text: "" // "" = the matched element's own text
}
}]
});"sel"→ first match's text;["sel"]→ all matches' text;{ selector, attr }/[{ selector, attr }]→ attribute(s);limit: Ncaps any array form.- Every value is a string or null — parse numbers in script logic.
- Empty arrays are valid results; if every field misses, extract throws ("no schema selector matched any element") → your selectors are wrong, not the page empty.
- An object schema always returns an object (destructure it); a bare array schema returns the array directly.
- No
save:option in scripts — keep results in variables.
- Navigate, settle, read. After
await page.gotoon a dynamic page (feeds, search results, comment threads), callpage.waitForState("networkidle")orpage.waitForSelector(...)before extracting. Most static pages are complete atload— don't wait blindly. - List-to-detail — fan out independent pages. Extract the list, then open one page per item and start every navigation together so the detail pages load in parallel instead of one-after-another:
const list = new Page(); await list.goto(listUrl); const { items } = list.extract({ items: [{ selector: "a.row", fields: { url: { attr: "href" } } }] }); const pages = items.map(() => new Page()); await Promise.all(pages.map((p, i) => p.goto(items[i].url))); // all in flight at once return pages.map((p, i) => ({ ...items[i], ...p.extract({ /* schema */ }) }));
- Concurrency is bounded by the HTTP connection pool: 40 total (
--http-max-concurrent) and 6 per host (--http-max-host-open, the browser default — raising it much higher risks overwhelming the target server). Extra navigations queue rather than fail, so a same-site fan-out loads ~6 pages at a time. For long lists, fan out in batches andpage.close()each page once read so its memory is reclaimed. Promise.allrejects the whole batch if anygotofails (a timeout does not reject); usePromise.allSettledwhen partial results are fine.- Walk serially on one page (
for (const it of items) { await page.goto(it.url); … }) only when the steps depend on each other — each page decides the next URL, or they share login/session state.
- Concurrency is bounded by the HTTP connection pool: 40 total (
evaluateis a last resort, not a reading tool. AquerySelectorAll-and-parsepage.evaluateblock is always wrong: lift the raw strings withpage.extract, then trim/split/parse them in top-level JS. Reservepage.evaluatefor behavior that must run inside the page and no builtin covers — and remember its state dies on every navigation/reload, while script variables persist.- Credentials via
$LP_*placeholders in any string argument (page.fill("#pw", "$LP_HN_PASSWORD")). Never inline a real secret; placeholders resolve inside the Lightpanda process. - Unique selectors. Disambiguate with attributes/position:
input[type="submit"][value="login"], notinput[type="submit"]. - Let failures fail. Primitives throw on error and stop the script — only
try/catchwhere you have a real fallback (e.g. optional cookie banner:try { page.click("#accept") } catch {}). - End with
return <result>.console.logis for debug output only and doesn't JSON-format objects. - Modern, readable JS:
const/let,for (const x of xs), template literals, destructuring, 2-space indent. - Comment the intent of each block. Put a one-line
//comment above each logical step describing what it accomplishes toward the goal (not restating the call). One comment per block, not per line — skip self-evident lines:// Load the Hacker News front page const page = new Page(); await page.goto("https://news.ycombinator.com"); // Pull the top 5 stories (title + link) const { stories } = page.extract({ stories: [{ selector: "tr.athing", limit: 5, fields: { title: ".titleline > a", url: { selector: ".titleline > a", attr: "href" } } }] }); // Open each story page in parallel and read its text const pages = stories.map(() => new Page()); await Promise.all(pages.map((p, i) => p.goto(stories[i].url)));
| Error | Cause / fix |
|---|---|
extract is not defined (or click/fill/…) |
These are methods on the page object, not globals → const page = new Page(); await page.goto(url); page.extract(...) |
Page must be called with new |
Page(...) called without new → const page = new Page(); |
page is not navigated or has been closed |
A method on a fresh new Page() (or a closed page) → await page.goto(url) first |
page handle is no longer valid |
Used a page after a later goto on the same page replaced it → read it before navigating away. Sibling pages from other new Page() calls stay valid. |
document is not defined |
DOM API in script context → use page.extract or page.evaluate |
require is not defined |
Not Node.js |
no page loaded - run page.goto(url) first |
Page method before navigation |
invalid arguments |
Wrong arity/shape, non-JSON value, or a field set both positionally and in options |
extract: no schema selector matched any element |
All schema fields missed → fix selectors |
press fails with one string arg |
Selector-first: use page.press(null, "Enter") or page.press({ key: "Enter" }) |