Docs

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 | --help

install 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:

PlaceholderBecomes
{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.pdf

Two 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.diff

Watching 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-design

A 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/docs

Or 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 15

Add --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/ --components

The 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:

FileHolds
tokens.jsonThe tokens in the W3C Design Tokens Community Group format, each with where it came from and how many elements use it
tokens.cssThe same as custom properties, the dark values in a prefers-color-scheme: dark block
tailwind.preset.jsA Tailwind preset whose values point at those custom properties
audit.mdValues 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.mdWCAG 2 contrast for every text color over the background it is drawn on, failures first
extract.jsonThe measurements themselves, for an agent or a script
components.jsonWith --components: each component, its instance count, its pages and its variants with their look and states
components.mdWith --components: the same as a table per component
catalog.pdfWith --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.jpg for scene 3's entry to scene-03-6.jpg for 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 390x844

It 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>&1

cron 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 scan that is one line, the absolute path. For tabs and browsers it is a column-aligned table with no separator row, so awk can read it.
  • stderr carries anything addressed to a person: progress, warnings, the reason something failed.
  • --json puts 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

CodeMeaning
0success
1the browser was reached and the work failed
2the arguments were wrong
3no usable browser: none connected, several connected, or the one named is not
4not set up: no pairing on this machine (install makes one)
5the 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%:F

From 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);

On this page