A QA automation tool that finds, fills, and verifies contact forms on websites you own or are authorized to test — and now monitors websites for meaningful changes over time.
The web UI is organized around Projects (a client + their URLs), plus two tool areas — Forms and Site — and Docs:
| Area | What it is |
|---|---|
| Projects | Group a client's URLs into a project; see their form, uptime & SSL health in one place (a thin overlay over the monitors). URLs you've tested or monitored but not grouped appear in an Unassigned bucket to assign or dismiss. |
| Status page | Per-client health page built from the same data. Internal ops view (/projects/<id>/status, auth-gated, with technical detail) + an opt-in public shareable link (/status/<token>, no login, client-safe only). Uptime history, 24h/7d/30d, response-time trend, SSL & form status. |
| Forms | Test a form (on-demand — results persist across tabs/refresh, with a Clear button) · Scheduled monitors (recurring form tests + Slack alerts) |
| Site | Uptime & SSL (availability + cert-expiry monitoring + Slack) · Change tracking (content / SEO / form / script changes over time) |
- Accepts one URL or a batch file of URLs
- Discovers the contact page using deterministic heuristics (path matching + anchor text scoring)
- Verifies the top candidates by loading them with Playwright and scoring the page content
- Detects the main contact form on that page using heuristic scoring (field types, submit button text)
- Fills the form with configurable test data
- Optionally submits it and observes for a thank-you redirect or inline success message
- Returns a structured JSON result for each site
Landing-page mode (--landing-page, or the "Landing page" toggle in the UI): skips steps 2–3
and runs form detection directly on the given URL — for standalone landing pages that have an
inline form and no separate /contact page (which would otherwise fail CONTACT_PAGE_NOT_FOUND).
Off by default. Available on both the Form Tester and Form Watch (remembered per scheduled monitor).
- Crawls a small set of important pages (homepage, about, pricing, services, contact, thank-you)
- Saves a snapshot of each page (title, meta, H1, form fields, buttons, scripts, text-hash, optional screenshot)
- Re-checks later, compares old vs new, and reports meaningful changes
- Detects content, SEO, form, and technical changes
- Optional AI summary turns the diff into a human-readable paragraph
FormPing is for authorized testing only.
- Use it only on websites you own, operate, or have explicit written permission to test
- Never use it against third-party sites without permission
- Never use it to spam, flood, or abuse contact forms
- Real submissions in live mode send real emails — use a test email address you control
- Cannot bypass CAPTCHA or anti-bot systems (by design)
- JavaScript-heavy SPAs may need longer timeouts
- Some forms use non-standard submit mechanisms that heuristics may miss
- AI fallback is a stub — you must implement the LLM provider yourself
- Does not handle file upload fields
- Node.js >= 18
- npm >= 9
cd formping
npm install
npx playwright install chromiumnpm run start -- --url https://example.comnpm run start -- --url https://example.com --mode livenpm run start -- --url https://example.com --mode detect-onlynpm run start -- --file sites.txt --output results.jsonnpm run start -- --file sites.csv --output results.json --json-pretty --mode livenpm run start -- --url https://example.com --headed--url <url> Single URL to test
--file <path> Path to .txt or .csv with one URL per line
--mode <mode> live | safe | detect-only (default: safe)
--headed Show browser window
--output <path> Write results to JSON file
--json-pretty Pretty-print JSON output
--timeout <ms> Per-action timeout (default: 15000)
--concurrency <n> Batch concurrency (default: 2)
--ai Enable AI fallback (disabled by default)
--email <email> Override test email address
| Mode | Behavior |
|---|---|
safe |
Discover contact page, find form, fill fields — do not submit |
live |
Full flow including form submission — use only on authorized sites |
detect-only |
Find contact page and form, do not fill or submit |
Default is safe.
Edit src/config.ts to customize:
testData— names, email, phone, message used to fill formsthankYouUrlPatterns— URL patterns that indicate successful submissioninlineSuccessPatterns— text patterns on-page that indicate successbatchConcurrency— number of parallel sites in batch modeheadless— show/hide browsertimeout/navigationTimeout— timeouts in mssaveScreenshotOnFailure— capture screenshot on failure (wiring optional)saveHtmlSnapshotOnFailure— save HTML snapshot on failure (wiring optional)
FormPing persists all structured data to Supabase (Postgres). It is
required — there is no JSON fallback (the app expects the two env vars below
to be set). Confirm the live backend and environment any time via
GET /api/health → {"storage":"supabase","schema":"public"|"dev","environment":"production"|"development"}.
Set these server-only vars in ui/.env.local (local) and Railway's Variables
(prod). Never commit them:
SUPABASE_URL=https://<your-ref>.supabase.co
SUPABASE_SERVICE_ROLE_KEY=<secret key> # bypasses RLS — server-side only
SUPABASE_SCHEMA=dev # LOCAL ONLY — see "Environments" below
Production and local development share one Supabase project, isolated by Postgres schema, so development (including destructive tests) can never touch production data:
| Schema | Environment | SUPABASE_SCHEMA |
|---|---|---|
public |
production (Railway) | unset (defaults to public) |
dev |
local development | dev (in .env.local) |
This is working-agreement rule 6, "Production data is sacred." DB scripts
under ui/scripts/db/ enforce it: guard.mjs refuses to run a mutation unless
SUPABASE_SCHEMA=dev, so a smoke test can never hit public. Run
node ui/scripts/db/smoke.mjs (CRUD round-trip on dev) or
node ui/scripts/db/compare-schemas.mjs (read-only row counts, both schemas).
The schema lives in supabase/migrations/ — see that folder's README.md for
the dual-apply process (each migration is schema-agnostic and runs against BOTH
public and dev). Tables are named after the app's tools:
- Phase 1 (core) —
projects,form_tester_runs(Test a form),form_watch_schedules(Form Watch),site_watch_schedules(Site Watch),dismissed_urls(Projects "Don't track"). - Phase 2 (history + reports) —
form_watch_runs(Form Watch run history),site_watch_runs(Site Watch check history),change_reports(Change Monitor). The two history tables cascade-delete with their parent schedule, so removing a monitor also clears its run log (no orphan rows). - Phase 3 (durable results) —
form_watch_results,site_watch_results: the last known result per URL, written on every scheduled run. Keyed by URL (not by monitor), so they survive stopping/deleting a monitor — see the lifecycle rules below. - FR-20 (daily rollup) —
site_watch_daily: one summarised row per URL per UTC day (checks, up/down, response sum+count, SSL min), written by the Site Watch ticker each check. Powers the dashboard's 7d / 30d / All-time uptime & response charts truthfully (raw history is capped at 200 rows). - FR-22 (alerts) —
alerts: the alert delivery log. Plumbing, not a feature — nothing in the UI reads it. It exists so the dispatcher can dedupe across restarts (uniquededupe_key) and answer "why didn't I get an alert for X?" (delivery). The alert's full detail is deliberately not copied here — it already lives inchange_reports/ the run-history tables. - FR-21 (change events) —
change_events: one slim row per Change Monitor run, for all three modes (snapshot|compare|watch). Deliberately separate fromchange_reports: events are small and kept long (they power the Projects status line + the dashboard timeline), while the heavydetailspayload inchange_reportsis pruned to the recent few. Asnapshotwrites an event but no report — which is how a freshly-baselined URL now shows up in its project instead of looking untracked.
RLS is enabled on every table (the anon key can do nothing; the server's secret key bypasses it and has full access).
Two views share one StatusView, curated server-side so nothing technical
can reach a client:
- Internal dashboard (
/projects/[id]/status, auth-gated) — full detail: uptime + response-time charts, HTTP status, check frequency, domain expiry, form verdict, and the content-change timeline (every Change Monitor run in the window). Timeline rows that found changes expand to show that run's page-by-page breakdown, fetched on demand from/api/projects/[id]/changes?site=&at=(auth-gated). Built withbuildClientStatus(project, { internal: true }). - Public status page (
/status/[token]) — client-safe only: overall status, hostname, uptime %, uptime history, SSL validity, contact-form working/not. No response times, latency, check frequency, reason codes, full URLs, or content-change detail.
Content changes are internal-only by design: a diff is a technical QA signal, and
"84 changes detected" would alarm a client about what is often their own team's
intentional edits. It is enforced by types — changes exists only on
InternalStatus, and the public route returns ClientStatus, so it cannot leak.
Similarly, when the crawler simply can't locate a contact page to test
(CONTACT_PAGE_NOT_FOUND / CONTACT_PAGE_AMBIGUOUS), the client page treats
it as not monitored rather than "contact form needs attention" — telling a
client their form is broken when we merely couldn't find a page to test is a
false alarm. Internally it stays a real signal: the team still sees the reason on
the dashboard, and it still counts toward the internal overall.
Both carry a Today / 7 days / 30 days / All-time filter (?window=).
Signing in is gated to allow-listed Google domains (ALLOWED_AUTH_DOMAINS). On
top of who can get in, each user has a role that controls what they can
do, stored in the app_users table (keyed by their Google email):
| Role | Can |
|---|---|
| Owner (exactly one) | Everything, + manage admins + transfer ownership |
| Admin | Full app incl. delete projects + manage members/viewers |
| Member (default) | Add URLs, create/run/edit monitors, view everything — no delete, no user management |
| Viewer | Read-only |
Google proves who you are (the email); the role is FormPing's, assigned in the
table. A new allow-listed login defaults to Member. Enforcement is
server-side on every mutating route (requireRole): a Viewer is blocked
from all writes (genuinely read-only), a Member can create/run/edit monitors and
projects but can't delete a project or manage users (Admin+), and only the
Owner manages admins or transfers ownership. The role is read fresh from the DB
per request, so a promotion/demotion takes effect immediately.
The UI mirrors this — action buttons hide for roles that can't use them, viewers see a read-only banner, and any blocked action surfaces a clear message rather than failing silently. But the server is the real gate; the UI is only convenience.
Team management. Admins and the Owner get a Team tab (/team) to list
users, change roles (respecting the ownership rules), remove users, and — Owner
only — transfer ownership. The rules live in one place (userStore), enforced
by both the API and the DB single-owner index.
Env:
OWNER_EMAIL=you@apexure.com # break-glass owner (see below)
ADMIN_EMAILS=manager@…,pm@… # optional: start these as Admin on first login
Lockout safety. There is always exactly one Owner — the Owner can't be
demoted, only transferred (an atomic action that swaps the successor to Owner
and the old Owner to Admin). OWNER_EMAIL is break-glass: it re-seeds an Owner
only when the table has none, so the app can never end up with no
administrator, yet a real transfer still sticks. A database single-owner index
backs the app-level checks.
Handoff (when the person who runs it leaves). The in-app "Transfer ownership"
changes only the role table — it never touches projects or data, so it can't lock
the app or lose anything. But the app role is not the infrastructure: GitHub,
Railway, and Supabase are owned at the account level (separately). A full handoff
is: transfer the Owner role in-app → update OWNER_EMAIL in Railway → ensure the
successor has GitHub/Railway/Supabase access. As long as those account owners
persist, the app can't be stranded.
Set SLACK_WEBHOOK_URL to a Slack Incoming Webhook
and FormPing posts to your channel when a monitor finds something worth knowing:
- Change Monitor — changes detected (watch mode and one-off compare runs)
- Form Watch — a scheduled form run changes or breaks
- Site Watch — downtime, SSL/domain expiry thresholds
One webhook serves all three. Set it in ui/.env.local locally, or the Railway
service variables in production. Leave it unset to disable: runs still execute
and are stored — the Slack send is just skipped. Notifications are best-effort,
so a Slack outage or a bad webhook never breaks a monitor.
Alerts are INTERNAL-ONLY. They carry full technical detail (reason codes, full URLs, what changed) and go to your team — never to a client. A client's view is the status page, which is curated separately.
Every alert — from all three tools — goes through one dispatcher
(ui/src/lib/alerts/). Previously each tool POSTed to the webhook on its own,
which meant no rate limiting, no dedupe and no retry; three tickers firing at
once risked getting the webhook throttled or disabled. Now:
- Logged first. Each alert is written to the
alertstable before anything is sent. That row is the record of what was sent and how it went — it exists so a missing alert is diagnosable, not so you can browse it (there is no inbox UI; the detail already lives in the dashboards). - Deduped across restarts.
dedupe_keyis unique per occurrence, so a retry — or a watch that resumes after a redeploy — cannot ping you twice. - Sent safely. Sends are serialised and spaced ~1s apart per channel, honour
429/Retry-Afterwith exponential backoff, and a circuit breaker rests a channel after repeated failures. - Slack stays small on purpose — a headline, the URL, a few next steps, and
a link to the full detail in the app. The old behaviour trimmed a big
change report to a handful of lines and appended
+39 more, silently dropping the rest; now the message states the real total and links to the project dashboard, whose change timeline expands to show every change.
The Change Monitor's alert is raised by the UI (watchSpawner), not the CLI —
the engine is a pure producer that detects changes and reports them.
FormPing treats a result as belonging to the URL, not to the monitor that produced it. That gives three predictable rules, each surfaced to the user in a themed confirmation dialog before it happens:
| Action | What happens |
|---|---|
| Stop / delete a monitor (Form Watch or Site Watch) | Future runs stop and the monitor + its run log are removed. The last result stays on the project's URL. Use Pause to keep the monitor and resume later. |
| Edit a project → remove a URL | The URL leaves the project and drops to Unassigned. Its monitor keeps running and its results are not deleted — reassign or dismiss it there. |
| Delete a project | A complete delete: its monitors, run history, durable per-URL results, last manual Form Tester run, and per-host change reports. Irreversible. |
So only a project delete destroys results; everything else is recoverable or non-destructive.
Structured data lives in Supabase (above). Only two things stay on disk, because neither belongs in a relational table:
- Change Monitor snapshots — the raw HTML captured each check to diff against next time (large blobs; a move to object storage is a planned future task).
- activeWatches — the PIDs of running watch processes (machine-local runtime state, meaningless in a shared DB).
Both live under data/snapshots/…; by default <repo>/data/snapshots (on Railway
the mounted persistent volume, so leave FORMPING_DATA_DIR unset in production).
Set FORMPING_DATA_DIR (in ui/.env.local) to an absolute path to relocate
them. This matters for local dev when the repo lives inside a synced folder
like OneDrive/Dropbox, which re-syncs and reverts these frequently-written
files (snapshots are rewritten on every check). Point it at a non-synced folder:
FORMPING_DATA_DIR="C:/Users/<you>/AppData/Local/FormPing/data"
The default (unset) behaviour is unchanged.
AI is off by default. To enable, create a .env file in formping/ with one or more of these keys:
# Pick whichever provider you want — all four are optional
ANTHROPIC_API_KEY=sk-ant-... # Claude Haiku 4.5 — best quality, paid
GEMINI_API_KEY=... # Gemini 1.5 Flash — free tier, no card needed
GROQ_API_KEY=gsk_... # Llama 3.1 8B — free tier, very fast
# Ollama needs no key — install from ollama.com and run `ollama pull llama3.1:8b`Setup links:
- Anthropic: https://console.anthropic.com/settings/keys (paid, ~$0.30–$2/mo for typical use)
- Gemini: https://aistudio.google.com/app/apikey (free tier covers FormPing easily)
- Groq: https://console.groq.com/keys (free tier)
- Ollama: https://ollama.com (free, local-only — needs ~5GB disk)
When enabled, the UI shows a dropdown to pick which provider to use, with "Auto" selecting the first available in the priority order: Anthropic → Gemini → Groq → Ollama. CLI users can pass --ai-provider <id> or --ai-provider auto.
You can expose the local UI to the internet behind a password gate without buying a domain or server.
-
Add a basic-auth password in
ui/.env.local(this file is gitignored):BASIC_AUTH_USER=admin BASIC_AUTH_PASSWORD=YourLongRandomPasswordHereGenerate a strong password:
node -e "console.log(require('crypto').randomBytes(18).toString('base64url'))" -
Install
cloudflared:brew install cloudflared
Open two terminals:
# Terminal 1 — start the UI (also starts the password gate)
cd formping/ui
npm run dev
# Terminal 2 — start the tunnel and get a public URL
cloudflared tunnel --url http://localhost:3000The tunnel command prints something like:
| Your quick Tunnel has been created! Visit it at: |
| https://random-words-here.trycloudflare.com |
Copy that URL. Anyone visiting it sees the basic-auth login. Only people with the password get through.
- The
*.trycloudflare.comURL changes every time you restartcloudflared— bookmark whichever is current. To get a stable URL, register a domain at Cloudflare Registrar (~$10/yr) and use a named tunnel instead. - When your Mac sleeps or the dev server stops, the public URL goes down. For 24/7 uptime, deploy to a small VPS (Hetzner CX22 €4/mo is the best value).
- Watch out for port collisions: Next.js may pick port 3001 if 3000 is busy. Adjust the
cloudflared --urlaccordingly.
{
"inputUrl": "https://example.com",
"normalizedUrl": "https://example.com",
"mode": "safe",
"resolvedContactPage": "https://example.com/contact",
"contactPageFound": true,
"contactPageConfidence": 0.91,
"formFound": true,
"formConfidence": 0.88,
"formIdentifier": {
"id": "contact-form",
"name": null,
"action": "/submit",
"method": "post"
},
"submissionAttempted": false,
"submissionResult": "not_attempted",
"redirectUrl": null,
"finalUrl": "https://example.com/contact",
"thankYouDetected": false,
"inlineSuccessDetected": false,
"captchaDetected": false,
"antiBotDetected": false,
"finalStatus": "warn",
"reasonCode": "SAFE_MODE_NO_SUBMIT",
"notes": [],
"durationMs": 3421
}| Code | Meaning |
|---|---|
CONTACT_PAGE_NOT_FOUND |
No contact page candidate found |
CONTACT_PAGE_AMBIGUOUS |
Multiple candidates, low confidence |
FORM_NOT_FOUND |
No contact form on the contact page |
FORM_AMBIGUOUS |
Multiple forms, low confidence |
CAPTCHA_DETECTED |
CAPTCHA widget found — aborted |
ANTI_BOT_DETECTED |
Anti-bot/challenge page — aborted |
REQUIRED_FIELDS_UNSUPPORTED |
Could not fill required fields |
SAFE_MODE_NO_SUBMIT |
Safe mode — filled but not submitted |
DETECT_ONLY |
Detect-only mode — no interaction |
SUBMIT_FAILED |
Submit click failed |
VALIDATION_ERROR |
Form showed validation errors |
NO_REDIRECT_NO_SUCCESS |
Submitted but no success signal |
INLINE_SUCCESS_ONLY |
Inline success message detected |
THANK_YOU_REDIRECT |
Redirected to thank-you URL |
PASS |
Full success |
ERROR |
Unhandled exception |
| Status | Meaning |
|---|---|
pass |
Form submitted and success confirmed |
fail |
Something went wrong or blocked |
warn |
Partial — safe/detect-only mode, or ambiguous |
error |
Unhandled exception |
URL is matched against patterns: thank-you, thankyou, success, submitted, confirmation, sent, received
Page text is matched against: "thank you", "thanks for contacting", "message sent", "form submitted", "we'll be in touch", "submission received", "get back to you"
FormPing never attempts to bypass CAPTCHA or anti-bot systems.
- If a CAPTCHA widget is detected before form fill, it stops and returns
CAPTCHA_DETECTED - If an anti-bot challenge page is detected when loading the contact page, it stops and returns
ANTI_BOT_DETECTED - It does not retry through protected pages
AI is disabled by default and is an optional stub.
When enabled (--ai flag or aiEnabled: true in config), it is only called in two cases:
- Multiple contact page candidates score similarly — AI picks one
- Multiple forms on the contact page score similarly — AI picks one
The AI stub lives in src/ai/aiClassifier.ts. It returns null until you implement a provider. Prompts use only compact metadata (URLs, scores, field names) — never raw HTML.
Do not pass --ai. The default config has aiEnabled: false. The stub contains clear TODO comments showing exactly where to wire in Claude or OpenAI.
npm testTests cover:
- Contact link scoring and exclusion logic
- Success URL detection
- Inline success text detection
- Validation error detection
- CAPTCHA detection
- Anti-bot detection
- Post-submit analysis composition
Per-change location context in monitor diffs— done. Each diff card now shows a breadcrumb like🧭 main › About me › <p>.Wire AI toggles— done, and made provider-agnostic. Supports Anthropic Claude Haiku, Google Gemini Flash, Groq Llama, and local Ollama. Pick one in the UI dropdown or setAI_PROVIDER=autoto use the first configured provider.
(Add new ideas here.)
- Screenshot capture on failure
- HTML snapshot on failure
- Retry logic for flaky navigations
- Proxy/rotation support for large-scale authorized testing
- Configurable per-site overrides (contact page URL, field mapping)
- Output formats: CSV, JUnit XML for CI integration
- Slack/webhook notifications for batch results
- Visual diffs from monitor screenshots (pixel-by-pixel)
- Accessibility / Lighthouse score tracking in monitor
A separate mode that snapshots your site over time and reports meaningful changes — content, SEO, forms, tracking scripts, performance.
# 1. Take a baseline snapshot
npm run start -- --url https://yoursite.com --monitor snapshot
# 2. Later, compare current state vs the most recent snapshot
npm run start -- --url https://yoursite.com --monitor compare --json-pretty
# 3. Or run on a schedule until Ctrl+C
npm run start -- --url https://yoursite.com --monitor watch --watch-interval 3600000| Mode | What it does |
|---|---|
snapshot |
Crawls important pages, saves snapshot JSON, exits |
compare |
Takes a fresh snapshot, diffs it against the most recent stored snapshot, prints a change report, saves the new one |
watch |
Repeats compare on a fixed interval until SIGINT |
--monitor <mode> snapshot | compare | watch
--pages <n> max pages to crawl (default 10)
--screenshots capture full-page screenshots (uses Playwright)
--ai-summary use AI to summarize change reports (stub by default)
--watch-interval <ms> cycle interval for watch mode (default 3600000 = 1 hour)
--output <file> write JSON report to a file (in addition to stdout)
--json-pretty pretty-print JSON
For each page crawled:
{
"url": "https://example.com/contact",
"title": "Contact Us",
"metaDescription": "Get in touch...",
"metaRobots": "index,follow",
"canonical": "https://example.com/contact",
"h1": "Talk to us",
"textContentHash": "9a3b2c1d4e5f6789",
"textContentLength": 4231,
"formFields": [
{ "name": "email", "type": "email", "required": true, "label": "Email" },
{ "name": "message", "type": "textarea", "required": true, "label": "Message" }
],
"buttons": ["Send Message"],
"links": ["/about", "/pricing", "..."],
"scripts": ["https://www.googletagmanager.com/gtm.js?id=..."],
"loadTime": 842,
"screenshotPath": "data/snapshots/example.com/screenshots/2026-04-26T18-00-00-000Z/contact.png",
"timestamp": "2026-04-26T18:00:00.000Z",
"fetchedVia": "fetch"
}compare and watch produce a structured report:
{
"site": "example.com",
"rootUrl": "https://example.com/",
"checkedAt": "2026-04-27T18:00:00.000Z",
"previousSnapshot": "data/snapshots/example.com/2026-04-26T18-00-00-000Z.json",
"pagesScanned": 5,
"pagesChanged": 2,
"changesFound": 3,
"summary": "3 changes across 2 pages. 1 high-severity. Most notable: /contact — New required tel field added: \"phone\".",
"details": [
{
"url": "https://example.com/",
"changes": [
"H1 changed: \"Welcome\" → \"Get Started Today\"",
"New button: \"Get Started\""
],
"severity": "medium"
},
{
"url": "https://example.com/contact",
"changes": ["New required tel field added: \"phone\""],
"severity": "high"
}
]
}| Category | Examples |
|---|---|
| Content | H1 changed, major text-content delta, button/CTA text changed |
| SEO | Title, meta description, canonical, robots meta |
| Forms | Field added/removed, field becomes required, type changed, button text changed |
| Technical | New tracking scripts (GTM, GA, Meta Pixel), removed scripts, load-time spikes |
| Site-level | New page appears, page disappears |
| Severity | Meaning |
|---|---|
low |
Cosmetic — meta description, minor text edits, script noise, load-time |
medium |
Likely intentional — title/H1 changed, CTA text changed, page added |
high |
Material — form field added/removed, page disappeared, robots meta changed |
formping/data/snapshots/example.com/
2026-04-26T18-00-00-000Z.json
2026-04-27T18-00-00-000Z.json
screenshots/
2026-04-26T18-00-00-000Z/
home.png
contact.png
No database, no remote storage. Snapshots are plain JSON on disk so you can inspect, archive, or delete them with normal tooling.
The monitor is built to be cheap by default:
- Cheerio fetch is used for HTML parsing — Playwright launches only when
--screenshotsis enabled - Page count is capped via
--pages(default 10) - AI summarization is opt-in only (
--ai-summary) and the implementation is a stub until you wire in a provider - No diffing libraries — the diff engine is pure TS, ~150 LOC
--ai-summary enables a hook in src/monitor/summarizeChanges.ts where you can wire in Claude or OpenAI. Until then it logs a warning and falls back to the deterministic summary. Prompts should pass diff metadata only (URLs, severities, change strings) — never raw page HTML.
watch runs compare in a loop until SIGINT. The interval is set with --watch-interval (ms, default 1 hour). Each cycle:
- Snapshot current site
- Diff against previous snapshot
- Emit a JSON report to stdout
- Sleep until next cycle (interruptible by Ctrl+C)
Pipe stdout to a log, a webhook handler, or your own alerting glue.