Commands
The whole surface, what each one prints, and what it exits with.
page-scanner install [--extension-id <id>]... [--browser-dir <dir>]... [--node <path>] [--json]page-scanner uninstall [--browser-dir <dir>]... [--json]page-scanner pair [--port <n>] [--rotate] [--wait <s>=120] [--no-wait] [--json]page-scanner status [--json]page-scanner browsers [--json]page-scanner tabs [--browser <id|label>] [--wait <s>=30] [--json]page-scanner scan (--url <u>... | --urls <file|-> | --tab <id>) [--window <id>] [--name <template>] [--markdown beside|only] [--main-content] [--max-chars <n>] [--links inline|references|text] [--pictures omit|alt] [--redact] [--hide <kinds>|all|none] [--slices] [--slice-side <px>=1568] [--picture-files] [--accessibility] [--tables] [--highlight <quote>]... [--highlight-from <file|->] [--browser <id|label>] [--format pdf|png|jpeg=pdf] [--page-size auto|a4|letter|phone=a4] [--quality <0-1>] [--video frame|blank] [--scheme auto|light|dark] [--page-width window|a4|letter|phone] [--open-editor] [--out <file|dir>] [--keep-existing] [--wait <s>=30] [--timeout <s>=120] [--json]page-scanner diff <old> <new> [--section <heading>] [--out <file>] [--json]page-scanner verify <file> [--record <file.integrity.json>] [--json]page-scanner design (--url <u>... | --urls <file|-> | --tab <id> | --crawl <u> [--max-pages <n>=10] [--depth <n>=2]) [--components] [--window <id>] [--out <dir>] [--min-uses <n>=2] [--browser <id|label>] [--wait <s>=30] [--timeout <s>=120] [--json]page-scanner storyboard (--url <u> | --tab <id>) [--viewport <w>x<h>] [--window <id>] [--out <file|dir>] [--keep-existing] [--browser <id|label>] [--wait <s>=30] [--timeout <s>=240] [--json]page-scanner serve [--daemon] [--idle <min>]page-scanner stop [--json]page-scanner --version | --helpinstall and uninstall
install sets up Page Scanner's helper, the program Chrome starts to connect the extension to
the daemon, for every Chromium browser it finds, and makes a pairing if there is none. It prints
which browsers, the helper's path and each manifest it wrote; --json puts the same on stdout.
An unpacked build loaded in developer mode is found in the browser's profiles and allowed too;
--extension-id <id> adds an id by hand, and --browser-dir <dir>
sets up only that user data directory, and --node <path> names the Node the helper runs on
(by default the one running install; a path like /opt/homebrew/bin/node survives an upgrade).
uninstall takes every manifest and the helper out again,
prints what it removed, and leaves the pairing. status says which browsers the helper is set up
for, and whether the Node it runs on is still there.
scan
--url or --tab. --url opens a background tab, in a green Page Scanner tab group unless
Group an agent's tabs is off in the settings, captures it and closes it again; --tab takes an
id from tabs and never closes what you already had open.
--out is a file when it ends in a 2 to 5 character extension and is not an existing
directory, and a directory otherwise, in which case the browser's suggested filename is used
inside it. Parent directories are created. A relative path is relative to your working
directory, not the daemon's, which is why the daemon hands the bytes back and the command writes
them.
--page-width lays the page out at the width of a sheet before capturing it, the way a narrow
window would, so the PDF prints at 1:1 rather than being scaled down. window, the default, is
the width the browser has the page at. A 1280 px window on A4 is scaled to 55 %, which puts 16 px
body text at 6.5 pt; --page-width a4 puts it at 12.
--scheme picks which of a page's two themes to capture, for a page that has both. auto, the
default, is whichever the browser is showing; light and dark force one. It applies to the PDF
as well as the preview, which is the point: Chrome prints every page in the light scheme unless it
is told otherwise, so without this a dark page produced a white PDF.
--page-size decides what a PDF is laid onto. a4 and letter slice the capture across
printable sheets with a half-inch margin; auto is one page the exact size of the capture, which
is not printable and which Acrobat clamps past 200 inches.
Hiding clutter first
--hide ads,consent,chat,overlays (any of them, or all, or none) hides ads, cookie and
consent banners, chat widgets and pop-ups before the capture, and puts them back after, the way
the extension's Clean up before capture does.
Without the flag, the extension's own settings decide, and they hide all four unless you
turned them off. What was hidden is said on stderr
("Hid 7 ads and a consent banner before the capture.") and counted in --json as hidden.
Slices for a vision model
--slices also writes the capture as PNG slices, top to bottom, into a folder beside the file:
page.pdf gives page.slices/slice-01.png and on. Each is at most --slice-side pixels on a side
(1568 by default, the size past which Claude shrinks a picture; set another model's), and repeats
the last 48 px of the one before, so a line on a seam is whole in one of them. A screenshot of a
whole long page is shrunk by a model until its text cannot be read, and a slice is not.
--picture-files writes the page's pictures and canvases, the charts, diagrams and screenshots
at least 120 by 40 px, into page.pictures/, cut from the capture where they were drawn. --json
lists each file with the part of the page it shows as slices and pictures.
--accessibility checks the page against WCAG 2.0, 2.1 and 2.2, levels A and AA, with axe-core,
and writes page.accessibility.pdf, the capture with each problem boxed and numbered where it is
and the rules listed after it, and page.accessibility.json, the whole report. An automated check
finds some of what fails WCAG, not all of it, so a page with nothing reported still needs a person
to look at it.
Tables as data
--tables writes the page's tables as page.tables.json, for a script or a spreadsheet to read
rather than the PDF:
{ "tables": [ { "header": true, "rows": [ ["Plan", "Price", "Seats"], ["Pro", "$5", "1"] ], "box": { "x": 120, "y": 840, "width": 640, "height": 96 } } ]}Every row is as wide as the widest, a cell is its words on one line (a link as its text, an icon
by its name), header says whether the first row is the page's own header row, and box is where
the table was on the page, in CSS px. A <table> counts, and so does a table a site draws with
table roles (role="table" or "grid") or as a CSS grid. A grid that draws only the rows on
screen has only those. With --markdown, --main-content keeps the tables in the main content
and --redact hides the same values in them as in the text. The editor writes the same tables as
CSV.
Quoted passages, highlighted
--highlight "<quote>", once a passage, or --highlight-from <file> (one a line, or a JSON array;
- reads standard input), marks passages you quoted from the page where the page has them as
written, found the way check finds them. A PDF lists each under a Quoted passages
bookmark, so a reader of your notes can go from a quote to the page. --json says which were found
as highlighted; one the page does not have as written is named on stderr and not marked.
When the page declares how to cite it (citation tags, schema.org data, Dublin Core, OpenGraph),
--json has it as citation: the kind of work, and the title, authors, published date,
container (journal or site), volume, issue, pages, doi and the rest it gave, as it wrote
them and unchecked, with from, where they came from. The file itself carries the same. A page the
browser had machine-translated before the scan has translated ({ "by": "chrome" }, or
edge), and the file says it is a translation.
The page as text
--markdown beside writes the page's text as Markdown next to the file, and prints its path on a
second line. --markdown only writes the .md and no file. The text is read from the page itself,
the way the editor's Markdown export reads it: headings, paragraphs, lists, quotes, code, tables
and links, with the title, address and capture time at the top. Pictures are left out. With
--json the result also has page: the title, the address, the capture time, the page's language
and its headings.
For an agent's context window, --main-content writes only the main content the extension found
(the whole page when it found none), --max-chars <n> keeps whole blocks up to that many
characters and reports in page.cut how long the whole was and how many blocks it left out,
--links references lists each address once at the end and --links text drops them, and
--pictures alt leaves a placeholder with each picture's alt text. Each heading in page.headings
has offset and chars, where its section starts in the text and how long it is. Dropping the
addresses is what shortens a link-heavy page most: Korean Wikipedia's "PDF" went from 85,657
characters to 37,134.
--redact replaces what Sensitive Text's rules find in the text before it leaves the browser:
every email address, phone number, card and IBAN, ID or tax number, IP address, key and token,
and the patterns you saved in the extension's Sensitive Text settings, each with a numbered
placeholder such as ⟦EMAIL 1⟧, the same value always the same placeholder. page.redacted counts
what was replaced by kind, never the values. It is for a signed-in page whose text goes on to a
cloud model; only the Markdown is redacted, so use it with --markdown only, since a PDF beside
it holds the page as it was. An extension older than this refuses the scan rather than send the
values.
It needs an extension new enough to send the text. An older one sends the file alone, and the command then fails with a sentence saying to update it, and writes nothing.
A list of pages
Give --url more than once, or --urls with a file of addresses, one to a line. Blank lines and
lines starting with # are skipped, and --urls - reads the list from standard input.
page-scanner scan --urls reading.txt --out ~/Captures/ --name '{date}/{n}-{host}'The pages are scanned one at a time, in order, into --out, which has to be a directory (end it
with a slash if it does not exist yet). Each path is printed as its file is written, and a page
that fails is reported on stderr with its address while the rest carry on. The run stops early
only when nothing after it could work: no pairing, no browser, no daemon. It exits 1 if any
page failed, and --json gives one document at the end with a result for each page. A list takes
up to 1,000 addresses, and --open-editor is not for a list.
--name sets the file name, for a list or a single page:
| Placeholder | Becomes |
|---|---|
{n} | the page's place in the list, 001 in a list of a hundred |
{host} | the address's host, dots as hyphens: docs-example-com; with --tab, the tab's |
{name} | the name the browser suggests, without its extension |
{date} | the day the run started, 20260923 |
{time} | the time it started, 0800 |
{ext} | the format's extension; added at the end if the template has none |
A / in the template makes a subdirectory. Without --name, each file takes the browser's name,
which already has the page and the minute in it, and two pages of one run that would get the same
name are written as -2, -3. A file left by an earlier run under the same name is overwritten,
unless --keep-existing is given: then the earlier file stays and the new one is written as -2,
-3, for one page as for a list.
What changed
page-scanner diff <old> <new> compares two captures of a page and needs no browser.
page-scanner scan --url https://example.com/pricing --markdown beside --out ~/Captures/page-scanner diff ~/Captures/monday.pdf ~/Captures/tuesday.pdfTwo Markdown files, or two PDFs with the Markdown --markdown beside wrote next to them, are
compared a passage at a time, and it prints what diff -u prints, with a count on stderr. The
capture time in each file's front matter is set aside, so the same page captured twice is no
change.
--section compares one section of the page, found by its heading, and nothing around it: the
banners, a "latest posts" rail and the footer can change every day without the plans table
changing.
page-scanner diff monday.md tuesday.md --section "Pricing > Pro"The heading is matched with its case and markup set aside, and a path of headings (> or ›
between them) picks one under another. The section runs to the next heading of the same level or
higher. A section only one capture has is a change; one neither has exits 2 and lists the
headings they do have.
Two PNGs are compared pixel by pixel, and the newer one is written again with every changed region
outlined in red, to --out or to <new>.diff.png beside it; stdout is that path. The comparison
is by position, so content that moved down the page shows as changed below the move: for that, the
text is the better comparison. JPEGs are refused, because their compression changes pixels on its
own.
It exits 0 when nothing changed and 1 when something did, so a scheduled job can send
itself a note only when it matters:
page-scanner diff old.md new.md > changes.diff || mail -s "The pricing page changed" me < changes.diffWatching a page for changes puts scan and diff together in a script that keeps
the last capture and a dated copy of each change.
Checking an integrity record
page-scanner verify <file> checks a file against the integrity record the extension exported
beside it, <file>.integrity.json unless --record names another. It prints whether the file's
SHA-256 and size match, whether the signature holds and which key made it, and that the time in
the record is the capturing computer's clock. It exits 0 when everything holds and 1 when
anything does not.
Checking quotes
Before an agent puts a page's words in an answer, page-scanner check says whether each quote or
value is in the capture's Markdown as written, and where:
page-scanner check pricing.md "Pro is \$10 a month" "billed yearly at \$120"found "Pro is $10 a month": line 9, Pricing › Promissing "billed yearly at $120": line 9, Pricing › Pro agrees up to "...billed yearly at $", then has "96." where the quote has "120"The Markdown's own markup is undone first (escapes, emphasis, link syntax, list and quote
prefixes, table pipes) and line wrapping is ignored, so a quote of the page's words matches however
the export marked them up. A quote never runs from one block into the next, and a word or number
has to stand apart: 4.99 is not in $14.99. A quote that matches only once curly quotes, dashes
and case are folded is reported as loosely and is not counted as found; --loose accepts it.
Quotes come after the file, or from --from quotes.txt (one a line, or a JSON array of strings),
or --from - for standard input. --json gives the whole result for a script. It exits 0 when
every quote is there and 1 when one is not. No browser and no model: the capture is a file on
disk, from scan --markdown. The MCP server has the same check as
check_quotes.
A page's design
page-scanner design reads the design a page is drawn with and writes it down as tokens, with two
reports.
page-scanner design --url https://example.com/ --out ./example-designA site's design is spread over its pages: the home page has no pricing table and the docs have no
hero. Give --url more than once, or --urls <file> with up to 50 addresses, and the pages are
read one after another and written as one design, the uses added up. audit.md then names the
pages and lists the values only one of them uses, such as a corner radius only the docs have.
page-scanner design --url https://example.com/ --url https://example.com/pricing --url https://example.com/docsOr let it find the pages: --crawl <address> follows the page's links to its own site, breadth
first, and reads one page of each kind, so a blog is read through one of its posts while each
section with pages of its own is read. It stops at
--max-pages (10 unless you say, 50 at most) and at --depth links from the start (2, at most
5). It follows only <a href> links, never presses a button or sends a form, skips nofollow
links, honors the site's robots.txt for every page but the one you gave, never opens an address
that acts (log out, sign out, delete, unsubscribe), since these are your own signed-in tabs, and
never opens a file. A page that a redirect took to another site (a sign-in page, say) or to a page
it already read is left out, and a page the server answered with an error, such as a 403 "Access
Denied", is not read into the design. stderr says how many links it passed over and pages it left
out, and why.
page-scanner design --crawl https://example.com/ --max-pages 15Add --components to find the components too: the buttons, fields and repeated boxes, each with
its variants and what changes when you point at it or tab to it. Each page is scanned as well,
and three more files are written: components.json, components.md, and catalog.pdf, which
shows every variant cut from the page, still sharp and with its text selectable, and its values
beside it.
page-scanner design --crawl https://example.com/ --componentsThe page is read twice, in its light scheme and its dark one. An address is opened in a tab of its
own and reloaded under each scheme, because many sites choose their theme as they load; a tab you
have open (--tab) is read as it stands and never reloaded. Six files are written to --out, or to
design-<host> in the working directory, whose path is printed, and nine with --components:
| File | Holds |
|---|---|
tokens.json | The tokens in the W3C Design Tokens Community Group format, each with where it came from and how many elements use it |
tokens.css | The same as custom properties, the dark values in a prefers-color-scheme: dark block |
tailwind.preset.js | A Tailwind preset whose values point at those custom properties |
audit.md | Values so close they were probably meant as one: two grays, a 5 px radius beside a 6 px one, 15 px text beside 16 px |
contrast.md | WCAG 2 contrast for every text color over the background it is drawn on, failures first |
extract.json | The measurements themselves, for an agent or a script |
components.json | With --components: each component, its instance count, its pages and its variants with their look and states |
components.md | With --components: the same as a table per component |
catalog.pdf | With --components: every variant cut from its page as vector artwork, with its values under it |
A custom property the page declares keeps its name, and one that is a formula
(oklch(from var(--surface) calc(l - .04) c h), a calc() in a channel, color-mix()) is written
as the color the browser works it out to. When several names hold one value, or two
colors nobody could tell apart, they are one token under the most used name, and the others are
listed in its aliases. A declared length counts only what its name says it is for: --radius-*
counts radii, --space-* and --gap-* spacing, --text-* font sizes, --leading-* line heights,
--tracking-* letter spacing, and a length whose name says none of these (--blur-lg) counts
nothing. A rem is read at 16 px, and a radius of 1000 px or more is a pill, written 9999px. Any
other value becomes a token when at least --min-uses elements use it (2 unless you say), and is
numbered by how much it is used: color-1 is the most used color. A numbered color has a dark
value only when the dark scheme clearly swaps it for another, so one without is not necessarily
the same in dark. Two runs on the same pages write
the same files, byte for byte, extract.json and catalog.pdf aside, unless a page itself changed
between them. A page that could not be read is reported and the rest are used, and the command
then exits 1.
Text drawn over a gradient or a picture is listed apart in contrast.md and neither passes nor
fails, since the color under the picture is not what a reader sees. Text over a layer that another
element paints (a positioned block, a canvas, a video) is measured against the wrong background,
so read a failure there with the page open.
A page's scroll as a storyboard
page-scanner storyboard scrolls a page from its top at 1.25 screens a second, the pace of a
person reading quickly, and writes how it behaves: where the screen holds while its content
plays, what fades, slides, scales or is uncovered, and whether it glides on or plays once when
the scroll stops. It is the extension's Scan scrolling, for an
agent rebuilding a page's scroll animations.
page-scanner storyboard --url https://example.com/ --out ./boards/It writes three things beside each other, named after the page and the day:
example-storyboard-2026-10-24.pdf: an overview, then a page for each scene with six frames from its entry to its exit and what happens in it, the same file the editor's Extract writes.example-storyboard-2026-10-24.json: every scene's kind (pinned, the screen holds while it plays;motion, things move as it scrolls;scroll, plain scrolling), its scroll range in px, what moved in sentences, its text on screen, how it behaves once the scroll stops, and its six frames, each with its file.example-storyboard-2026-10-24.frames/: the frames as JPEGs,scene-03-1.jpgfor scene 3's entry toscene-03-6.jpgfor its exit, at most 720 px wide.
A tab opened for --url is scanned at --viewport, 1440x900 without it. --tab scrolls a tab
you have open, where you can see it, at its window's size, or at --viewport and put back after:
page-scanner storyboard --tab 1234 --viewport 390x844It takes up to two minutes, so --timeout is 240 seconds here. Scrolling, clicking or typing on
the page, or the popup's Stop the scan, ends it early with what it had, and the JSON's ended
says which; cut is set when it stopped short of the page's end. The PDF's path goes to stdout
and a count of scenes and frames to stderr. One storyboard scan runs at a time in a browser.
On a schedule
The command has no scheduler of its own. The operating system's runs it at a time, survives a restart and keeps a log. Two things have to be true when it fires: Chrome is running in the profile you connected, and the extension is connected.
Through the helper, that takes care of itself: once Connect is pressed, Chrome keeps the helper running for as long as it runs, the helper looks for the daemon every second and a half, and the scheduled command starts the daemon, so the browser arrives a moment after it does.
A browser paired with a port and a token dials the bridge itself when Chrome starts. If nothing is
listening then, it tries again for as long as Chrome keeps it awake, and after that it waits until
something wakes it. So on that path, start the daemon when you log in, before Chrome, and keep it
running: page-scanner serve --daemon --idle 0. Once connected, the extension stays connected,
and the scheduled scan finds it there. Give the scheduled command a longer --wait too, for the
minute after Chrome itself starts.
macOS and Linux, cron. crontab -e, then:
PATH=/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin@reboot page-scanner serve --daemon --idle 00 8 * * 1-5 page-scanner scan --urls ~/reading.txt --out ~/Captures/ --name '{date}/{n}-{host}' --wait 120 >> ~/Captures/scan.log 2>&1cron starts with almost nothing on its PATH, so the first line names where node and
page-scanner are (which page-scanner says).
macOS, launchd. Save this as ~/Library/LaunchAgents/app.pagescanner.morning.plist and load it
with launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/app.pagescanner.morning.plist. The
login shell (zsh -lc) is what puts node on the path.
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict> <key>Label</key><string>app.pagescanner.morning</string> <key>ProgramArguments</key> <array> <string>/bin/zsh</string><string>-lc</string> <string>page-scanner scan --urls ~/reading.txt --out ~/Captures/ --name '{date}/{n}-{host}' --wait 120</string> </array> <key>StartCalendarInterval</key> <dict><key>Hour</key><integer>8</integer><key>Minute</key><integer>0</integer></dict> <key>StandardOutPath</key><string>/tmp/page-scanner-morning.log</string> <key>StandardErrorPath</key><string>/tmp/page-scanner-morning.log</string></dict></plist>A second agent with RunAtLoad and page-scanner serve --daemon --idle 0 keeps the daemon up from
login.
Windows, Task Scheduler. From a Command Prompt, one task for the daemon at log on and one for the scan:
schtasks /Create /SC ONLOGON /TN "Page Scanner bridge" /TR "cmd /c page-scanner serve --daemon --idle 0"schtasks /Create /SC DAILY /ST 08:00 /TN "Page Scanner morning" /TR "cmd /c page-scanner scan --urls %USERPROFILE%\reading.txt --out %USERPROFILE%\Captures --name {date}/{n}-{host} --wait 120"Waiting
Chrome retires the extension's service worker after about thirty seconds of silence, and it dials
back in when something wakes it. --wait covers that reconnect, and defaults to 30 seconds for
tabs and scan. --wait 0 means do not wait at all, and fails immediately instead.
What a command prints
- stdout carries the answer and nothing else. For
scanthat is one line, the absolute path. Fortabsandbrowsersit is a column-aligned table with no separator row, soawkcan read it. - stderr carries anything addressed to a person: progress, warnings, the reason something failed.
--jsonputs exactly one JSON document on stdout, for success and for failure alike, and leaves stderr empty.
{ "ok": true, "path": "/Users/you/pdf.pdf", "width": 1280, "height": 4200, "mode": "vector", "selectableText": true, "truncated": null, "browserId": "b-9f2c41", "fileName": "PDF - Wikipedia.pdf"}{ "ok": false, "code": 3, "error": "NO_BROWSER", "message": "No browser is connected." }truncated is null rather than absent when the capture was whole, so a reader can see the
question was asked. When it is not null it carries what the page measured, what was captured, and
a sentence naming the gap.
Exit codes
| Code | Meaning |
|---|---|
| 0 | success |
| 1 | the browser was reached and the work failed |
| 2 | the arguments were wrong |
| 3 | no usable browser: none connected, several connected, or the one named is not |
| 4 | not set up: no pairing on this machine (install makes one) |
| 5 | the daemon would not start |
diff uses the codes the way diff itself does: 0 when the two captures are the same, 1 when
something changed, and 2 for anything it cannot compare. check does the same: 0 when every quote is
there, 1 when one is not, 2 for a missing file or no quotes.
The daemon
Started on demand by the first command that needs one, and reused by every command after it, including the MCP server's.
page-scanner serve runs it in the foreground, which is how to see why it will not start.
page-scanner stop stops it. It exits on its own after 15 idle minutes
(PAGE_SCANNER_IDLE_MINUTES, 0 for never) and never while a browser is attached: the open
socket is what keeps Chrome's service worker alive.
Files
Everything lives in ~/.page-scanner, or in $PAGE_SCANNER_HOME if that is set: config.json
(the pairing), daemon.json (the running daemon), daemon.log, and native-host/, the helper
install copied there and the script Chrome runs to start it. The first two are mode 0600,
which Windows does not enforce: NTFS has no such mode, so both are readable by anything
running as you. On a shared machine, restrict the directory yourself.
icacls %USERPROFILE%\.page-scanner /inheritance:r /grant:r %USERNAME%:FFrom Node
import { scan, scanMany, listTabs } from '@page-scanner/cli';
const { tabs } = await listTabs();const result = await scan({ tabId: tabs[0].tabId, out: './out/', format: 'pdf' });console.log(result.path, result.selectableText);
const batch = await scanMany(['https://example.com/a', 'https://example.com/b'], { out: './out/', name: '{n}-{host}',});console.log(batch.written, batch.failed, batch.results);