[object Object]

← back to Animals

init: project animals scaffold (schema + viewer + audit + mockups + ad engine + lead funnel)

474af3c93d12b69bff2c2f4d29c94ab94e8cbc2a · 2026-05-01 11:33:17 -0700 · Steve

Files touched

Diff

commit 474af3c93d12b69bff2c2f4d29c94ab94e8cbc2a
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri May 1 11:33:17 2026 -0700

    init: project animals scaffold (schema + viewer + audit + mockups + ad engine + lead funnel)
---
 .env.example                        |   39 +
 .gitignore                          |   10 +
 PLAN.md                             |  162 +++
 README.md                           |   48 +
 docs/domain_research.json           |  325 ++++++
 docs/monetization.md                |  131 +++
 ecosystem.config.cjs                |   55 +
 migrations/001_initial_schema.sql   |  456 ++++++++
 package-lock.json                   | 2048 +++++++++++++++++++++++++++++++++++
 package.json                        |   41 +
 public/css/site.css                 |   84 ++
 public/mockups/biz-13-a.html        |   24 +
 public/mockups/biz-13-b.html        |   28 +
 public/mockups/biz-13-c.html        |   28 +
 public/mockups/biz-4-a.html         |   24 +
 public/mockups/biz-4-b.html         |   28 +
 public/mockups/biz-4-c.html         |   28 +
 public/mockups/biz-5-a.html         |   24 +
 public/mockups/biz-5-b.html         |   28 +
 public/mockups/biz-5-c.html         |   28 +
 public/mockups/biz-8-a.html         |   24 +
 public/mockups/biz-8-b.html         |   28 +
 public/mockups/biz-8-c.html         |   28 +
 src/audit/generate_mockups.js       |  174 +++
 src/audit/server.js                 |   30 +
 src/audit/site_audit.js             |  126 +++
 src/ingest/server.js                |   14 +
 src/lib/db.js                       |   33 +
 src/lib/log.js                      |   12 +
 src/lib/slug.js                     |    9 +
 src/monetize/ad_engine.js           |   48 +
 src/monetize/leads_server.js        |   18 +
 src/monetize/match_engine.js        |   86 ++
 src/scripts/hawk-local.sh           |   21 +
 src/scripts/migrate.js              |   56 +
 src/scripts/research_domains.js     |  111 ++
 src/scripts/seed_breeds.js          |  161 +++
 src/scripts/seed_demo_businesses.js |   97 ++
 src/scripts/seed_sources.js         |   67 ++
 src/server/index.js                 |  174 +++
 src/server/render.js                |  286 +++++
 41 files changed, 5242 insertions(+)

diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..73b832d
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,39 @@
+# Animals Directory + Vet Upgrade SaaS — env
+
+# Postgres
+DATABASE_URL=postgresql:///animals_directory?host=/tmp&user=stevestudio2
+PGHOST=/tmp
+PGUSER=stevestudio2
+PGDATABASE=animals_directory
+
+# Server
+NODE_ENV=development
+PORT=9720
+PUBLIC_BASE_URL=http://localhost:9720
+SESSION_SECRET=replace-me-with-openssl-rand-hex-32
+
+# Admin (bootstrap login for /admin)
+ADMIN_EMAIL=steve@designerwallcoverings.com
+ADMIN_PASSWORD=change-me
+
+# Stripe (upgrade orders)
+STRIPE_SECRET_KEY=
+STRIPE_PUBLIC_KEY=
+STRIPE_PRICE_ID_BASIC=
+STRIPE_PRICE_ID_PRO=
+
+# Optional ingestion APIs
+PETFINDER_KEY=
+PETFINDER_SECRET=
+GOOGLE_PLACES_KEY=
+
+# Ad provider (Google AdSense or in-house direct)
+ADSENSE_CLIENT_ID=
+AD_PROVIDER=inhouse  # 'inhouse' | 'adsense'
+
+# Outbound email (uses Steve's George gmail agent on tailnet)
+GEORGE_GMAIL_URL=http://localhost:9850
+GEORGE_GMAIL_AUTH=admin:DWSecure2024!
+
+# Domain research (uses domain-suite MCP via local helper)
+DOMAIN_SUITE_PROVIDER=godaddy
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..07a89cf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,10 @@
+node_modules
+.env
+.env.local
+logs/*.log
+public/screenshots/*.png
+public/mockups/*.png
+raw_html/
+tmp/
+*.bak
+.DS_Store
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..5f150e7
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,162 @@
+# Project: Animals — Master Plan
+
+**Goal.** Build the dominant consumer + B2B platform for the U.S. animal-care
+ecosystem. Mirrors the architecture of `lawyer-directory-builder` and
+`professional-directory` (doctors), but for vets, clinics, breeds, breeders,
+shelters, groomers, and trainers — with three monetization rails baked in
+from day one.
+
+Owner: Steve. Working dir: `~/Projects/animals/`. PG DB: `animals_directory`.
+Ports: 9720 (viewer), 9721 (ingest), 9722 (audit), 9723 (leads).
+
+---
+
+## 1. Why this works
+
+The exact pattern already proved itself twice:
+- `lawyer-directory-builder` — CalBar full-scrape, audits, lead funnel, EZ-Upgrade $499 site product.
+- `professional-directory` — NPI doctors, LA County focus, 5-agent crawler/enrich/dedupe.
+
+Both already have `site_audits → site_mockups → upgrade_orders` and `leads`
+tables. We mirror that schema exactly, swap "law firm" → "vet clinic", add the
+animal-specific entities (breeds, shelters, breeders, groomers, trainers), and
+flip on the same monetization rails.
+
+The animal market is *less crowded* than legal/medical for digital tooling:
+- ~32k accredited vet hospitals in the US (AAHA + non-AAHA ≈ 70k+ practices).
+- Most independent vet clinic websites are objectively bad (WordPress 2014,
+  no online booking, no schema.org, no mobile). A real upgrade is a slam-dunk pitch.
+- Pet owners search constantly: breed traits, "vet near me", emergency clinics,
+  groomers, breeder vetting, rescue/adoption.
+
+---
+
+## 2. Entity model (high level)
+
+| Entity        | Source(s)                                            | Volume target |
+| ------------- | ---------------------------------------------------- | ------------- |
+| breeds        | AKC + UKC + FCI + cat fancier assoc + exotic         | ~500          |
+| veterinarians | state license boards (CA, TX, FL, NY first)          | ~120k US      |
+| clinics       | AAHA accredited list + state corp registries + GMaps | ~70k US       |
+| shelters      | Petfinder + ASPCA + state SPCAs                      | ~14k US       |
+| breeders      | AKC parent club referrals + state ag registries      | ~60k US       |
+| groomers      | NDGAA + state directories                            | ~40k US       |
+| trainers      | CCPDT + APDT directories                             | ~20k US       |
+| pet_stores    | independent pet stores (PIDA + state biz registries + GMaps) + chains (Petco/Petsmart/Pet Supplies Plus locator pages) | ~22k US |
+| boarding      | IBPSA + state biz registries                         | ~9k US        |
+| dog_parks     | OSM + city parks-dept feeds                          | ~5k US        |
+| emergency_vets| AAHA accredited emergency category + VECCS           | ~1.5k US      |
+
+Each business entity has the same site_audit + mockup + upgrade_order pipeline.
+
+---
+
+## 3. Data sourcing rules (MUST follow Steve's standing rules)
+
+- **No stock images.** Public domain only — Wikimedia Commons, USDA, NIH/NLM,
+  AKC public photos, owner uploads. Empty image > stock image.
+- **Source-traceable.** Every fact stores `source_url` and a `raw_records` row.
+  No fabricated bios, no hallucinated phone numbers.
+- **Opt-out honored.** `opt_out_flag` on every business + professional. A single
+  email request removes them from public surfaces within 24h.
+- **State-board ToS.** We honor robots.txt and rate limits. CalBar-style scrape
+  patterns are reused (proven legal in lawyer project).
+
+---
+
+## 4. Monetization (three rails, all turned on day one)
+
+### Rail A — Display ads on every directory page
+- Every clinic page, vet page, breed page = ad-supported (AdSense first, then
+  direct contextual ads — pet food, pet insurance, Chewy/Petco affiliate, etc.).
+- Revenue model at scale: 70k clinics × 200 sessions/mo × $4 RPM = ~$56k/mo.
+- See `src/monetize/ad_engine.js` (in-house) + AdSense fallback.
+
+### Rail B — Lead-gen funnel ("Find a Vet" / "Find a Trainer")
+- Public form on every directory page → `leads` table → matches top 3 nearby
+  clinics by ZIP + species + service type → SMS/email blast to those clinics
+  via George (gmail agent on tailnet) and Twilio (later).
+- Pricing: $25/lead for vets, $15/lead for groomers/trainers, $50/lead for
+  breeders. 5x more sustainable than ads if conversion is real.
+
+### Rail C — White-label upgraded-website upsell ("Vet Site Upgrade")
+- For every clinic w/ site_audit score < 60: auto-generate 3 mockup variants
+  (Editorial Noir, Clean Modern Light, Bold Confident) via Playwright + a few
+  EJS templates. Email the office with screenshots.
+- Pricing: $499 setup + $49/mo hosting (Kamatera-deployed Express+EJS, on a
+  brandable subdomain we own — see Section 5).
+- ~70k clinics with bad sites × 1% conversion × $499 = $349k one-time + $34k MRR.
+  Even 0.1% conversion is $35k one-time + $3.4k MRR.
+
+---
+
+## 5. Domain strategy (use GoDaddy / domain-suite MCP)
+
+Two domain decisions:
+
+**A. The consumer-facing directory.** Working name: `animalsdirectory.com`,
+fallbacks `animalpages.com`, `petsdirectory.com`, `findavet.com`.
+
+**B. The B2B SaaS upsell brand.** This is the brand we sell clinics on.
+Candidates to check via domain-suite (`check_availability`):
+- vetsites.com, vetpages.com, clinicpages.com, pawpages.com, vethaus.com,
+  paws.site, vet.site, animalsite.com, kennelweb.com, clinicfront.com,
+  vetfront.com, vethq.io, healvet.com, vetlinked.com, vetbranded.com,
+  vetcrafted.com, hellopaws.com, paws.studio, bigpawvet.com, petsetgo.com,
+  pawloft.com, vetloft.com, paws.host, vetbloom.com, vetorigin.com.
+
+Pick 3 finalists by: (1) available, (2) ≤$2k registration, (3) brandable in
+under 5 syllables, (4) `.com` preferred (per Steve's default-TLD rule).
+
+`src/scripts/research_domains.js` runs this lookup + writes results to
+`docs/domain_research.json` + a markdown summary.
+
+---
+
+## 6. Build order (week 1)
+
+1. ✅ Scaffold project layout, package.json, ecosystem.config.js
+2. ✅ Postgres schema (migration `001_initial_schema.sql`)
+3. Seed AKC breed list (≈200 dogs) + cat breeds
+4. Seed sources table (state vet boards, AAHA, Petfinder)
+5. Wire Express viewer w/ `/`, `/breeds`, `/breeds/:slug`, `/vets`, `/clinic/:id`,
+   `/groomers`, `/pet-stores`, `/shelters`, `/breeders`, `/trainers`, `/dog-parks`,
+   plus city pages `/in/:city-:state` that show **all categories on one map**.
+   Every detail page embeds a **Leaflet + OpenStreetMap** map (no API key, free,
+   tile attribution per OSM ToS). City pages use a clustered marker layer.
+6. Site-audit job + mockup generator (Playwright)
+7. Lead form + email blast (via George)
+8. Domain research script + finalist picks
+9. claude-codex debate on PLAN.md + monetization.md → converge
+10. Launch `animals-hawk` watchdog (launchd, every 30m)
+
+---
+
+## 7. Watchdogs / ops
+
+- pm2: `animals-viewer`, `animals-ingest`, `animals-audit`, `animals-leads`
+- launchd: `~/Library/LaunchAgents/com.steve.animals-hawk.plist` pings
+  `localhost:9720/healthz` every 30m and restarts via pm2 on failure.
+- All long-running jobs under pm2 (per Steve's rule — no cloud agents).
+
+---
+
+## 8. What's explicitly NOT in v1
+
+- Booking/scheduling integration (vet calendars are a swamp; defer)
+- Telehealth (regulated; defer)
+- Mobile app (web-first; defer)
+- Pet insurance comparison (PII-heavy; defer)
+- Genetic-testing affiliate (defer to v2)
+
+---
+
+## 9. Risk register
+
+| Risk                                | Mitigation                                    |
+| ----------------------------------- | --------------------------------------------- |
+| State vet boards block scraping     | Same pattern that worked for CalBar; throttle |
+| Clinics complain about cold pitches | Opt-out flag + one-click removal              |
+| Stock-image rule violation          | Hard gate in audit job — never use Unsplash   |
+| Stripe fee on $499 = $14            | Acceptable; price covers it                   |
+| Trademark on "AKC" / "AAHA"         | Use only as factual references, never logos   |
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..19ebe01
--- /dev/null
+++ b/README.md
@@ -0,0 +1,48 @@
+# Project: Animals
+
+Compliance-first directory + monetization platform for the U.S. animal-care
+ecosystem (vets, clinics, breeds, breeders, shelters, groomers, trainers).
+
+Mirrors the proven architecture of `lawyer-directory-builder` and
+`professional-directory` — Postgres schema, Express viewer, pm2 workers,
+site-audit + mockup + upgrade-order rails, lead funnel, ad engine.
+
+See `PLAN.md` and `docs/monetization.md` for the full design.
+
+## Quick start
+
+```sh
+cp .env.example .env          # fill in DATABASE_URL etc.
+createdb animals_directory
+npm install
+npm run migrate
+npm run seed:sources
+npm run seed:breeds
+npm run server                # localhost:9720
+```
+
+## Layout
+
+```
+src/
+  server/      Express app + admin + public routes
+  scripts/     migrate, seed, stats, domain research
+  ingest/      state vet boards, AAHA, Petfinder, AKC
+  enrich/      geocode, website discovery, contacts
+  audit/       site_audit + mockup generator (Playwright)
+  monetize/    ad engine, lead matching, upgrade flow
+  lib/         pg pool, logger, utils
+  sources/     per-source scraper modules
+migrations/    SQL migrations
+public/        static assets, screenshots, mockups
+docs/          monetization.md, domain_research.json (generated)
+```
+
+## pm2
+
+```sh
+npm run pm2:start && pm2 save
+```
+
+Apps: `animals-viewer:9720`, `animals-ingest:9721`, `animals-audit:9722`,
+`animals-leads:9723`.
diff --git a/docs/domain_research.json b/docs/domain_research.json
new file mode 100644
index 0000000..c47d498
--- /dev/null
+++ b/docs/domain_research.json
@@ -0,0 +1,325 @@
+{
+  "checked_at": "2026-05-01T18:31:56.075Z",
+  "candidates": [
+    {
+      "domain": "animalsdirectory.com",
+      "available": true,
+      "price_usd": 12.99,
+      "premium": false,
+      "intended_use": "consumer_directory",
+      "notes": ""
+    },
+    {
+      "domain": "petsdirectory.com",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "consumer_directory",
+      "notes": ""
+    },
+    {
+      "domain": "animalpages.com",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "consumer_directory",
+      "notes": ""
+    },
+    {
+      "domain": "petpages.com",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "consumer_directory",
+      "notes": ""
+    },
+    {
+      "domain": "allthepets.com",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "consumer_directory",
+      "notes": ""
+    },
+    {
+      "domain": "paw.directory",
+      "available": null,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "consumer_directory",
+      "notes": ""
+    },
+    {
+      "domain": "pets.directory",
+      "available": null,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "consumer_directory",
+      "notes": ""
+    },
+    {
+      "domain": "findavet.com",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "consumer_directory",
+      "notes": ""
+    },
+    {
+      "domain": "animaldex.com",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "consumer_directory",
+      "notes": ""
+    },
+    {
+      "domain": "pawdex.com",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "consumer_directory",
+      "notes": ""
+    },
+    {
+      "domain": "petfind.com",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "consumer_directory",
+      "notes": ""
+    },
+    {
+      "domain": "pawfinder.com",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "consumer_directory",
+      "notes": ""
+    },
+    {
+      "domain": "vetsites.com",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "b2b_upsell",
+      "notes": ""
+    },
+    {
+      "domain": "vetpages.com",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "b2b_upsell",
+      "notes": ""
+    },
+    {
+      "domain": "clinicpages.com",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "b2b_upsell",
+      "notes": ""
+    },
+    {
+      "domain": "pawpages.com",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "b2b_upsell",
+      "notes": ""
+    },
+    {
+      "domain": "vethaus.com",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "b2b_upsell",
+      "notes": ""
+    },
+    {
+      "domain": "paws.site",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "b2b_upsell",
+      "notes": ""
+    },
+    {
+      "domain": "vet.site",
+      "available": null,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "b2b_upsell",
+      "notes": ""
+    },
+    {
+      "domain": "animalsite.com",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "b2b_upsell",
+      "notes": ""
+    },
+    {
+      "domain": "kennelweb.com",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "b2b_upsell",
+      "notes": ""
+    },
+    {
+      "domain": "clinicfront.com",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "b2b_upsell",
+      "notes": ""
+    },
+    {
+      "domain": "vetfront.com",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "b2b_upsell",
+      "notes": ""
+    },
+    {
+      "domain": "vethq.io",
+      "available": true,
+      "price_usd": 59.99,
+      "premium": true,
+      "intended_use": "b2b_upsell",
+      "notes": ""
+    },
+    {
+      "domain": "healvet.com",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "b2b_upsell",
+      "notes": ""
+    },
+    {
+      "domain": "vetlinked.com",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "b2b_upsell",
+      "notes": ""
+    },
+    {
+      "domain": "vetbranded.com",
+      "available": true,
+      "price_usd": 12.99,
+      "premium": false,
+      "intended_use": "b2b_upsell",
+      "notes": ""
+    },
+    {
+      "domain": "vetcrafted.com",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "b2b_upsell",
+      "notes": ""
+    },
+    {
+      "domain": "hellopaws.com",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "b2b_upsell",
+      "notes": ""
+    },
+    {
+      "domain": "paws.studio",
+      "available": null,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "b2b_upsell",
+      "notes": ""
+    },
+    {
+      "domain": "bigpawvet.com",
+      "available": true,
+      "price_usd": 12.99,
+      "premium": false,
+      "intended_use": "b2b_upsell",
+      "notes": ""
+    },
+    {
+      "domain": "petsetgo.com",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "b2b_upsell",
+      "notes": ""
+    },
+    {
+      "domain": "pawloft.com",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "b2b_upsell",
+      "notes": ""
+    },
+    {
+      "domain": "vetloft.com",
+      "available": true,
+      "price_usd": 12.99,
+      "premium": false,
+      "intended_use": "b2b_upsell",
+      "notes": ""
+    },
+    {
+      "domain": "paws.host",
+      "available": null,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "b2b_upsell",
+      "notes": ""
+    },
+    {
+      "domain": "vetbloom.com",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "b2b_upsell",
+      "notes": ""
+    },
+    {
+      "domain": "vetorigin.com",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "b2b_upsell",
+      "notes": ""
+    },
+    {
+      "domain": "mrvet.com",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "b2b_upsell",
+      "notes": ""
+    },
+    {
+      "domain": "vethero.com",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "b2b_upsell",
+      "notes": ""
+    },
+    {
+      "domain": "vethub.io",
+      "available": false,
+      "price_usd": null,
+      "premium": false,
+      "intended_use": "b2b_upsell",
+      "notes": ""
+    }
+  ]
+}
\ No newline at end of file
diff --git a/docs/monetization.md b/docs/monetization.md
new file mode 100644
index 0000000..a47f012
--- /dev/null
+++ b/docs/monetization.md
@@ -0,0 +1,131 @@
+# Monetization — Project: Animals
+
+Three independent revenue rails, ranked by build cost / unit economics.
+
+---
+
+## Rail A · Display ads (lowest friction, lowest CPM)
+
+**Where:** every public page — breed pages, clinic pages, vet pages, groomer/pet-store/
+shelter/breeder/trainer/boarding pages, breed-vs-breed, city directory pages, "best
+vets in {city}" SEO pages, and the city-map "everything near you" pages.
+
+**Slot inventory per page:**
+- Header banner (728×90 desktop / 320×50 mobile)
+- Mid-content native (in-flow card)
+- Sidebar sticky (300×600 desktop only)
+- Footer (728×90)
+
+**Implementation:**
+- Phase 1: Google AdSense (`AD_PROVIDER=adsense`, just inject the snippet).
+  Pros: zero sales effort. Cons: $1-3 RPM in this vertical.
+- Phase 2: Direct contextual ads (`AD_PROVIDER=inhouse`). Sell directly to:
+  - Pet food (Chewy affiliate, Farmer's Dog, The Honest Kitchen, Open Farm)
+  - Pet insurance (Lemonade, Trupanion, Pets Best, Healthy Paws, MetLife)
+  - Pet retailers (Chewy, Petco, BarkBox)
+  - Vet schools / continuing ed (low priority)
+- Each clinic page that has ≥1 ad-friendly trigger keyword (e.g., "puppy",
+  "vaccination", "senior dog") gets a contextually-matched native card.
+
+**Target unit economics at scale:**
+- 70k clinic pages + 500 breed pages + 1k city pages = ~71k indexable pages
+- 5 sessions/page/mo (modest SEO baseline) = 355k sessions/mo
+- $4 effective RPM = **~$1,420/mo** at first
+- Realistic 12-month target with SEO: 200 sessions/page = $5.7k/mo
+- Realistic 24-month target with SEO + brand: 500 sessions/page = $14k/mo
+
+**Schema:** `ad_slots`, `ad_impressions`, `ad_clicks` (rail A is mostly third-party
+served — we just track impressions for our own analytics + ad-fraud verification).
+
+---
+
+## Rail B · Lead-gen funnel (highest LTV per lead)
+
+**Where:** every clinic/vet/groomer/trainer/breeder page has a "Get matched
+with a {role} near you" CTA that opens a 4-field form (name, email, ZIP,
+species + symptom or service).
+
+**Routing:**
+- Form submit → `leads` row → `match_engine.js` picks top 3 businesses by:
+  - distance from ZIP (Haversine)
+  - species match (dog/cat/exotic/etc.)
+  - service match (general/emergency/dental/dermatology/etc.)
+  - `verified=true` priority (clinics that responded to a prior outreach)
+- George (gmail agent on tailnet) emails the matched business owner with a
+  1-click "Claim this lead" link.
+
+**Pricing:**
+| Vertical    | Per-lead |
+| ----------- | -------- |
+| Veterinarians (general)   | $25 |
+| Vets (specialty: derma/cards/onco) | $60 |
+| Emergency vets            | $80 |
+| Groomers                  | $15 |
+| Trainers                  | $25 |
+| Breeders (puppy buyers)   | $50 |
+| Boarding/daycare          | $20 |
+| Pet store (premium feature placement) | flat $39/mo |
+
+**Target:** 200 leads/mo @ avg $35 = **$7k/mo MRR** within 6 months.
+
+**Schema:** `leads`, `lead_matches`, `lead_charges` (Stripe usage-billed).
+
+---
+
+## Rail C · White-label "Vet Site Upgrade" SaaS
+
+**The pitch (auto-generated, sent to clinic email from site_audit):**
+
+> Hi [Clinic Name] team — we audited your website and noticed a few things
+> that may be costing you new patients (no mobile menu, no schema.org for
+> Google to understand your services, no online booking, slow page load).
+>
+> We rebuilt 3 mockups for you — see them at:
+> https://animalsdirectory.com/upgrade/preview/[token]
+>
+> $499 one-time, $49/mo. We host, maintain, update — you just send us your
+> photos and we ship in 7 days.
+
+**What they get:**
+- Modern responsive site (one of 3 templates — same as lawyer EZ-Upgrade)
+- Custom domain (theirs) or our subdomain `[clinic].vetpages.com`
+- Online appointment request form (POSTs to clinic email)
+- Schema.org `VeterinaryCare` markup (so Google ranks them for local-pack)
+- Photo gallery, services list, team bios
+- Hosted on Kamatera, deployed via push-to-deploy bare repo (per Steve pattern)
+
+**Funnel:**
+1. `audit:websites` cron computes `marketing_score` for every clinic with a
+   known website. Anything < 60 enters the upgrade pipeline.
+2. `audit:mockups` generates 3 PNG mockups via Playwright + EJS templates.
+3. Outbound email sent via George with a `/upgrade/preview/:token` link.
+4. Clinic clicks → sees mockups + price → submits `upgrade_order`.
+5. Admin gets Stripe checkout → onboards clinic → ships site in 7 days.
+
+**Target:** 70k clinics × 0.3% conversion = 210 customers
+- 210 × $499 setup = **$104,790 one-time** in year 1
+- 210 × $49/mo = **$10,290 MRR** by end of year 1
+
+**Schema:** `site_audits`, `site_mockups`, `upgrade_orders`, `app_users`
+(clinic owner login to claim profile + manage subscription).
+
+---
+
+## Combined target (12-month base case)
+
+| Rail | Year-1 revenue | Notes                              |
+| ---- | -------------- | ---------------------------------- |
+| A    | $30k           | Ad CPMs grow w/ SEO                |
+| B    | $70k           | 200 leads/mo at $35 avg            |
+| C    | $135k          | $105k setup + $30k from MRR ramp   |
+| **Total** | **~$235k**  | with one full-time human handling C delivery |
+
+---
+
+## Build sequence (revenue-first)
+
+1. Scaffold + ingest 5k clinic seed (CA + TX) — needed to demo any rail
+2. Build viewer w/ ad slots wired (AdSense first) — turns on Rail A immediately
+3. Build site_audit + mockup generator — turns on Rail C
+4. Build lead form + George outbound — turns on Rail B
+5. Hire 1 part-time site-builder when Rail C books >$5k/mo
diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs
new file mode 100644
index 0000000..e8a3a46
--- /dev/null
+++ b/ecosystem.config.cjs
@@ -0,0 +1,55 @@
+/**
+ * Project: Animals — pm2 master config.
+ *
+ *   pm2 start ecosystem.config.js && pm2 save
+ *
+ * Ports:
+ *   9720  animals-viewer    public Express app + admin
+ *   9721  animals-ingest    state vet board + AAHA + Petfinder workers (cron-driven HTTP)
+ *   9722  animals-audit     site audit + mockup generator (Playwright)
+ *   9723  animals-leads     lead-routing + outbound (George gmail)
+ */
+module.exports = {
+  apps: [
+    {
+      name: 'animals-viewer',
+      script: 'src/server/index.js',
+      cwd: __dirname,
+      env: { NODE_ENV: 'production', PORT: 9720 },
+      max_memory_restart: '1G',
+      autorestart: true,
+      out_file: 'logs/animals-viewer.out.log',
+      error_file: 'logs/animals-viewer.err.log',
+    },
+    {
+      name: 'animals-ingest',
+      script: 'src/ingest/server.js',
+      cwd: __dirname,
+      env: { NODE_ENV: 'production', PORT: 9721 },
+      max_memory_restart: '1G',
+      autorestart: true,
+      out_file: 'logs/animals-ingest.out.log',
+      error_file: 'logs/animals-ingest.err.log',
+    },
+    {
+      name: 'animals-audit',
+      script: 'src/audit/server.js',
+      cwd: __dirname,
+      env: { NODE_ENV: 'production', PORT: 9722 },
+      max_memory_restart: '2G',
+      autorestart: true,
+      out_file: 'logs/animals-audit.out.log',
+      error_file: 'logs/animals-audit.err.log',
+    },
+    {
+      name: 'animals-leads',
+      script: 'src/monetize/leads_server.js',
+      cwd: __dirname,
+      env: { NODE_ENV: 'production', PORT: 9723 },
+      max_memory_restart: '512M',
+      autorestart: true,
+      out_file: 'logs/animals-leads.out.log',
+      error_file: 'logs/animals-leads.err.log',
+    },
+  ],
+};
diff --git a/migrations/001_initial_schema.sql b/migrations/001_initial_schema.sql
new file mode 100644
index 0000000..fc59f3c
--- /dev/null
+++ b/migrations/001_initial_schema.sql
@@ -0,0 +1,456 @@
+-- Project: Animals — Initial schema
+-- Compliance-first: every fact traceable to source_url; opt_out_flag respected
+-- on every public surface. Mirrors the lawyer-directory + professional-directory
+-- pattern, but for the animal-care ecosystem.
+
+BEGIN;
+
+-- ─── helper: updated_at trigger fn ──────────────────────────────────────────
+CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS $$
+BEGIN
+  NEW.updated_at = NOW();
+  RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+-- ─── reference / dictionary ────────────────────────────────────────────────
+
+CREATE TABLE IF NOT EXISTS sources (
+  id              BIGSERIAL PRIMARY KEY,
+  source_name     TEXT NOT NULL UNIQUE,
+  source_type     TEXT NOT NULL CHECK (source_type IN (
+                    'state_vet_board','aaha','akc','ukc','fci',
+                    'petfinder','aspca','spca','ndgaa','ccpdt','apdt',
+                    'pida','ibpsa','veccs','osm','google_places','manual','other'
+                  )),
+  base_url        TEXT NOT NULL,
+  terms_notes     TEXT,
+  allowed_method  TEXT NOT NULL CHECK (allowed_method IN ('api','crawl','manual','denied')),
+  rate_limit_rps  NUMERIC(6,2),
+  robots_txt_url  TEXT,
+  last_checked_at TIMESTAMPTZ
+);
+
+CREATE TABLE IF NOT EXISTS species (
+  id    BIGSERIAL PRIMARY KEY,
+  name  TEXT NOT NULL UNIQUE     -- 'dog','cat','bird','reptile','small_mammal','fish','exotic','livestock'
+);
+
+CREATE TABLE IF NOT EXISTS service_categories (
+  id     BIGSERIAL PRIMARY KEY,
+  name   TEXT NOT NULL UNIQUE,   -- 'general_practice','emergency','dental','dermatology','cardiology','oncology','surgery','grooming','training','boarding','daycare','retail','adoption','breeding','vaccination','dental','radiology'
+  parent TEXT
+);
+
+-- ─── breeds (the consumer SEO engine) ──────────────────────────────────────
+
+CREATE TABLE IF NOT EXISTS breeds (
+  id                  BIGSERIAL PRIMARY KEY,
+  species_id          BIGINT NOT NULL REFERENCES species(id),
+  slug                TEXT NOT NULL UNIQUE,
+  common_name         TEXT NOT NULL,
+  alt_names           TEXT[],
+  origin_country      TEXT,
+  recognized_by       TEXT[],   -- ['AKC','UKC','FCI','TICA',...]
+  group_name          TEXT,     -- AKC group: 'Sporting','Hound','Working',etc.
+  size_class          TEXT,     -- 'toy','small','medium','large','giant'
+  weight_lbs_min      NUMERIC,
+  weight_lbs_max      NUMERIC,
+  height_in_min       NUMERIC,
+  height_in_max       NUMERIC,
+  life_span_years_min NUMERIC,
+  life_span_years_max NUMERIC,
+  coat_type           TEXT,
+  coat_colors         TEXT[],
+  shedding_level      INTEGER CHECK (shedding_level BETWEEN 1 AND 5),
+  energy_level        INTEGER CHECK (energy_level BETWEEN 1 AND 5),
+  trainability        INTEGER CHECK (trainability BETWEEN 1 AND 5),
+  good_with_kids      INTEGER CHECK (good_with_kids BETWEEN 1 AND 5),
+  good_with_pets      INTEGER CHECK (good_with_pets BETWEEN 1 AND 5),
+  hypoallergenic      BOOLEAN,
+  common_health_issues TEXT[],
+  description_md      TEXT,                -- editorial; never autogenerated lies
+  hero_image_url      TEXT,                -- public domain only (Wikimedia, etc.)
+  hero_image_credit   TEXT,
+  source_urls         TEXT[],
+  created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  updated_at          TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_breeds_species ON breeds(species_id);
+CREATE INDEX IF NOT EXISTS idx_breeds_size    ON breeds(size_class);
+
+CREATE TRIGGER trg_breeds_updated BEFORE UPDATE ON breeds
+  FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+
+-- ─── businesses (the unified table for all 9 categories) ───────────────────
+-- One row per real-world business location. The `category` column tells you
+-- whether it's a vet clinic, groomer, pet store, etc. Maps embed reads from
+-- (latitude, longitude). No PostGIS dependency in v1 — we use Haversine in JS.
+
+CREATE TABLE IF NOT EXISTS businesses (
+  id                BIGSERIAL PRIMARY KEY,
+  category          TEXT NOT NULL CHECK (category IN (
+                      'vet_clinic','emergency_vet','specialty_hospital',
+                      'groomer','pet_store','shelter','breeder',
+                      'trainer','boarding','daycare','dog_park','other'
+                    )),
+  name              TEXT NOT NULL,
+  slug              TEXT NOT NULL,
+  address           TEXT,
+  city              TEXT,
+  state             TEXT,
+  zip               TEXT,
+  county            TEXT,
+  country           TEXT DEFAULT 'US',
+  latitude          NUMERIC(10,7),
+  longitude         NUMERIC(10,7),
+  phone             TEXT,
+  email             TEXT,
+  website           TEXT,
+  google_place_id   TEXT UNIQUE,
+  rating            NUMERIC(3,1),
+  review_count      INTEGER,
+
+  -- vertical-specific
+  species_served    BIGINT[],          -- references species(id)
+  services_offered  BIGINT[],          -- references service_categories(id)
+  is_aaha_accredited BOOLEAN,
+  hours_json        JSONB,             -- {mon:[{open:'08:00',close:'18:00'}], ...}
+  online_booking_url TEXT,
+  appointment_email TEXT,
+
+  -- compliance
+  source_url        TEXT,
+  source_id         BIGINT REFERENCES sources(id) ON DELETE SET NULL,
+  opt_out_flag      BOOLEAN NOT NULL DEFAULT FALSE,
+  opt_out_reason    TEXT,
+  opt_out_at        TIMESTAMPTZ,
+  verified_at       TIMESTAMPTZ,       -- last time the owner confirmed the listing
+  claimed_by        BIGINT,            -- app_users(id) once they claim
+
+  created_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  updated_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+  UNIQUE (category, slug)
+);
+CREATE INDEX IF NOT EXISTS idx_biz_cat       ON businesses(category);
+CREATE INDEX IF NOT EXISTS idx_biz_zip       ON businesses(zip);
+CREATE INDEX IF NOT EXISTS idx_biz_city_st   ON businesses(LOWER(city), state);
+CREATE INDEX IF NOT EXISTS idx_biz_latlng    ON businesses(latitude, longitude);
+CREATE INDEX IF NOT EXISTS idx_biz_optout    ON businesses(opt_out_flag);
+CREATE INDEX IF NOT EXISTS idx_biz_website   ON businesses(LOWER(website));
+CREATE INDEX IF NOT EXISTS idx_biz_name_low  ON businesses(LOWER(name));
+CREATE TRIGGER trg_biz_updated BEFORE UPDATE ON businesses
+  FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+
+-- ─── veterinarians (people, separate from clinics) ─────────────────────────
+
+CREATE TABLE IF NOT EXISTS veterinarians (
+  id                  BIGSERIAL PRIMARY KEY,
+  full_name           TEXT NOT NULL,
+  first_name          TEXT,
+  last_name           TEXT,
+  middle_name         TEXT,
+  suffix              TEXT,
+  credentials         TEXT,            -- 'DVM','VMD','PhD'...
+  state_license_no    TEXT,
+  license_state       TEXT,
+  license_status      TEXT,
+  license_issued_date DATE,
+  vet_school          TEXT,
+  graduation_year     INTEGER,
+  primary_specialty   TEXT,
+  secondary_specialties TEXT[],
+  species_focus       BIGINT[],
+  bio_md              TEXT,
+  profile_image_url   TEXT,
+  source_url          TEXT,
+  source_id           BIGINT REFERENCES sources(id) ON DELETE SET NULL,
+  opt_out_flag        BOOLEAN NOT NULL DEFAULT FALSE,
+  opt_out_reason      TEXT,
+  opt_out_at          TIMESTAMPTZ,
+  confidence_score    NUMERIC(4,3) NOT NULL DEFAULT 0.000,
+  created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  updated_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  UNIQUE (license_state, state_license_no)
+);
+CREATE INDEX IF NOT EXISTS idx_vets_name_lower ON veterinarians(LOWER(last_name), LOWER(first_name));
+CREATE INDEX IF NOT EXISTS idx_vets_specialty  ON veterinarians(primary_specialty);
+CREATE INDEX IF NOT EXISTS idx_vets_optout     ON veterinarians(opt_out_flag);
+CREATE TRIGGER trg_vets_updated BEFORE UPDATE ON veterinarians
+  FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+
+CREATE TABLE IF NOT EXISTS vet_business_links (
+  veterinarian_id BIGINT NOT NULL REFERENCES veterinarians(id) ON DELETE CASCADE,
+  business_id     BIGINT NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
+  role            TEXT,                 -- 'owner','associate','medical_director'
+  is_primary      BOOLEAN NOT NULL DEFAULT FALSE,
+  source_url      TEXT,
+  PRIMARY KEY (veterinarian_id, business_id)
+);
+
+-- ─── breeders / shelters extras ────────────────────────────────────────────
+
+CREATE TABLE IF NOT EXISTS breeder_breeds (
+  business_id BIGINT NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
+  breed_id    BIGINT NOT NULL REFERENCES breeds(id) ON DELETE CASCADE,
+  PRIMARY KEY (business_id, breed_id)
+);
+
+CREATE TABLE IF NOT EXISTS shelter_animals (
+  id               BIGSERIAL PRIMARY KEY,
+  business_id      BIGINT NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
+  external_id      TEXT,                -- Petfinder ID etc.
+  name             TEXT,
+  species_id       BIGINT REFERENCES species(id),
+  breed_primary    TEXT,
+  breed_secondary  TEXT,
+  age_class        TEXT,                -- 'baby','young','adult','senior'
+  size_class       TEXT,
+  sex              TEXT,
+  spayed_neutered  BOOLEAN,
+  description      TEXT,
+  photo_urls       TEXT[],
+  status           TEXT,                -- 'available','adopted','foster','hold'
+  source_url       TEXT,
+  fetched_at       TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  UNIQUE (business_id, external_id)
+);
+CREATE INDEX IF NOT EXISTS idx_shelter_animals_biz ON shelter_animals(business_id);
+
+-- ─── audit / provenance ────────────────────────────────────────────────────
+
+CREATE TABLE IF NOT EXISTS raw_records (
+  id            BIGSERIAL PRIMARY KEY,
+  source_id     BIGINT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
+  source_url    TEXT NOT NULL,
+  entity_type   TEXT NOT NULL,
+  entity_id     TEXT,
+  raw_json      JSONB,
+  raw_html_path TEXT,
+  fetched_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  http_status   INTEGER,
+  hash          TEXT NOT NULL,
+  UNIQUE (source_id, hash)
+);
+CREATE INDEX IF NOT EXISTS idx_raw_source ON raw_records(source_id);
+CREATE INDEX IF NOT EXISTS idx_raw_entity ON raw_records(entity_type, entity_id);
+
+CREATE TABLE IF NOT EXISTS scrape_jobs (
+  id            BIGSERIAL PRIMARY KEY,
+  source_id     BIGINT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
+  job_label     TEXT,
+  status        TEXT NOT NULL DEFAULT 'queued'
+                CHECK (status IN ('queued','running','completed','failed','aborted_compliance')),
+  started_at    TIMESTAMPTZ,
+  finished_at   TIMESTAMPTZ,
+  records_seen  INTEGER NOT NULL DEFAULT 0,
+  records_kept  INTEGER NOT NULL DEFAULT 0,
+  notes         TEXT
+);
+
+-- ─── site_audits + mockups (Rail C upsell engine) ──────────────────────────
+
+CREATE TABLE IF NOT EXISTS site_audits (
+  id                BIGSERIAL PRIMARY KEY,
+  business_id       BIGINT NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
+  audited_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  url               TEXT NOT NULL,
+  final_url         TEXT,
+  status_code       INTEGER,
+  screenshot_path   TEXT,
+  primary_color     TEXT,
+  palette           JSONB,
+  has_https         BOOLEAN,
+  has_favicon       BOOLEAN,
+  has_og_image      BOOLEAN,
+  has_meta_viewport BOOLEAN,
+  has_analytics     BOOLEAN,
+  has_h1            BOOLEAN,
+  has_schema_org    BOOLEAN,
+  has_phone_visible BOOLEAN,
+  has_online_booking BOOLEAN,           -- vet-specific signal
+  page_size_bytes   INTEGER,
+  load_time_ms      INTEGER,
+  font_count        INTEGER,
+  image_count       INTEGER,
+  link_count        INTEGER,
+  marketing_score   INTEGER,
+  suggestions       TEXT[]
+);
+CREATE INDEX IF NOT EXISTS idx_audits_biz   ON site_audits(business_id);
+CREATE INDEX IF NOT EXISTS idx_audits_score ON site_audits(marketing_score DESC NULLS LAST);
+CREATE INDEX IF NOT EXISTS idx_audits_when  ON site_audits(audited_at DESC);
+
+CREATE TABLE IF NOT EXISTS site_mockups (
+  id              BIGSERIAL PRIMARY KEY,
+  business_id     BIGINT NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
+  variant         TEXT NOT NULL,
+  template_label  TEXT NOT NULL,
+  screenshot_path TEXT NOT NULL,
+  html_path       TEXT,
+  generated_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  UNIQUE (business_id, variant)
+);
+CREATE INDEX IF NOT EXISTS idx_mockups_biz ON site_mockups(business_id);
+
+-- ─── leads (Rail B funnel) ─────────────────────────────────────────────────
+
+CREATE TABLE IF NOT EXISTS leads (
+  id                 BIGSERIAL PRIMARY KEY,
+  full_name          TEXT NOT NULL,
+  email              TEXT NOT NULL,
+  phone              TEXT,
+  category_wanted    TEXT NOT NULL,    -- mirrors businesses.category
+  species_wanted     TEXT,             -- 'dog','cat',...
+  service_wanted     TEXT,             -- 'general','emergency','grooming',...
+  zip                TEXT,
+  city               TEXT,
+  state              TEXT,
+  description        TEXT,
+  budget             TEXT,
+  urgency            TEXT,             -- 'immediate','within_week','within_month','researching'
+  consent_to_contact BOOLEAN NOT NULL DEFAULT TRUE,
+  status             TEXT NOT NULL DEFAULT 'new'
+                     CHECK (status IN ('new','matched','contacted','won','lost','spam')),
+  matched_business_ids BIGINT[],
+  admin_notes        TEXT,
+  ip                 TEXT,
+  user_agent         TEXT,
+  source             TEXT,             -- 'public_form' | 'partner' | 'organic' | 'paid'
+  created_at         TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  updated_at         TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_leads_status   ON leads(status);
+CREATE INDEX IF NOT EXISTS idx_leads_cat      ON leads(category_wanted);
+CREATE INDEX IF NOT EXISTS idx_leads_zip      ON leads(zip);
+CREATE INDEX IF NOT EXISTS idx_leads_created  ON leads(created_at DESC);
+CREATE TRIGGER trg_leads_updated BEFORE UPDATE ON leads
+  FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+
+CREATE TABLE IF NOT EXISTS lead_charges (
+  id              BIGSERIAL PRIMARY KEY,
+  lead_id         BIGINT NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
+  business_id     BIGINT NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
+  amount_cents    INTEGER NOT NULL,
+  status          TEXT NOT NULL DEFAULT 'pending'
+                  CHECK (status IN ('pending','charged','refunded','failed')),
+  stripe_charge_id TEXT,
+  charged_at      TIMESTAMPTZ,
+  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- ─── app_users (clinic owner login) ────────────────────────────────────────
+
+CREATE TABLE IF NOT EXISTS app_users (
+  id              BIGSERIAL PRIMARY KEY,
+  email           TEXT NOT NULL UNIQUE,
+  password_hash   TEXT NOT NULL,
+  full_name       TEXT,
+  phone           TEXT,
+  role            TEXT NOT NULL DEFAULT 'business_owner'
+                  CHECK (role IN ('business_owner','admin','staff')),
+  email_verified  BOOLEAN NOT NULL DEFAULT FALSE,
+  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  last_login_at   TIMESTAMPTZ
+);
+CREATE TRIGGER trg_app_users_updated BEFORE UPDATE ON app_users
+  FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+
+-- ─── upgrade_orders (Rail C pay event) ─────────────────────────────────────
+
+CREATE TABLE IF NOT EXISTS upgrade_orders (
+  id              BIGSERIAL PRIMARY KEY,
+  app_user_id     BIGINT REFERENCES app_users(id) ON DELETE SET NULL,
+  business_id     BIGINT REFERENCES businesses(id) ON DELETE SET NULL,
+  full_name       TEXT NOT NULL,
+  email           TEXT NOT NULL,
+  phone           TEXT,
+  business_name   TEXT,
+  website         TEXT,
+  plan            TEXT NOT NULL DEFAULT 'starter_499',
+  amount_cents    INTEGER NOT NULL DEFAULT 49900,
+  status          TEXT NOT NULL DEFAULT 'pending_payment'
+                  CHECK (status IN ('pending_payment','paid','in_production','delivered','refunded','cancelled')),
+  payment_link    TEXT,
+  stripe_session_id TEXT,
+  paid_at         TIMESTAMPTZ,
+  delivered_at    TIMESTAMPTZ,
+  notes           TEXT,
+  ip              TEXT,
+  user_agent      TEXT,
+  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_upgrade_status  ON upgrade_orders(status);
+CREATE INDEX IF NOT EXISTS idx_upgrade_email   ON upgrade_orders(LOWER(email));
+CREATE INDEX IF NOT EXISTS idx_upgrade_created ON upgrade_orders(created_at DESC);
+CREATE TRIGGER trg_upgrade_updated BEFORE UPDATE ON upgrade_orders
+  FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+
+-- ─── ad engine (Rail A) ────────────────────────────────────────────────────
+
+CREATE TABLE IF NOT EXISTS ad_advertisers (
+  id          BIGSERIAL PRIMARY KEY,
+  name        TEXT NOT NULL,
+  category    TEXT,                    -- 'pet_food','insurance','retail','service'
+  contact_email TEXT,
+  active      BOOLEAN NOT NULL DEFAULT TRUE,
+  created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE TABLE IF NOT EXISTS ad_creatives (
+  id              BIGSERIAL PRIMARY KEY,
+  advertiser_id   BIGINT NOT NULL REFERENCES ad_advertisers(id) ON DELETE CASCADE,
+  slot_size       TEXT NOT NULL,       -- '728x90','300x250','300x600','native_card'
+  headline        TEXT,
+  body            TEXT,
+  cta_text        TEXT,
+  cta_url         TEXT NOT NULL,
+  image_url       TEXT,
+  target_keywords TEXT[],              -- contextual matching: 'puppy','vaccination','senior_dog',...
+  target_categories TEXT[],            -- match businesses.category
+  cpm_cents       INTEGER,             -- what we charge advertiser per 1000 impressions
+  active          BOOLEAN NOT NULL DEFAULT TRUE,
+  created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_creatives_active ON ad_creatives(active) WHERE active;
+
+CREATE TABLE IF NOT EXISTS ad_impressions (
+  id          BIGSERIAL PRIMARY KEY,
+  creative_id BIGINT REFERENCES ad_creatives(id) ON DELETE SET NULL,
+  page_path   TEXT,
+  ip          TEXT,
+  user_agent  TEXT,
+  ts          TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_imp_creative ON ad_impressions(creative_id);
+CREATE INDEX IF NOT EXISTS idx_imp_ts       ON ad_impressions(ts DESC);
+
+CREATE TABLE IF NOT EXISTS ad_clicks (
+  id          BIGSERIAL PRIMARY KEY,
+  creative_id BIGINT REFERENCES ad_creatives(id) ON DELETE SET NULL,
+  page_path   TEXT,
+  ip          TEXT,
+  user_agent  TEXT,
+  ts          TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_clk_creative ON ad_clicks(creative_id);
+
+-- ─── domain research (output of /scripts/research_domains.js) ──────────────
+
+CREATE TABLE IF NOT EXISTS domain_candidates (
+  id            BIGSERIAL PRIMARY KEY,
+  domain        TEXT NOT NULL UNIQUE,
+  available     BOOLEAN,
+  price_usd     NUMERIC(10,2),
+  premium       BOOLEAN,
+  registrar     TEXT,
+  brandable_score INTEGER,             -- 1-10 hand-rated
+  intended_use  TEXT,                  -- 'consumer_directory' | 'b2b_upsell'
+  notes         TEXT,
+  checked_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+COMMIT;
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..e63894d
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,2048 @@
+{
+  "name": "animals",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "animals",
+      "version": "0.1.0",
+      "license": "UNLICENSED",
+      "dependencies": {
+        "bcryptjs": "^3.0.3",
+        "cheerio": "^1.0.0",
+        "cookie-parser": "^1.4.7",
+        "csv-parse": "^6.2.1",
+        "csv-stringify": "^6.7.0",
+        "dotenv": "^16.4.7",
+        "ejs": "^3.1.10",
+        "express": "^4.21.2",
+        "pg": "^8.13.1",
+        "playwright": "^1.59.1",
+        "robots-parser": "^3.0.1",
+        "sharp": "^0.34.5",
+        "stripe": "^22.1.0",
+        "undici": "^7.2.0"
+      }
+    },
+    "node_modules/@emnapi/runtime": {
+      "version": "1.10.0",
+      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
+      "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@img/colour": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+      "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@img/sharp-darwin-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+      "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-darwin-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-darwin-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+      "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-darwin-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-libvips-darwin-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+      "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-darwin-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+      "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-arm": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+      "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+      "cpu": [
+        "arm"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+      "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-ppc64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+      "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-riscv64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+      "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-s390x": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+      "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+      "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+      "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+      "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-linux-arm": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+      "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+      "cpu": [
+        "arm"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-arm": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+      "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-ppc64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+      "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-ppc64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-riscv64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+      "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-riscv64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-s390x": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+      "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+      "cpu": [
+        "s390x"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-s390x": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+      "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linuxmusl-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+      "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linuxmusl-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+      "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-wasm32": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+      "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+      "cpu": [
+        "wasm32"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/runtime": "^1.7.0"
+      },
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+      "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-ia32": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+      "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+      "cpu": [
+        "ia32"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+      "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/accepts": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+      "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "~2.1.34",
+        "negotiator": "0.6.3"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/array-flatten": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+      "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+      "license": "MIT"
+    },
+    "node_modules/async": {
+      "version": "3.2.6",
+      "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
+      "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
+      "license": "MIT"
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "license": "MIT"
+    },
+    "node_modules/bcryptjs": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
+      "integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
+      "license": "BSD-3-Clause",
+      "bin": {
+        "bcrypt": "bin/bcrypt"
+      }
+    },
+    "node_modules/body-parser": {
+      "version": "1.20.5",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
+      "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "content-type": "~1.0.5",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "~1.2.0",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "on-finished": "~2.4.1",
+        "qs": "~6.15.1",
+        "raw-body": "~2.5.3",
+        "type-is": "~1.6.18",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/body-parser/node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/body-parser/node_modules/qs": {
+      "version": "6.15.1",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
+      "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/boolbase": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+      "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+      "license": "ISC"
+    },
+    "node_modules/brace-expansion": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
+      "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/cheerio": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
+      "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
+      "license": "MIT",
+      "dependencies": {
+        "cheerio-select": "^2.1.0",
+        "dom-serializer": "^2.0.0",
+        "domhandler": "^5.0.3",
+        "domutils": "^3.2.2",
+        "encoding-sniffer": "^0.2.1",
+        "htmlparser2": "^10.1.0",
+        "parse5": "^7.3.0",
+        "parse5-htmlparser2-tree-adapter": "^7.1.0",
+        "parse5-parser-stream": "^7.1.2",
+        "undici": "^7.19.0",
+        "whatwg-mimetype": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=20.18.1"
+      },
+      "funding": {
+        "url": "https://github.com/cheeriojs/cheerio?sponsor=1"
+      }
+    },
+    "node_modules/cheerio-select": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
+      "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "boolbase": "^1.0.0",
+        "css-select": "^5.1.0",
+        "css-what": "^6.1.0",
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.3",
+        "domutils": "^3.0.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/fb55"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "0.5.4",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+      "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "5.2.1"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-parser": {
+      "version": "1.4.7",
+      "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
+      "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
+      "license": "MIT",
+      "dependencies": {
+        "cookie": "0.7.2",
+        "cookie-signature": "1.0.6"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+      "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+      "license": "MIT"
+    },
+    "node_modules/css-select": {
+      "version": "5.2.2",
+      "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
+      "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "boolbase": "^1.0.0",
+        "css-what": "^6.1.0",
+        "domhandler": "^5.0.2",
+        "domutils": "^3.0.1",
+        "nth-check": "^2.0.1"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/fb55"
+      }
+    },
+    "node_modules/css-what": {
+      "version": "6.2.2",
+      "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
+      "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">= 6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/fb55"
+      }
+    },
+    "node_modules/csv-parse": {
+      "version": "6.2.1",
+      "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-6.2.1.tgz",
+      "integrity": "sha512-LRLMV+UCyfMokp8Wb411duBf1gaBKJfOfBWU9eHMJ+b+cJYZsNu3AFmjJf3+yPGd59Exz1TsMjaSFyxnYB9+IQ==",
+      "license": "MIT"
+    },
+    "node_modules/csv-stringify": {
+      "version": "6.7.0",
+      "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.7.0.tgz",
+      "integrity": "sha512-UdtziYp5HuTz7e5j8Nvq+a/3HQo+2/aJZ9xntNTpmRRIg/3YYqDVgiS9fvAhtNbnyfbv2ZBe0bqCHqzhE7FqWQ==",
+      "license": "MIT"
+    },
+    "node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/destroy": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+      "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/dom-serializer": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+      "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+      "license": "MIT",
+      "dependencies": {
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.2",
+        "entities": "^4.2.0"
+      },
+      "funding": {
+        "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+      }
+    },
+    "node_modules/domelementtype": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+      "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fb55"
+        }
+      ],
+      "license": "BSD-2-Clause"
+    },
+    "node_modules/domhandler": {
+      "version": "5.0.3",
+      "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+      "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "domelementtype": "^2.3.0"
+      },
+      "engines": {
+        "node": ">= 4"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/domhandler?sponsor=1"
+      }
+    },
+    "node_modules/domutils": {
+      "version": "3.2.2",
+      "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+      "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "dom-serializer": "^2.0.0",
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.3"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/domutils?sponsor=1"
+      }
+    },
+    "node_modules/dotenv": {
+      "version": "16.6.1",
+      "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+      "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://dotenvx.com"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/ejs": {
+      "version": "3.1.10",
+      "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
+      "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "jake": "^10.8.5"
+      },
+      "bin": {
+        "ejs": "bin/cli.js"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/encoding-sniffer": {
+      "version": "0.2.1",
+      "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
+      "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
+      "license": "MIT",
+      "dependencies": {
+        "iconv-lite": "^0.6.3",
+        "whatwg-encoding": "^3.1.1"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
+      }
+    },
+    "node_modules/entities": {
+      "version": "4.5.0",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+      "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/express": {
+      "version": "4.22.1",
+      "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
+      "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "~1.3.8",
+        "array-flatten": "1.1.1",
+        "body-parser": "~1.20.3",
+        "content-disposition": "~0.5.4",
+        "content-type": "~1.0.4",
+        "cookie": "~0.7.1",
+        "cookie-signature": "~1.0.6",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "finalhandler": "~1.3.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.0",
+        "merge-descriptors": "1.0.3",
+        "methods": "~1.1.2",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "path-to-regexp": "~0.1.12",
+        "proxy-addr": "~2.0.7",
+        "qs": "~6.14.0",
+        "range-parser": "~1.2.1",
+        "safe-buffer": "5.2.1",
+        "send": "~0.19.0",
+        "serve-static": "~1.16.2",
+        "setprototypeof": "1.2.0",
+        "statuses": "~2.0.1",
+        "type-is": "~1.6.18",
+        "utils-merge": "1.0.1",
+        "vary": "~1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/filelist": {
+      "version": "1.0.6",
+      "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
+      "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "minimatch": "^5.0.1"
+      }
+    },
+    "node_modules/finalhandler": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+      "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "statuses": "~2.0.2",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+      "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+      "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/htmlparser2": {
+      "version": "10.1.0",
+      "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+      "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+      "funding": [
+        "https://github.com/fb55/htmlparser2?sponsor=1",
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/fb55"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "domelementtype": "^2.3.0",
+        "domhandler": "^5.0.3",
+        "domutils": "^3.2.2",
+        "entities": "^7.0.1"
+      }
+    },
+    "node_modules/htmlparser2/node_modules/entities": {
+      "version": "7.0.1",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+      "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+      "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/jake": {
+      "version": "10.9.4",
+      "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz",
+      "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "async": "^3.2.6",
+        "filelist": "^1.0.4",
+        "picocolors": "^1.1.1"
+      },
+      "bin": {
+        "jake": "bin/cli.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+      "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/methods": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+      "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+      "license": "MIT",
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/minimatch": {
+      "version": "5.1.9",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
+      "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+      "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/nth-check": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+      "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+      "license": "BSD-2-Clause",
+      "dependencies": {
+        "boolbase": "^1.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/nth-check?sponsor=1"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/parse5": {
+      "version": "7.3.0",
+      "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+      "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+      "license": "MIT",
+      "dependencies": {
+        "entities": "^6.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/inikulin/parse5?sponsor=1"
+      }
+    },
+    "node_modules/parse5-htmlparser2-tree-adapter": {
+      "version": "7.1.0",
+      "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
+      "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
+      "license": "MIT",
+      "dependencies": {
+        "domhandler": "^5.0.3",
+        "parse5": "^7.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/inikulin/parse5?sponsor=1"
+      }
+    },
+    "node_modules/parse5-parser-stream": {
+      "version": "7.1.2",
+      "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
+      "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
+      "license": "MIT",
+      "dependencies": {
+        "parse5": "^7.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/inikulin/parse5?sponsor=1"
+      }
+    },
+    "node_modules/parse5/node_modules/entities": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+      "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "0.1.13",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+      "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+      "license": "MIT"
+    },
+    "node_modules/pg": {
+      "version": "8.20.0",
+      "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
+      "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-connection-string": "^2.12.0",
+        "pg-pool": "^3.13.0",
+        "pg-protocol": "^1.13.0",
+        "pg-types": "2.2.0",
+        "pgpass": "1.0.5"
+      },
+      "engines": {
+        "node": ">= 16.0.0"
+      },
+      "optionalDependencies": {
+        "pg-cloudflare": "^1.3.0"
+      },
+      "peerDependencies": {
+        "pg-native": ">=3.0.1"
+      },
+      "peerDependenciesMeta": {
+        "pg-native": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/pg-cloudflare": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
+      "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/pg-connection-string": {
+      "version": "2.12.0",
+      "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
+      "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
+      "license": "MIT"
+    },
+    "node_modules/pg-int8": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+      "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/pg-pool": {
+      "version": "3.13.0",
+      "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
+      "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
+      "license": "MIT",
+      "peerDependencies": {
+        "pg": ">=8.0"
+      }
+    },
+    "node_modules/pg-protocol": {
+      "version": "1.13.0",
+      "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
+      "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
+      "license": "MIT"
+    },
+    "node_modules/pg-types": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+      "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-int8": "1.0.1",
+        "postgres-array": "~2.0.0",
+        "postgres-bytea": "~1.0.0",
+        "postgres-date": "~1.0.4",
+        "postgres-interval": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/pgpass": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+      "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+      "license": "MIT",
+      "dependencies": {
+        "split2": "^4.1.0"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "license": "ISC"
+    },
+    "node_modules/playwright": {
+      "version": "1.59.1",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
+      "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright-core": "1.59.1"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "fsevents": "2.3.2"
+      }
+    },
+    "node_modules/playwright-core": {
+      "version": "1.59.1",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
+      "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
+      "license": "Apache-2.0",
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/postgres-array": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+      "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postgres-bytea": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+      "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-date": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+      "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-interval": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+      "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+      "license": "MIT",
+      "dependencies": {
+        "xtend": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.14.2",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
+      "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "2.5.3",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+      "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/raw-body/node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/robots-parser": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/robots-parser/-/robots-parser-3.0.1.tgz",
+      "integrity": "sha512-s+pyvQeIKIZ0dx5iJiQk1tPLJAWln39+MI5jtM8wnyws+G5azk+dMnMX0qfbqNetKKNgcWWOdi0sfm+FbQbgdQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.0.0"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/semver": {
+      "version": "7.7.4",
+      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
+      "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/send": {
+      "version": "0.19.2",
+      "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+      "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "1.2.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.1",
+        "mime": "1.6.0",
+        "ms": "2.1.3",
+        "on-finished": "~2.4.1",
+        "range-parser": "~1.2.1",
+        "statuses": "~2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/send/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/serve-static": {
+      "version": "1.16.3",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+      "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "parseurl": "~1.3.3",
+        "send": "~0.19.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/sharp": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
+      "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+      "hasInstallScript": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@img/colour": "^1.0.0",
+        "detect-libc": "^2.1.2",
+        "semver": "^7.7.3"
+      },
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-darwin-arm64": "0.34.5",
+        "@img/sharp-darwin-x64": "0.34.5",
+        "@img/sharp-libvips-darwin-arm64": "1.2.4",
+        "@img/sharp-libvips-darwin-x64": "1.2.4",
+        "@img/sharp-libvips-linux-arm": "1.2.4",
+        "@img/sharp-libvips-linux-arm64": "1.2.4",
+        "@img/sharp-libvips-linux-ppc64": "1.2.4",
+        "@img/sharp-libvips-linux-riscv64": "1.2.4",
+        "@img/sharp-libvips-linux-s390x": "1.2.4",
+        "@img/sharp-libvips-linux-x64": "1.2.4",
+        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+        "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+        "@img/sharp-linux-arm": "0.34.5",
+        "@img/sharp-linux-arm64": "0.34.5",
+        "@img/sharp-linux-ppc64": "0.34.5",
+        "@img/sharp-linux-riscv64": "0.34.5",
+        "@img/sharp-linux-s390x": "0.34.5",
+        "@img/sharp-linux-x64": "0.34.5",
+        "@img/sharp-linuxmusl-arm64": "0.34.5",
+        "@img/sharp-linuxmusl-x64": "0.34.5",
+        "@img/sharp-wasm32": "0.34.5",
+        "@img/sharp-win32-arm64": "0.34.5",
+        "@img/sharp-win32-ia32": "0.34.5",
+        "@img/sharp-win32-x64": "0.34.5"
+      }
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3",
+        "side-channel-list": "^1.0.0",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/split2": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+      "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">= 10.x"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/stripe": {
+      "version": "22.1.0",
+      "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.1.0.tgz",
+      "integrity": "sha512-w/xHyJGxXWnLPbNHG13sz/fae0MrFGC80Oz7YbICQymbfpqfEcsoG+6yG+9BWb81PWc4rrkeSO4wmTcmefmbLw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      },
+      "peerDependencies": {
+        "@types/node": ">=18"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "license": "0BSD",
+      "optional": true
+    },
+    "node_modules/type-is": {
+      "version": "1.6.18",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+      "license": "MIT",
+      "dependencies": {
+        "media-typer": "0.3.0",
+        "mime-types": "~2.1.24"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/undici": {
+      "version": "7.25.0",
+      "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
+      "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=20.18.1"
+      }
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/utils-merge": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+      "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/whatwg-encoding": {
+      "version": "3.1.1",
+      "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+      "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+      "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+      "license": "MIT",
+      "dependencies": {
+        "iconv-lite": "0.6.3"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/whatwg-mimetype": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+      "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/xtend": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..d4fe2ad
--- /dev/null
+++ b/package.json
@@ -0,0 +1,41 @@
+{
+  "name": "animals",
+  "version": "0.1.0",
+  "private": true,
+  "description": "Compliance-first directory + monetization platform for the entire animal ecosystem: vets, clinics, breeds, breeders, shelters, groomers, trainers. Powers the AnimalsDirectory consumer site, the Vet Site Upgrade SaaS upsell, and a lead-gen funnel.",
+  "license": "UNLICENSED",
+  "type": "module",
+  "scripts": {
+    "migrate": "node src/scripts/migrate.js",
+    "seed:breeds": "node src/scripts/seed_breeds.js",
+    "seed:sources": "node src/scripts/seed_sources.js",
+    "ingest:vets": "node src/ingest/state_vet_boards.js",
+    "ingest:clinics:aaha": "node src/ingest/aaha_accredited.js",
+    "ingest:shelters": "node src/ingest/petfinder_shelters.js",
+    "audit:websites": "node src/audit/site_audit.js",
+    "audit:mockups": "node src/audit/generate_mockups.js",
+    "domains:research": "node src/scripts/research_domains.js",
+    "stats": "node src/scripts/stats.js",
+    "server": "node src/server/index.js",
+    "dev": "node --watch src/server/index.js",
+    "pm2:start": "pm2 start ecosystem.config.js",
+    "pm2:stop": "pm2 stop ecosystem.config.js",
+    "pm2:logs": "pm2 logs animals-viewer"
+  },
+  "dependencies": {
+    "bcryptjs": "^3.0.3",
+    "cheerio": "^1.0.0",
+    "cookie-parser": "^1.4.7",
+    "csv-parse": "^6.2.1",
+    "csv-stringify": "^6.7.0",
+    "dotenv": "^16.4.7",
+    "ejs": "^3.1.10",
+    "express": "^4.21.2",
+    "pg": "^8.13.1",
+    "playwright": "^1.59.1",
+    "robots-parser": "^3.0.1",
+    "sharp": "^0.34.5",
+    "stripe": "^22.1.0",
+    "undici": "^7.2.0"
+  }
+}
diff --git a/public/css/site.css b/public/css/site.css
new file mode 100644
index 0000000..0416879
--- /dev/null
+++ b/public/css/site.css
@@ -0,0 +1,84 @@
+:root{--accent:#0f4d3a;--accent2:#c97a3e;--bg:#fafaf7;--bg2:#fff;--ink:#1a1a1a;--muted:#6b6b6b;--rule:#e6e1d6}
+*{box-sizing:border-box}
+html,body{margin:0;padding:0;background:var(--bg);color:var(--ink);font:16px/1.55 -apple-system,BlinkMacSystemFont,'Inter',Segoe UI,sans-serif}
+a{color:var(--accent);text-decoration:none}
+a:hover{text-decoration:underline}
+img{max-width:100%;display:block}
+
+.site-header{display:flex;align-items:center;gap:1.5em;padding:1em 2em;border-bottom:1px solid var(--rule);background:var(--bg2);position:sticky;top:0;z-index:50}
+.site-header .brand{font-weight:700;font-size:1.15em;color:var(--ink)}
+.brand-mark{color:var(--accent);font-size:1.2em;margin-right:.25em}
+.site-header nav{display:flex;gap:1.25em;flex:1}
+.site-header nav a{color:var(--ink);font-weight:500}
+.site-header .cta{background:var(--accent);color:#fff;padding:.55em 1.1em;border-radius:6px;font-weight:600}
+.site-header .cta:hover{text-decoration:none;background:#0a3a2c}
+
+.site-footer{margin-top:4em;padding:2em;border-top:1px solid var(--rule);text-align:center;color:var(--muted)}
+.site-footer a{color:var(--muted)}
+
+.container{max-width:1100px;margin:0 auto;padding:1.5em 2em}
+.muted{color:var(--muted)}
+
+.hero{background:linear-gradient(160deg,#e8efea,#fff);padding:4em 2em;text-align:center;border-bottom:1px solid var(--rule)}
+.hero h1{font-size:2.4em;margin:.2em 0 .5em;letter-spacing:-.01em}
+.hero p{max-width:700px;margin:0 auto;color:#444;font-size:1.1em}
+.cta-big{display:inline-block;margin-top:1.5em;background:var(--accent);color:#fff;padding:1em 1.6em;border-radius:8px;font-weight:600;font-size:1.05em}
+.cta-big:hover{text-decoration:none;background:#0a3a2c}
+
+.cat-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:.75em;padding:1.5em 2em;max-width:1100px;margin:0 auto}
+.cat-card{display:block;background:var(--bg2);border:1px solid var(--rule);border-radius:10px;padding:1em;text-align:center;color:var(--ink);text-transform:capitalize}
+.cat-card:hover{border-color:var(--accent);text-decoration:none}
+.cat-card strong{display:block;margin-bottom:.25em}
+.cat-card span{color:var(--muted);font-size:.85em}
+
+.breed-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:1em;padding:1em 2em;max-width:1100px;margin:0 auto}
+.breed-card{background:var(--bg2);border:1px solid var(--rule);border-radius:10px;overflow:hidden;color:var(--ink)}
+.breed-card:hover{border-color:var(--accent);text-decoration:none;box-shadow:0 4px 14px rgba(15,77,58,.07)}
+.breed-img{height:140px;background-size:cover;background-position:center;background-color:#e6efe9}
+.breed-card strong{display:block;padding:.7em 1em 0}
+.breed-card small{display:block;color:var(--muted);padding:0 1em .8em}
+
+.breed-header{display:grid;grid-template-columns:240px 1fr;gap:2em;align-items:center;margin:1em 0 2em}
+.breed-img-large{width:240px;height:240px;background-size:cover;background-position:center;background-color:#e6efe9;border-radius:12px}
+.badges span{display:inline-block;background:#e6efe9;color:var(--accent);padding:.2em .6em;border-radius:4px;margin-right:.4em;font-size:.8em}
+.trait-list{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:.5em 1.5em;background:var(--bg2);border:1px solid var(--rule);border-radius:10px;padding:1.25em 1.5em}
+.trait-list dt{color:var(--muted);font-size:.85em}
+.trait-list dd{margin:0 0 .5em;font-weight:600}
+.prose{max-width:740px;margin-top:2em;line-height:1.75}
+
+.business-list{list-style:none;padding:0}
+.business-list li{padding:.85em 0;border-bottom:1px solid var(--rule);display:flex;flex-wrap:wrap;gap:.75em;align-items:baseline}
+.business-list li small{color:var(--muted)}
+.business-list li .ext{margin-left:auto;font-size:.85em}
+
+.biz-grid{display:grid;grid-template-columns:1fr 320px;gap:2em;margin:1em 0 2em}
+@media (max-width:780px){.biz-grid{grid-template-columns:1fr}.breed-header{grid-template-columns:1fr}.breed-img-large{width:100%;height:200px}}
+
+.audit{background:var(--bg2);border:1px solid var(--rule);border-radius:10px;padding:1em 1.25em;margin-bottom:1em}
+.audit h3{margin:.1em 0 .5em}
+.cta-small{display:inline-block;background:var(--accent2);color:#fff;padding:.5em .9em;border-radius:6px;font-weight:600;font-size:.9em}
+.cta-small:hover{text-decoration:none;background:#a85e2c}
+
+.map{border-radius:10px;overflow:hidden;border:1px solid var(--rule);margin:1em 0}
+
+.lead-form{background:#fff;border:1px solid var(--rule);border-radius:12px;padding:2em;max-width:600px;margin:3em auto}
+.lead-form h2{margin-top:0}
+.lead-form label{display:block;margin:.7em 0;font-weight:500}
+.lead-form input,.lead-form select,.lead-form textarea{width:100%;padding:.55em .7em;border:1px solid var(--rule);border-radius:6px;font:inherit;background:#fff}
+.lead-form button{background:var(--accent);color:#fff;padding:.85em 1.5em;border:0;border-radius:8px;font-weight:600;font-size:1em;cursor:pointer;margin-top:1em}
+.lead-form button:hover{background:#0a3a2c}
+.lead-form p.ok{background:#e6efe9;color:var(--accent);padding:1em;border-radius:8px}
+
+.filters{display:flex;gap:.75em;margin:1em 0 2em;flex-wrap:wrap}
+.filters select,.filters button{padding:.55em .8em;border:1px solid var(--rule);border-radius:6px;background:#fff;font:inherit}
+
+.ad-slot{background:var(--bg2);border:1px dashed var(--rule);border-radius:8px;padding:1em;margin:1.5em 0;position:relative}
+.ad-slot a{color:var(--ink)}
+.ad-slot .ad-disclosure{position:absolute;top:.4em;right:.6em;font-size:.7em;color:var(--muted);text-transform:uppercase;letter-spacing:.05em}
+.ad-slot .ad-cta{display:inline-block;margin-top:.4em;color:var(--accent);font-weight:600}
+.ad-slot img{max-height:80px;float:left;margin-right:1em}
+.ad-house{background:#fffaf3;border-style:solid;border-color:var(--accent2)}
+
+table{width:100%;border-collapse:collapse;margin:1em 0;background:var(--bg2);border:1px solid var(--rule);border-radius:8px;overflow:hidden}
+th,td{padding:.6em .9em;border-bottom:1px solid var(--rule);text-align:left;font-size:.9em}
+th{background:#f3efe6;font-weight:600}
diff --git a/public/mockups/biz-13-a.html b/public/mockups/biz-13-a.html
new file mode 100644
index 0000000..7b32089
--- /dev/null
+++ b/public/mockups/biz-13-a.html
@@ -0,0 +1,24 @@
+<!doctype html><html><head><meta charset=utf-8>
+<style>
+body{font-family:Georgia,'Times New Roman',serif;background:#f8f5ef;color:#1a1a1a;margin:0}
+.hero{padding:6em 4em 5em;background:linear-gradient(165deg,#f0e8d8,#fff);border-bottom:1px solid #e0d8c8}
+.hero h1{font-size:3em;margin:0 0 .3em;letter-spacing:-.02em;font-weight:400}
+.hero p{font-size:1.2em;color:#555;max-width:600px}
+.cta{display:inline-block;background:#3a4a3a;color:#fff;padding:1em 2em;border-radius:0;margin-top:1.5em;text-decoration:none;font-family:Helvetica,Arial,sans-serif;letter-spacing:.05em;text-transform:uppercase;font-size:.9em}
+.row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:3em;padding:4em}
+.row h3{font-weight:400;font-size:1.3em;border-bottom:1px solid #c8b89c;padding-bottom:.5em}
+.foot{padding:2em 4em;background:#3a4a3a;color:#e8dfd2;font-family:Helvetica,Arial,sans-serif}
+</style></head><body>
+<section class=hero>
+  <small style="text-transform:uppercase;letter-spacing:.1em;color:#a08850">groomer</small>
+  <h1>The Spot Experience</h1>
+  <p>Compassionate care, modern medicine. Caring for  New York family pets for over a decade.</p>
+  <a class=cta href="#book">Book an appointment</a>
+</section>
+<section class=row>
+  <div><h3>Our services</h3><p>Wellness exams, vaccinations, dental care, surgery, and senior pet care.</p></div>
+  <div><h3>Our team</h3><p>Board-certified veterinarians and Fear Free Certified® technicians.</p></div>
+  <div><h3>Visit us</h3><p>230 W 41st St, New York, NY, 10036<br>(212) 730-6080</p></div>
+</section>
+<footer class=foot>The Spot Experience · 230 W 41st St, New York, NY, 10036 · (212) 730-6080</footer>
+</body></html>
\ No newline at end of file
diff --git a/public/mockups/biz-13-b.html b/public/mockups/biz-13-b.html
new file mode 100644
index 0000000..17695b1
--- /dev/null
+++ b/public/mockups/biz-13-b.html
@@ -0,0 +1,28 @@
+<!doctype html><html><head><meta charset=utf-8>
+<style>
+body{font-family:-apple-system,BlinkMacSystemFont,'Inter',sans-serif;background:#fff;color:#0f172a;margin:0}
+.nav{display:flex;align-items:center;padding:1.2em 2em;border-bottom:1px solid #e5e7eb}
+.nav strong{font-size:1.15em}.nav a{margin-left:1.5em;color:#475569;text-decoration:none}
+.hero{padding:5em 2em 4em;text-align:center;background:linear-gradient(180deg,#f0f9ff,#fff)}
+.hero h1{font-size:3.5em;margin:0;font-weight:700;letter-spacing:-.03em;color:#0f172a}
+.hero p{color:#64748b;font-size:1.25em;max-width:560px;margin:1em auto 2em}
+.cta{display:inline-block;background:#0ea5e9;color:#fff;padding:1em 2.2em;border-radius:10px;text-decoration:none;font-weight:600;box-shadow:0 4px 14px rgba(14,165,233,.3)}
+.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:1.25em;padding:3em 2em;max-width:1100px;margin:0 auto}
+.card{background:#fff;border:1px solid #e5e7eb;border-radius:14px;padding:1.5em}
+.card h3{margin:0 0 .4em;font-size:1.1em}
+.card p{color:#64748b;margin:0;font-size:.95em}
+.foot{padding:2em;background:#0f172a;color:#94a3b8;text-align:center}
+</style></head><body>
+<nav class=nav><strong>The Spot Experience</strong><a href="#">Services</a><a href="#">Team</a><a href="#">Visit</a><a href="#">Book</a></nav>
+<section class=hero>
+  <h1>Modern care for modern pets.</h1>
+  <p>Online booking. Email reminders. Honest pricing. The vet experience your pet (and you) deserve.</p>
+  <a class=cta href="#book">Book online →</a>
+</section>
+<section class=cards>
+  <div class=card><h3>Wellness</h3><p>Annual exams, vaccinations, and preventive care plans.</p></div>
+  <div class=card><h3>Surgery & Dental</h3><p>In-house procedures with same-day discharge for most.</p></div>
+  <div class=card><h3>Urgent Care</h3><p>Same-day sick visits — call us before going to the ER.</p></div>
+</section>
+<footer class=foot>The Spot Experience · 230 W 41st St, New York, NY, 10036 · (212) 730-6080</footer>
+</body></html>
\ No newline at end of file
diff --git a/public/mockups/biz-13-c.html b/public/mockups/biz-13-c.html
new file mode 100644
index 0000000..9ffc52a
--- /dev/null
+++ b/public/mockups/biz-13-c.html
@@ -0,0 +1,28 @@
+<!doctype html><html><head><meta charset=utf-8>
+<style>
+body{font-family:'Helvetica Neue',Arial,sans-serif;background:#0f4d3a;color:#fff;margin:0}
+.hero{padding:5em 3em;background:linear-gradient(135deg,#0f4d3a,#0a3a2c)}
+.hero small{text-transform:uppercase;letter-spacing:.15em;color:#c97a3e;font-weight:700;font-size:.9em}
+.hero h1{font-size:4em;margin:.2em 0 .2em;font-weight:900;letter-spacing:-.02em;line-height:1}
+.hero p{font-size:1.3em;color:#d6e8df;max-width:600px;margin:1em 0 2em}
+.cta{display:inline-block;background:#c97a3e;color:#fff;padding:1.1em 2.4em;border-radius:8px;text-decoration:none;font-weight:700;font-size:1.05em;text-transform:uppercase;letter-spacing:.05em}
+.three{display:grid;grid-template-columns:repeat(3,1fr);gap:0;padding:0;background:#fff;color:#0f172a}
+.three > div{padding:3em 2.5em;border-right:1px solid #e5e7eb}
+.three h3{margin:0 0 .5em;font-size:1.4em}
+.three p{margin:0;color:#475569}
+.cta2{display:inline-block;margin-top:1em;color:#0f4d3a;font-weight:700;text-decoration:underline}
+.foot{padding:2em 3em;background:#0a3a2c;color:#9bbcae}
+</style></head><body>
+<section class=hero>
+  <small>groomer ·  New York</small>
+  <h1>The Spot Experience</h1>
+  <p>Real medicine. Real people. Real results — for over 1,000 families and counting.</p>
+  <a class=cta href="#book">Book your visit →</a>
+</section>
+<section class=three>
+  <div><h3>Same-day sick visits</h3><p>Don't wait a week to be seen. Call us before 10am, see us before 5pm.</p><a class=cta2 href="#">Call (212) 730-6080 →</a></div>
+  <div><h3>Specialty referrals</h3><p>Dermatology, cardiology, oncology — we know who to send you to.</p><a class=cta2 href="#">See our network →</a></div>
+  <div><h3>Senior pet program</h3><p>A dedicated quarterly visit + lab panel for pets 8+.</p><a class=cta2 href="#">Learn more →</a></div>
+</section>
+<footer class=foot>The Spot Experience · 230 W 41st St, New York, NY, 10036 · (212) 730-6080</footer>
+</body></html>
\ No newline at end of file
diff --git a/public/mockups/biz-4-a.html b/public/mockups/biz-4-a.html
new file mode 100644
index 0000000..bf18500
--- /dev/null
+++ b/public/mockups/biz-4-a.html
@@ -0,0 +1,24 @@
+<!doctype html><html><head><meta charset=utf-8>
+<style>
+body{font-family:Georgia,'Times New Roman',serif;background:#f8f5ef;color:#1a1a1a;margin:0}
+.hero{padding:6em 4em 5em;background:linear-gradient(165deg,#f0e8d8,#fff);border-bottom:1px solid #e0d8c8}
+.hero h1{font-size:3em;margin:0 0 .3em;letter-spacing:-.02em;font-weight:400}
+.hero p{font-size:1.2em;color:#555;max-width:600px}
+.cta{display:inline-block;background:#3a4a3a;color:#fff;padding:1em 2em;border-radius:0;margin-top:1.5em;text-decoration:none;font-family:Helvetica,Arial,sans-serif;letter-spacing:.05em;text-transform:uppercase;font-size:.9em}
+.row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:3em;padding:4em}
+.row h3{font-weight:400;font-size:1.3em;border-bottom:1px solid #c8b89c;padding-bottom:.5em}
+.foot{padding:2em 4em;background:#3a4a3a;color:#e8dfd2;font-family:Helvetica,Arial,sans-serif}
+</style></head><body>
+<section class=hero>
+  <small style="text-transform:uppercase;letter-spacing:.1em;color:#a08850">groomer</small>
+  <h1>Healthy Spot — West Hollywood</h1>
+  <p>Compassionate care, modern medicine. Caring for  West Hollywood family pets for over a decade.</p>
+  <a class=cta href="#book">Book an appointment</a>
+</section>
+<section class=row>
+  <div><h3>Our services</h3><p>Wellness exams, vaccinations, dental care, surgery, and senior pet care.</p></div>
+  <div><h3>Our team</h3><p>Board-certified veterinarians and Fear Free Certified® technicians.</p></div>
+  <div><h3>Visit us</h3><p>7986 Santa Monica Blvd, West Hollywood, CA, 90046<br>(323) 656-1110</p></div>
+</section>
+<footer class=foot>Healthy Spot — West Hollywood · 7986 Santa Monica Blvd, West Hollywood, CA, 90046 · (323) 656-1110</footer>
+</body></html>
\ No newline at end of file
diff --git a/public/mockups/biz-4-b.html b/public/mockups/biz-4-b.html
new file mode 100644
index 0000000..5ee10ac
--- /dev/null
+++ b/public/mockups/biz-4-b.html
@@ -0,0 +1,28 @@
+<!doctype html><html><head><meta charset=utf-8>
+<style>
+body{font-family:-apple-system,BlinkMacSystemFont,'Inter',sans-serif;background:#fff;color:#0f172a;margin:0}
+.nav{display:flex;align-items:center;padding:1.2em 2em;border-bottom:1px solid #e5e7eb}
+.nav strong{font-size:1.15em}.nav a{margin-left:1.5em;color:#475569;text-decoration:none}
+.hero{padding:5em 2em 4em;text-align:center;background:linear-gradient(180deg,#f0f9ff,#fff)}
+.hero h1{font-size:3.5em;margin:0;font-weight:700;letter-spacing:-.03em;color:#0f172a}
+.hero p{color:#64748b;font-size:1.25em;max-width:560px;margin:1em auto 2em}
+.cta{display:inline-block;background:#0ea5e9;color:#fff;padding:1em 2.2em;border-radius:10px;text-decoration:none;font-weight:600;box-shadow:0 4px 14px rgba(14,165,233,.3)}
+.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:1.25em;padding:3em 2em;max-width:1100px;margin:0 auto}
+.card{background:#fff;border:1px solid #e5e7eb;border-radius:14px;padding:1.5em}
+.card h3{margin:0 0 .4em;font-size:1.1em}
+.card p{color:#64748b;margin:0;font-size:.95em}
+.foot{padding:2em;background:#0f172a;color:#94a3b8;text-align:center}
+</style></head><body>
+<nav class=nav><strong>Healthy Spot — West Hollywood</strong><a href="#">Services</a><a href="#">Team</a><a href="#">Visit</a><a href="#">Book</a></nav>
+<section class=hero>
+  <h1>Modern care for modern pets.</h1>
+  <p>Online booking. Email reminders. Honest pricing. The vet experience your pet (and you) deserve.</p>
+  <a class=cta href="#book">Book online →</a>
+</section>
+<section class=cards>
+  <div class=card><h3>Wellness</h3><p>Annual exams, vaccinations, and preventive care plans.</p></div>
+  <div class=card><h3>Surgery & Dental</h3><p>In-house procedures with same-day discharge for most.</p></div>
+  <div class=card><h3>Urgent Care</h3><p>Same-day sick visits — call us before going to the ER.</p></div>
+</section>
+<footer class=foot>Healthy Spot — West Hollywood · 7986 Santa Monica Blvd, West Hollywood, CA, 90046 · (323) 656-1110</footer>
+</body></html>
\ No newline at end of file
diff --git a/public/mockups/biz-4-c.html b/public/mockups/biz-4-c.html
new file mode 100644
index 0000000..ee1fec1
--- /dev/null
+++ b/public/mockups/biz-4-c.html
@@ -0,0 +1,28 @@
+<!doctype html><html><head><meta charset=utf-8>
+<style>
+body{font-family:'Helvetica Neue',Arial,sans-serif;background:#0f4d3a;color:#fff;margin:0}
+.hero{padding:5em 3em;background:linear-gradient(135deg,#0f4d3a,#0a3a2c)}
+.hero small{text-transform:uppercase;letter-spacing:.15em;color:#c97a3e;font-weight:700;font-size:.9em}
+.hero h1{font-size:4em;margin:.2em 0 .2em;font-weight:900;letter-spacing:-.02em;line-height:1}
+.hero p{font-size:1.3em;color:#d6e8df;max-width:600px;margin:1em 0 2em}
+.cta{display:inline-block;background:#c97a3e;color:#fff;padding:1.1em 2.4em;border-radius:8px;text-decoration:none;font-weight:700;font-size:1.05em;text-transform:uppercase;letter-spacing:.05em}
+.three{display:grid;grid-template-columns:repeat(3,1fr);gap:0;padding:0;background:#fff;color:#0f172a}
+.three > div{padding:3em 2.5em;border-right:1px solid #e5e7eb}
+.three h3{margin:0 0 .5em;font-size:1.4em}
+.three p{margin:0;color:#475569}
+.cta2{display:inline-block;margin-top:1em;color:#0f4d3a;font-weight:700;text-decoration:underline}
+.foot{padding:2em 3em;background:#0a3a2c;color:#9bbcae}
+</style></head><body>
+<section class=hero>
+  <small>groomer ·  West Hollywood</small>
+  <h1>Healthy Spot — West Hollywood</h1>
+  <p>Real medicine. Real people. Real results — for over 1,000 families and counting.</p>
+  <a class=cta href="#book">Book your visit →</a>
+</section>
+<section class=three>
+  <div><h3>Same-day sick visits</h3><p>Don't wait a week to be seen. Call us before 10am, see us before 5pm.</p><a class=cta2 href="#">Call (323) 656-1110 →</a></div>
+  <div><h3>Specialty referrals</h3><p>Dermatology, cardiology, oncology — we know who to send you to.</p><a class=cta2 href="#">See our network →</a></div>
+  <div><h3>Senior pet program</h3><p>A dedicated quarterly visit + lab panel for pets 8+.</p><a class=cta2 href="#">Learn more →</a></div>
+</section>
+<footer class=foot>Healthy Spot — West Hollywood · 7986 Santa Monica Blvd, West Hollywood, CA, 90046 · (323) 656-1110</footer>
+</body></html>
\ No newline at end of file
diff --git a/public/mockups/biz-5-a.html b/public/mockups/biz-5-a.html
new file mode 100644
index 0000000..e081eb7
--- /dev/null
+++ b/public/mockups/biz-5-a.html
@@ -0,0 +1,24 @@
+<!doctype html><html><head><meta charset=utf-8>
+<style>
+body{font-family:Georgia,'Times New Roman',serif;background:#f8f5ef;color:#1a1a1a;margin:0}
+.hero{padding:6em 4em 5em;background:linear-gradient(165deg,#f0e8d8,#fff);border-bottom:1px solid #e0d8c8}
+.hero h1{font-size:3em;margin:0 0 .3em;letter-spacing:-.02em;font-weight:400}
+.hero p{font-size:1.2em;color:#555;max-width:600px}
+.cta{display:inline-block;background:#3a4a3a;color:#fff;padding:1em 2em;border-radius:0;margin-top:1.5em;text-decoration:none;font-family:Helvetica,Arial,sans-serif;letter-spacing:.05em;text-transform:uppercase;font-size:.9em}
+.row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:3em;padding:4em}
+.row h3{font-weight:400;font-size:1.3em;border-bottom:1px solid #c8b89c;padding-bottom:.5em}
+.foot{padding:2em 4em;background:#3a4a3a;color:#e8dfd2;font-family:Helvetica,Arial,sans-serif}
+</style></head><body>
+<section class=hero>
+  <small style="text-transform:uppercase;letter-spacing:.1em;color:#a08850">pet store</small>
+  <h1>Centinela Feed & Pet Supplies — Mar Vista</h1>
+  <p>Compassionate care, modern medicine. Caring for  Los Angeles family pets for over a decade.</p>
+  <a class=cta href="#book">Book an appointment</a>
+</section>
+<section class=row>
+  <div><h3>Our services</h3><p>Wellness exams, vaccinations, dental care, surgery, and senior pet care.</p></div>
+  <div><h3>Our team</h3><p>Board-certified veterinarians and Fear Free Certified® technicians.</p></div>
+  <div><h3>Visit us</h3><p>12860 Washington Blvd, Los Angeles, CA, 90066<br>(310) 397-7544</p></div>
+</section>
+<footer class=foot>Centinela Feed & Pet Supplies — Mar Vista · 12860 Washington Blvd, Los Angeles, CA, 90066 · (310) 397-7544</footer>
+</body></html>
\ No newline at end of file
diff --git a/public/mockups/biz-5-b.html b/public/mockups/biz-5-b.html
new file mode 100644
index 0000000..aa4b4a7
--- /dev/null
+++ b/public/mockups/biz-5-b.html
@@ -0,0 +1,28 @@
+<!doctype html><html><head><meta charset=utf-8>
+<style>
+body{font-family:-apple-system,BlinkMacSystemFont,'Inter',sans-serif;background:#fff;color:#0f172a;margin:0}
+.nav{display:flex;align-items:center;padding:1.2em 2em;border-bottom:1px solid #e5e7eb}
+.nav strong{font-size:1.15em}.nav a{margin-left:1.5em;color:#475569;text-decoration:none}
+.hero{padding:5em 2em 4em;text-align:center;background:linear-gradient(180deg,#f0f9ff,#fff)}
+.hero h1{font-size:3.5em;margin:0;font-weight:700;letter-spacing:-.03em;color:#0f172a}
+.hero p{color:#64748b;font-size:1.25em;max-width:560px;margin:1em auto 2em}
+.cta{display:inline-block;background:#0ea5e9;color:#fff;padding:1em 2.2em;border-radius:10px;text-decoration:none;font-weight:600;box-shadow:0 4px 14px rgba(14,165,233,.3)}
+.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:1.25em;padding:3em 2em;max-width:1100px;margin:0 auto}
+.card{background:#fff;border:1px solid #e5e7eb;border-radius:14px;padding:1.5em}
+.card h3{margin:0 0 .4em;font-size:1.1em}
+.card p{color:#64748b;margin:0;font-size:.95em}
+.foot{padding:2em;background:#0f172a;color:#94a3b8;text-align:center}
+</style></head><body>
+<nav class=nav><strong>Centinela Feed & Pet Supplies — Mar Vista</strong><a href="#">Services</a><a href="#">Team</a><a href="#">Visit</a><a href="#">Book</a></nav>
+<section class=hero>
+  <h1>Modern care for modern pets.</h1>
+  <p>Online booking. Email reminders. Honest pricing. The vet experience your pet (and you) deserve.</p>
+  <a class=cta href="#book">Book online →</a>
+</section>
+<section class=cards>
+  <div class=card><h3>Wellness</h3><p>Annual exams, vaccinations, and preventive care plans.</p></div>
+  <div class=card><h3>Surgery & Dental</h3><p>In-house procedures with same-day discharge for most.</p></div>
+  <div class=card><h3>Urgent Care</h3><p>Same-day sick visits — call us before going to the ER.</p></div>
+</section>
+<footer class=foot>Centinela Feed & Pet Supplies — Mar Vista · 12860 Washington Blvd, Los Angeles, CA, 90066 · (310) 397-7544</footer>
+</body></html>
\ No newline at end of file
diff --git a/public/mockups/biz-5-c.html b/public/mockups/biz-5-c.html
new file mode 100644
index 0000000..dd659ff
--- /dev/null
+++ b/public/mockups/biz-5-c.html
@@ -0,0 +1,28 @@
+<!doctype html><html><head><meta charset=utf-8>
+<style>
+body{font-family:'Helvetica Neue',Arial,sans-serif;background:#0f4d3a;color:#fff;margin:0}
+.hero{padding:5em 3em;background:linear-gradient(135deg,#0f4d3a,#0a3a2c)}
+.hero small{text-transform:uppercase;letter-spacing:.15em;color:#c97a3e;font-weight:700;font-size:.9em}
+.hero h1{font-size:4em;margin:.2em 0 .2em;font-weight:900;letter-spacing:-.02em;line-height:1}
+.hero p{font-size:1.3em;color:#d6e8df;max-width:600px;margin:1em 0 2em}
+.cta{display:inline-block;background:#c97a3e;color:#fff;padding:1.1em 2.4em;border-radius:8px;text-decoration:none;font-weight:700;font-size:1.05em;text-transform:uppercase;letter-spacing:.05em}
+.three{display:grid;grid-template-columns:repeat(3,1fr);gap:0;padding:0;background:#fff;color:#0f172a}
+.three > div{padding:3em 2.5em;border-right:1px solid #e5e7eb}
+.three h3{margin:0 0 .5em;font-size:1.4em}
+.three p{margin:0;color:#475569}
+.cta2{display:inline-block;margin-top:1em;color:#0f4d3a;font-weight:700;text-decoration:underline}
+.foot{padding:2em 3em;background:#0a3a2c;color:#9bbcae}
+</style></head><body>
+<section class=hero>
+  <small>pet store ·  Los Angeles</small>
+  <h1>Centinela Feed & Pet Supplies — Mar Vista</h1>
+  <p>Real medicine. Real people. Real results — for over 1,000 families and counting.</p>
+  <a class=cta href="#book">Book your visit →</a>
+</section>
+<section class=three>
+  <div><h3>Same-day sick visits</h3><p>Don't wait a week to be seen. Call us before 10am, see us before 5pm.</p><a class=cta2 href="#">Call (310) 397-7544 →</a></div>
+  <div><h3>Specialty referrals</h3><p>Dermatology, cardiology, oncology — we know who to send you to.</p><a class=cta2 href="#">See our network →</a></div>
+  <div><h3>Senior pet program</h3><p>A dedicated quarterly visit + lab panel for pets 8+.</p><a class=cta2 href="#">Learn more →</a></div>
+</section>
+<footer class=foot>Centinela Feed & Pet Supplies — Mar Vista · 12860 Washington Blvd, Los Angeles, CA, 90066 · (310) 397-7544</footer>
+</body></html>
\ No newline at end of file
diff --git a/public/mockups/biz-8-a.html b/public/mockups/biz-8-a.html
new file mode 100644
index 0000000..1768022
--- /dev/null
+++ b/public/mockups/biz-8-a.html
@@ -0,0 +1,24 @@
+<!doctype html><html><head><meta charset=utf-8>
+<style>
+body{font-family:Georgia,'Times New Roman',serif;background:#f8f5ef;color:#1a1a1a;margin:0}
+.hero{padding:6em 4em 5em;background:linear-gradient(165deg,#f0e8d8,#fff);border-bottom:1px solid #e0d8c8}
+.hero h1{font-size:3em;margin:0 0 .3em;letter-spacing:-.02em;font-weight:400}
+.hero p{font-size:1.2em;color:#555;max-width:600px}
+.cta{display:inline-block;background:#3a4a3a;color:#fff;padding:1em 2em;border-radius:0;margin-top:1.5em;text-decoration:none;font-family:Helvetica,Arial,sans-serif;letter-spacing:.05em;text-transform:uppercase;font-size:.9em}
+.row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:3em;padding:4em}
+.row h3{font-weight:400;font-size:1.3em;border-bottom:1px solid #c8b89c;padding-bottom:.5em}
+.foot{padding:2em 4em;background:#3a4a3a;color:#e8dfd2;font-family:Helvetica,Arial,sans-serif}
+</style></head><body>
+<section class=hero>
+  <small style="text-transform:uppercase;letter-spacing:.1em;color:#a08850">trainer</small>
+  <h1>Beverly Hills Dog Training</h1>
+  <p>Compassionate care, modern medicine. Caring for  Beverly Hills family pets for over a decade.</p>
+  <a class=cta href="#book">Book an appointment</a>
+</section>
+<section class=row>
+  <div><h3>Our services</h3><p>Wellness exams, vaccinations, dental care, surgery, and senior pet care.</p></div>
+  <div><h3>Our team</h3><p>Board-certified veterinarians and Fear Free Certified® technicians.</p></div>
+  <div><h3>Visit us</h3><p>—, Beverly Hills, CA, 90210<br>(310) 962-3055</p></div>
+</section>
+<footer class=foot>Beverly Hills Dog Training · —, Beverly Hills, CA, 90210 · (310) 962-3055</footer>
+</body></html>
\ No newline at end of file
diff --git a/public/mockups/biz-8-b.html b/public/mockups/biz-8-b.html
new file mode 100644
index 0000000..5a5304f
--- /dev/null
+++ b/public/mockups/biz-8-b.html
@@ -0,0 +1,28 @@
+<!doctype html><html><head><meta charset=utf-8>
+<style>
+body{font-family:-apple-system,BlinkMacSystemFont,'Inter',sans-serif;background:#fff;color:#0f172a;margin:0}
+.nav{display:flex;align-items:center;padding:1.2em 2em;border-bottom:1px solid #e5e7eb}
+.nav strong{font-size:1.15em}.nav a{margin-left:1.5em;color:#475569;text-decoration:none}
+.hero{padding:5em 2em 4em;text-align:center;background:linear-gradient(180deg,#f0f9ff,#fff)}
+.hero h1{font-size:3.5em;margin:0;font-weight:700;letter-spacing:-.03em;color:#0f172a}
+.hero p{color:#64748b;font-size:1.25em;max-width:560px;margin:1em auto 2em}
+.cta{display:inline-block;background:#0ea5e9;color:#fff;padding:1em 2.2em;border-radius:10px;text-decoration:none;font-weight:600;box-shadow:0 4px 14px rgba(14,165,233,.3)}
+.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:1.25em;padding:3em 2em;max-width:1100px;margin:0 auto}
+.card{background:#fff;border:1px solid #e5e7eb;border-radius:14px;padding:1.5em}
+.card h3{margin:0 0 .4em;font-size:1.1em}
+.card p{color:#64748b;margin:0;font-size:.95em}
+.foot{padding:2em;background:#0f172a;color:#94a3b8;text-align:center}
+</style></head><body>
+<nav class=nav><strong>Beverly Hills Dog Training</strong><a href="#">Services</a><a href="#">Team</a><a href="#">Visit</a><a href="#">Book</a></nav>
+<section class=hero>
+  <h1>Modern care for modern pets.</h1>
+  <p>Online booking. Email reminders. Honest pricing. The vet experience your pet (and you) deserve.</p>
+  <a class=cta href="#book">Book online →</a>
+</section>
+<section class=cards>
+  <div class=card><h3>Wellness</h3><p>Annual exams, vaccinations, and preventive care plans.</p></div>
+  <div class=card><h3>Surgery & Dental</h3><p>In-house procedures with same-day discharge for most.</p></div>
+  <div class=card><h3>Urgent Care</h3><p>Same-day sick visits — call us before going to the ER.</p></div>
+</section>
+<footer class=foot>Beverly Hills Dog Training · —, Beverly Hills, CA, 90210 · (310) 962-3055</footer>
+</body></html>
\ No newline at end of file
diff --git a/public/mockups/biz-8-c.html b/public/mockups/biz-8-c.html
new file mode 100644
index 0000000..90e7bac
--- /dev/null
+++ b/public/mockups/biz-8-c.html
@@ -0,0 +1,28 @@
+<!doctype html><html><head><meta charset=utf-8>
+<style>
+body{font-family:'Helvetica Neue',Arial,sans-serif;background:#0f4d3a;color:#fff;margin:0}
+.hero{padding:5em 3em;background:linear-gradient(135deg,#0f4d3a,#0a3a2c)}
+.hero small{text-transform:uppercase;letter-spacing:.15em;color:#c97a3e;font-weight:700;font-size:.9em}
+.hero h1{font-size:4em;margin:.2em 0 .2em;font-weight:900;letter-spacing:-.02em;line-height:1}
+.hero p{font-size:1.3em;color:#d6e8df;max-width:600px;margin:1em 0 2em}
+.cta{display:inline-block;background:#c97a3e;color:#fff;padding:1.1em 2.4em;border-radius:8px;text-decoration:none;font-weight:700;font-size:1.05em;text-transform:uppercase;letter-spacing:.05em}
+.three{display:grid;grid-template-columns:repeat(3,1fr);gap:0;padding:0;background:#fff;color:#0f172a}
+.three > div{padding:3em 2.5em;border-right:1px solid #e5e7eb}
+.three h3{margin:0 0 .5em;font-size:1.4em}
+.three p{margin:0;color:#475569}
+.cta2{display:inline-block;margin-top:1em;color:#0f4d3a;font-weight:700;text-decoration:underline}
+.foot{padding:2em 3em;background:#0a3a2c;color:#9bbcae}
+</style></head><body>
+<section class=hero>
+  <small>trainer ·  Beverly Hills</small>
+  <h1>Beverly Hills Dog Training</h1>
+  <p>Real medicine. Real people. Real results — for over 1,000 families and counting.</p>
+  <a class=cta href="#book">Book your visit →</a>
+</section>
+<section class=three>
+  <div><h3>Same-day sick visits</h3><p>Don't wait a week to be seen. Call us before 10am, see us before 5pm.</p><a class=cta2 href="#">Call (310) 962-3055 →</a></div>
+  <div><h3>Specialty referrals</h3><p>Dermatology, cardiology, oncology — we know who to send you to.</p><a class=cta2 href="#">See our network →</a></div>
+  <div><h3>Senior pet program</h3><p>A dedicated quarterly visit + lab panel for pets 8+.</p><a class=cta2 href="#">Learn more →</a></div>
+</section>
+<footer class=foot>Beverly Hills Dog Training · —, Beverly Hills, CA, 90210 · (310) 962-3055</footer>
+</body></html>
\ No newline at end of file
diff --git a/src/audit/generate_mockups.js b/src/audit/generate_mockups.js
new file mode 100644
index 0000000..cd6a778
--- /dev/null
+++ b/src/audit/generate_mockups.js
@@ -0,0 +1,174 @@
+#!/usr/bin/env node
+// Mockup generator (Rail C). For every business with a low marketing_score,
+// renders 3 template variants (Editorial Calm, Clean Modern, Bold Trust) as
+// HTML, then screenshots each via Playwright at 1280×900. Stores a row per
+// (business_id, variant) in site_mockups.
+//
+// The HTML templates are deliberately simple — the goal is to *demonstrate*
+// what an upgrade looks like, not to ship the real site. Real site delivery
+// happens after the upgrade_order is paid.
+//
+// Run:
+//   node src/audit/generate_mockups.js --score-below 60 --limit 10
+//   node src/audit/generate_mockups.js --biz 5
+
+import 'dotenv/config';
+import { chromium } from 'playwright';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import fs from 'node:fs';
+import { pool, many, one } from '../lib/db.js';
+import { log } from '../lib/log.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const PUBLIC = path.resolve(__dirname, '..', '..', 'public');
+const MOCK_DIR = path.join(PUBLIC, 'mockups');
+fs.mkdirSync(MOCK_DIR, { recursive: true });
+
+const args = process.argv.slice(2);
+const scoreBelow = parseInt(args[args.indexOf('--score-below') + 1], 10) || 60;
+const limit = parseInt(args[args.indexOf('--limit') + 1], 10) || 25;
+const onlyBiz = parseInt(args[args.indexOf('--biz') + 1], 10) || null;
+
+const sql = onlyBiz
+  ? `SELECT b.* FROM businesses b WHERE b.id = ${onlyBiz}`
+  : `SELECT DISTINCT b.* FROM businesses b
+     JOIN site_audits a ON a.business_id = b.id
+     WHERE a.marketing_score < ${scoreBelow}
+       AND b.id NOT IN (SELECT business_id FROM site_mockups GROUP BY business_id HAVING COUNT(*) >= 3)
+     LIMIT ${limit}`;
+const targets = await many(sql);
+log.info(`generating mockups for ${targets.length} businesses`);
+
+const browser = await chromium.launch({ headless: true });
+const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 } });
+
+const VARIANTS = [
+  { id: 'a', label: 'Editorial Calm',  fn: editorialCalm },
+  { id: 'b', label: 'Clean Modern',    fn: cleanModern },
+  { id: 'c', label: 'Bold Trust',      fn: boldTrust },
+];
+
+for (const biz of targets) {
+  for (const v of VARIANTS) {
+    const html = v.fn(biz);
+    const htmlPath = path.join(MOCK_DIR, `biz-${biz.id}-${v.id}.html`);
+    fs.writeFileSync(htmlPath, html);
+    const shotPath = path.join(MOCK_DIR, `biz-${biz.id}-${v.id}.png`);
+    const page = await ctx.newPage();
+    await page.setContent(html, { waitUntil: 'load' });
+    await page.screenshot({ path: shotPath, fullPage: false });
+    await page.close();
+    await pool.query(
+      `INSERT INTO site_mockups (business_id, variant, template_label, screenshot_path, html_path)
+       VALUES ($1,$2,$3,$4,$5)
+       ON CONFLICT (business_id, variant) DO UPDATE
+         SET screenshot_path = EXCLUDED.screenshot_path, html_path = EXCLUDED.html_path,
+             generated_at = NOW()`,
+      [biz.id, v.id, v.label, `/static/mockups/biz-${biz.id}-${v.id}.png`, `/static/mockups/biz-${biz.id}-${v.id}.html`]
+    );
+  }
+  log.info(`mocked ${biz.name}`);
+}
+
+await ctx.close(); await browser.close(); await pool.end();
+log.info('done');
+
+// ─── templates ─────────────────────────────────────────────────────────────
+function shared(biz) {
+  return {
+    name: biz.name,
+    addr: [biz.address, biz.city, biz.state, biz.zip].filter(Boolean).join(', '),
+    phone: biz.phone || 'Call us',
+    cat: (biz.category || '').replace(/_/g,' '),
+  };
+}
+function editorialCalm(biz) {
+  const s = shared(biz);
+  return `<!doctype html><html><head><meta charset=utf-8>
+<style>
+body{font-family:Georgia,'Times New Roman',serif;background:#f8f5ef;color:#1a1a1a;margin:0}
+.hero{padding:6em 4em 5em;background:linear-gradient(165deg,#f0e8d8,#fff);border-bottom:1px solid #e0d8c8}
+.hero h1{font-size:3em;margin:0 0 .3em;letter-spacing:-.02em;font-weight:400}
+.hero p{font-size:1.2em;color:#555;max-width:600px}
+.cta{display:inline-block;background:#3a4a3a;color:#fff;padding:1em 2em;border-radius:0;margin-top:1.5em;text-decoration:none;font-family:Helvetica,Arial,sans-serif;letter-spacing:.05em;text-transform:uppercase;font-size:.9em}
+.row{display:grid;grid-template-columns:1fr 1fr 1fr;gap:3em;padding:4em}
+.row h3{font-weight:400;font-size:1.3em;border-bottom:1px solid #c8b89c;padding-bottom:.5em}
+.foot{padding:2em 4em;background:#3a4a3a;color:#e8dfd2;font-family:Helvetica,Arial,sans-serif}
+</style></head><body>
+<section class=hero>
+  <small style="text-transform:uppercase;letter-spacing:.1em;color:#a08850">${s.cat}</small>
+  <h1>${s.name}</h1>
+  <p>Compassionate care, modern medicine. Caring for ${s.addr.split(',')[1]||'your'} family pets for over a decade.</p>
+  <a class=cta href="#book">Book an appointment</a>
+</section>
+<section class=row>
+  <div><h3>Our services</h3><p>Wellness exams, vaccinations, dental care, surgery, and senior pet care.</p></div>
+  <div><h3>Our team</h3><p>Board-certified veterinarians and Fear Free Certified® technicians.</p></div>
+  <div><h3>Visit us</h3><p>${s.addr}<br>${s.phone}</p></div>
+</section>
+<footer class=foot>${s.name} · ${s.addr} · ${s.phone}</footer>
+</body></html>`;
+}
+function cleanModern(biz) {
+  const s = shared(biz);
+  return `<!doctype html><html><head><meta charset=utf-8>
+<style>
+body{font-family:-apple-system,BlinkMacSystemFont,'Inter',sans-serif;background:#fff;color:#0f172a;margin:0}
+.nav{display:flex;align-items:center;padding:1.2em 2em;border-bottom:1px solid #e5e7eb}
+.nav strong{font-size:1.15em}.nav a{margin-left:1.5em;color:#475569;text-decoration:none}
+.hero{padding:5em 2em 4em;text-align:center;background:linear-gradient(180deg,#f0f9ff,#fff)}
+.hero h1{font-size:3.5em;margin:0;font-weight:700;letter-spacing:-.03em;color:#0f172a}
+.hero p{color:#64748b;font-size:1.25em;max-width:560px;margin:1em auto 2em}
+.cta{display:inline-block;background:#0ea5e9;color:#fff;padding:1em 2.2em;border-radius:10px;text-decoration:none;font-weight:600;box-shadow:0 4px 14px rgba(14,165,233,.3)}
+.cards{display:grid;grid-template-columns:repeat(3,1fr);gap:1.25em;padding:3em 2em;max-width:1100px;margin:0 auto}
+.card{background:#fff;border:1px solid #e5e7eb;border-radius:14px;padding:1.5em}
+.card h3{margin:0 0 .4em;font-size:1.1em}
+.card p{color:#64748b;margin:0;font-size:.95em}
+.foot{padding:2em;background:#0f172a;color:#94a3b8;text-align:center}
+</style></head><body>
+<nav class=nav><strong>${s.name}</strong><a href="#">Services</a><a href="#">Team</a><a href="#">Visit</a><a href="#">Book</a></nav>
+<section class=hero>
+  <h1>Modern care for modern pets.</h1>
+  <p>Online booking. Email reminders. Honest pricing. The vet experience your pet (and you) deserve.</p>
+  <a class=cta href="#book">Book online →</a>
+</section>
+<section class=cards>
+  <div class=card><h3>Wellness</h3><p>Annual exams, vaccinations, and preventive care plans.</p></div>
+  <div class=card><h3>Surgery & Dental</h3><p>In-house procedures with same-day discharge for most.</p></div>
+  <div class=card><h3>Urgent Care</h3><p>Same-day sick visits — call us before going to the ER.</p></div>
+</section>
+<footer class=foot>${s.name} · ${s.addr} · ${s.phone}</footer>
+</body></html>`;
+}
+function boldTrust(biz) {
+  const s = shared(biz);
+  return `<!doctype html><html><head><meta charset=utf-8>
+<style>
+body{font-family:'Helvetica Neue',Arial,sans-serif;background:#0f4d3a;color:#fff;margin:0}
+.hero{padding:5em 3em;background:linear-gradient(135deg,#0f4d3a,#0a3a2c)}
+.hero small{text-transform:uppercase;letter-spacing:.15em;color:#c97a3e;font-weight:700;font-size:.9em}
+.hero h1{font-size:4em;margin:.2em 0 .2em;font-weight:900;letter-spacing:-.02em;line-height:1}
+.hero p{font-size:1.3em;color:#d6e8df;max-width:600px;margin:1em 0 2em}
+.cta{display:inline-block;background:#c97a3e;color:#fff;padding:1.1em 2.4em;border-radius:8px;text-decoration:none;font-weight:700;font-size:1.05em;text-transform:uppercase;letter-spacing:.05em}
+.three{display:grid;grid-template-columns:repeat(3,1fr);gap:0;padding:0;background:#fff;color:#0f172a}
+.three > div{padding:3em 2.5em;border-right:1px solid #e5e7eb}
+.three h3{margin:0 0 .5em;font-size:1.4em}
+.three p{margin:0;color:#475569}
+.cta2{display:inline-block;margin-top:1em;color:#0f4d3a;font-weight:700;text-decoration:underline}
+.foot{padding:2em 3em;background:#0a3a2c;color:#9bbcae}
+</style></head><body>
+<section class=hero>
+  <small>${s.cat} · ${s.addr.split(',')[1]||'Local'}</small>
+  <h1>${s.name}</h1>
+  <p>Real medicine. Real people. Real results — for over 1,000 families and counting.</p>
+  <a class=cta href="#book">Book your visit →</a>
+</section>
+<section class=three>
+  <div><h3>Same-day sick visits</h3><p>Don't wait a week to be seen. Call us before 10am, see us before 5pm.</p><a class=cta2 href="#">Call ${s.phone} →</a></div>
+  <div><h3>Specialty referrals</h3><p>Dermatology, cardiology, oncology — we know who to send you to.</p><a class=cta2 href="#">See our network →</a></div>
+  <div><h3>Senior pet program</h3><p>A dedicated quarterly visit + lab panel for pets 8+.</p><a class=cta2 href="#">Learn more →</a></div>
+</section>
+<footer class=foot>${s.name} · ${s.addr} · ${s.phone}</footer>
+</body></html>`;
+}
diff --git a/src/audit/server.js b/src/audit/server.js
new file mode 100644
index 0000000..7ad5413
--- /dev/null
+++ b/src/audit/server.js
@@ -0,0 +1,30 @@
+// HTTP shell for animals-audit pm2 process. Health endpoint + a simple
+// trigger so the orchestrator (or cron via launchd) can kick off audit runs.
+import 'dotenv/config';
+import express from 'express';
+import { spawn } from 'node:child_process';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { log } from '../lib/log.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(__dirname, '..', '..');
+
+const app = express();
+app.get('/healthz', (_req, res) => res.json({ ok: true }));
+app.post('/run/site-audit', (req, res) => {
+  const limit = parseInt(req.query.limit, 10) || 25;
+  const child = spawn('node', ['src/audit/site_audit.js', '--limit', String(limit)], { cwd: ROOT, detached: true, stdio: 'ignore' });
+  child.unref();
+  res.json({ ok: true, kicked: 'site-audit', limit });
+});
+app.post('/run/mockups', (req, res) => {
+  const below = parseInt(req.query.below, 10) || 60;
+  const limit = parseInt(req.query.limit, 10) || 10;
+  const child = spawn('node', ['src/audit/generate_mockups.js', '--score-below', String(below), '--limit', String(limit)], { cwd: ROOT, detached: true, stdio: 'ignore' });
+  child.unref();
+  res.json({ ok: true, kicked: 'mockups', below, limit });
+});
+
+const PORT = parseInt(process.env.PORT || '9722', 10);
+app.listen(PORT, () => log.info(`animals-audit listening on :${PORT}`));
diff --git a/src/audit/site_audit.js b/src/audit/site_audit.js
new file mode 100644
index 0000000..35d7d3e
--- /dev/null
+++ b/src/audit/site_audit.js
@@ -0,0 +1,126 @@
+#!/usr/bin/env node
+// Site audit job (Rail C upsell engine).
+//
+// For every business with a website:
+//   1. Fetch the home page (UA: AnimalsDirectoryAuditBot/1.0; respects robots).
+//   2. Take a Playwright screenshot @ 1280×800.
+//   3. Score on-page signals (HTTPS, viewport, schema.org, analytics, online
+//      booking, page weight, load time).
+//   4. Insert a row into site_audits.
+//
+// Run nightly via pm2 cron, or once-off:
+//   node src/audit/site_audit.js                  # all unaudited businesses
+//   node src/audit/site_audit.js --limit 10       # cap
+//   node src/audit/site_audit.js --biz 5          # one specific business id
+
+import 'dotenv/config';
+import { chromium } from 'playwright';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import fs from 'node:fs';
+import { pool, many } from '../lib/db.js';
+import { log } from '../lib/log.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const SHOTS_DIR = path.resolve(__dirname, '..', '..', 'public', 'screenshots');
+fs.mkdirSync(SHOTS_DIR, { recursive: true });
+
+const args = process.argv.slice(2);
+const limit = parseInt(args[args.indexOf('--limit') + 1], 10) || null;
+const onlyBiz = parseInt(args[args.indexOf('--biz') + 1], 10) || null;
+
+const where = onlyBiz ? `id = ${onlyBiz}` :
+  `website IS NOT NULL AND website <> '' AND id NOT IN (SELECT business_id FROM site_audits WHERE audited_at > NOW() - INTERVAL '30 days')`;
+const sql = `SELECT id, name, website FROM businesses WHERE ${where} LIMIT ${limit || 200}`;
+const rows = await many(sql);
+log.info(`auditing ${rows.length} businesses`);
+
+const browser = await chromium.launch({ headless: true });
+const ctx = await browser.newContext({
+  userAgent: 'Mozilla/5.0 (compatible; AnimalsDirectoryAuditBot/1.0; +https://animalsdirectory.com/bot)',
+  viewport: { width: 1280, height: 800 },
+});
+
+for (const biz of rows) {
+  try { await auditOne(biz); }
+  catch (err) { log.error('audit failed', { biz: biz.id, url: biz.website, err: err.message }); }
+}
+
+await ctx.close(); await browser.close(); await pool.end();
+log.info('done');
+
+async function auditOne(biz) {
+  const page = await ctx.newPage();
+  const t0 = Date.now();
+  let resp;
+  try {
+    resp = await page.goto(biz.website, { waitUntil: 'domcontentloaded', timeout: 25000 });
+  } catch (err) {
+    await pool.query(
+      `INSERT INTO site_audits (business_id, url, status_code, marketing_score, suggestions)
+       VALUES ($1,$2,NULL,0,$3)`,
+      [biz.id, biz.website, ['Site is unreachable from our crawler — pages must load reliably to rank.']]
+    );
+    await page.close();
+    return;
+  }
+  const loadMs = Date.now() - t0;
+  const status = resp?.status() ?? null;
+  const finalUrl = page.url();
+  const html = await page.content();
+  const shotPath = path.join(SHOTS_DIR, `biz-${biz.id}.png`);
+  await page.screenshot({ path: shotPath, fullPage: false });
+
+  // Signals
+  const has_https = finalUrl.startsWith('https://');
+  const has_favicon = /<link[^>]+rel=['"]?(?:shortcut )?icon['"]?[^>]*>/i.test(html);
+  const has_og_image = /<meta[^>]+property=['"]og:image['"][^>]*>/i.test(html);
+  const has_meta_viewport = /<meta[^>]+name=['"]viewport['"][^>]*>/i.test(html);
+  const has_analytics = /(googletagmanager|google-analytics|gtag\(|plausible|fathom|matomo)/i.test(html);
+  const has_h1 = /<h1[\s>]/i.test(html);
+  const has_schema_org = /schema\.org\/(VeterinaryCare|AnimalShelter|PetStore|MedicalOrganization|LocalBusiness)/i.test(html);
+  const has_phone_visible = /\b\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}\b/.test(html);
+  const has_online_booking = /(book[ -]?online|book[ -]?(an )?appointment|schedule[ -]?(an )?appointment|petdesk|vetstoria|onpetwell|weave)/i.test(html);
+  const page_size_bytes = Buffer.byteLength(html, 'utf8');
+  const font_count = (html.match(/<link[^>]+fonts\.googleapis|@font-face/gi) || []).length;
+  const image_count = (html.match(/<img[\s>]/gi) || []).length;
+  const link_count = (html.match(/<a[\s>]/gi) || []).length;
+
+  // Score (out of 100)
+  let score = 0;
+  const checks = [
+    [has_https, 12, 'No HTTPS — Google ranks HTTPS sites higher and modern browsers warn visitors.'],
+    [has_meta_viewport, 10, 'No mobile viewport tag — site looks broken on phones (60%+ of pet-owner searches).'],
+    [has_h1, 6, 'No <h1> headline — search engines can\'t identify your page topic.'],
+    [has_schema_org, 12, 'No schema.org markup — you\'re invisible to Google\'s vet/local-business rich results.'],
+    [has_og_image, 6, 'No og:image — links you share on Facebook/iMessage have no preview.'],
+    [has_analytics, 6, 'No analytics installed — you can\'t see what\'s working.'],
+    [has_phone_visible, 8, 'No phone number visible on the home page — pet owners can\'t click-to-call.'],
+    [has_online_booking, 14, 'No online booking link — every clinic adopting this in 2025 sees ~15% intake lift.'],
+    [has_favicon, 4, 'No favicon — browser tabs and bookmarks look unbranded.'],
+    [loadMs < 3000, 12, `Page load is ${(loadMs/1000).toFixed(1)}s — Google penalizes anything over 3s.`],
+    [page_size_bytes < 1024*1024, 5, `Page weighs ${(page_size_bytes/1024).toFixed(0)}KB — under 1MB is the modern target.`],
+    [image_count >= 3 && image_count <= 30, 5, image_count<3 ? 'Too few images — pet-owners want to see the team and facility.' : 'Too many images on home page — slows everything down.'],
+  ];
+  const suggestions = [];
+  for (const [pass, pts, why] of checks) {
+    if (pass) score += pts; else suggestions.push(why);
+  }
+
+  await pool.query(
+    `INSERT INTO site_audits
+       (business_id, url, final_url, status_code, screenshot_path,
+        has_https, has_favicon, has_og_image, has_meta_viewport, has_analytics,
+        has_h1, has_schema_org, has_phone_visible, has_online_booking,
+        page_size_bytes, load_time_ms, font_count, image_count, link_count,
+        marketing_score, suggestions)
+     VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)`,
+    [biz.id, biz.website, finalUrl, status, `/static/screenshots/biz-${biz.id}.png`,
+     has_https, has_favicon, has_og_image, has_meta_viewport, has_analytics,
+     has_h1, has_schema_org, has_phone_visible, has_online_booking,
+     page_size_bytes, loadMs, font_count, image_count, link_count,
+     score, suggestions]
+  );
+  log.info(`audited ${biz.name}`, { score, sugg: suggestions.length });
+  await page.close();
+}
diff --git a/src/ingest/server.js b/src/ingest/server.js
new file mode 100644
index 0000000..3e7d9fe
--- /dev/null
+++ b/src/ingest/server.js
@@ -0,0 +1,14 @@
+// HTTP shell for animals-ingest pm2 process. Health + trigger endpoints.
+// Real scrapers live in src/ingest/<name>.js (state vet boards, AAHA, etc.)
+// and are called via npm scripts or this server.
+import 'dotenv/config';
+import express from 'express';
+import { log } from '../lib/log.js';
+
+const app = express();
+app.get('/healthz', (_req, res) => res.json({ ok: true }));
+app.post('/run/:job', (_req, res) => {
+  res.json({ ok: false, todo: 'wire individual ingest scripts' });
+});
+const PORT = parseInt(process.env.PORT || '9721', 10);
+app.listen(PORT, () => log.info(`animals-ingest listening on :${PORT}`));
diff --git a/src/lib/db.js b/src/lib/db.js
new file mode 100644
index 0000000..c7f6f13
--- /dev/null
+++ b/src/lib/db.js
@@ -0,0 +1,33 @@
+import 'dotenv/config';
+import pg from 'pg';
+
+export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
+
+export async function query(text, params) {
+  return pool.query(text, params);
+}
+
+export async function one(text, params) {
+  const { rows } = await pool.query(text, params);
+  return rows[0] || null;
+}
+
+export async function many(text, params) {
+  const { rows } = await pool.query(text, params);
+  return rows;
+}
+
+export async function tx(fn) {
+  const client = await pool.connect();
+  try {
+    await client.query('BEGIN');
+    const result = await fn(client);
+    await client.query('COMMIT');
+    return result;
+  } catch (err) {
+    await client.query('ROLLBACK');
+    throw err;
+  } finally {
+    client.release();
+  }
+}
diff --git a/src/lib/log.js b/src/lib/log.js
new file mode 100644
index 0000000..c0ef7ea
--- /dev/null
+++ b/src/lib/log.js
@@ -0,0 +1,12 @@
+// Tiny structured logger; matches the lawyer/doctor project style.
+const ts = () => new Date().toISOString();
+const fmt = (lvl, msg, meta) => {
+  const m = meta ? ' ' + JSON.stringify(meta) : '';
+  return `[${ts()}] ${lvl} ${msg}${m}`;
+};
+export const log = {
+  info:  (msg, meta) => console.log(fmt('INFO', msg, meta)),
+  warn:  (msg, meta) => console.warn(fmt('WARN', msg, meta)),
+  error: (msg, meta) => console.error(fmt('ERROR', msg, meta)),
+  debug: (msg, meta) => process.env.DEBUG && console.log(fmt('DEBUG', msg, meta)),
+};
diff --git a/src/lib/slug.js b/src/lib/slug.js
new file mode 100644
index 0000000..40a675c
--- /dev/null
+++ b/src/lib/slug.js
@@ -0,0 +1,9 @@
+export function slugify(str) {
+  return String(str)
+    .toLowerCase()
+    .normalize('NFKD')
+    .replace(/[̀-ͯ]/g, '')
+    .replace(/[^a-z0-9]+/g, '-')
+    .replace(/^-+|-+$/g, '')
+    .slice(0, 80);
+}
diff --git a/src/monetize/ad_engine.js b/src/monetize/ad_engine.js
new file mode 100644
index 0000000..7c30fc2
--- /dev/null
+++ b/src/monetize/ad_engine.js
@@ -0,0 +1,48 @@
+// In-house ad engine. Two modes per AD_PROVIDER env var:
+//   inhouse: pick a creative from ad_creatives table by keyword/category match
+//   adsense: render the AdSense snippet (placeholder — needs ADSENSE_CLIENT_ID)
+//
+// Always falls through to a "house" promo (Vet Site Upgrade) when no
+// matching creative is available, so no slot is ever blank.
+
+import { pool, one } from '../lib/db.js';
+
+const PROVIDER = (process.env.AD_PROVIDER || 'inhouse').toLowerCase();
+
+export async function pickAdForPage({ pagePath, keywords = [], category = null }) {
+  if (PROVIDER === 'adsense') {
+    // For AdSense, return a sentinel; render layer injects the snippet.
+    return null;
+  }
+  // Score: +5 per matching keyword, +3 if category matches; ties broken by random.
+  const params = [];
+  const conds = ['active = true'];
+  if (category) {
+    params.push(category);
+    conds.push(`($${params.length} = ANY(target_categories) OR target_categories IS NULL OR cardinality(target_categories) = 0)`);
+  }
+  if (keywords.length) {
+    params.push(keywords);
+    conds.push(`(target_keywords && $${params.length}::text[] OR target_keywords IS NULL OR cardinality(target_keywords) = 0)`);
+  }
+  const sql = `
+    SELECT id, headline, body, cta_text, cta_url, image_url, slot_size
+    FROM ad_creatives
+    WHERE ${conds.join(' AND ')}
+    ORDER BY RANDOM()
+    LIMIT 1`;
+  return one(sql, params);
+}
+
+export async function recordImpression(creative_id, page_path, ip, ua) {
+  await pool.query(
+    `INSERT INTO ad_impressions (creative_id, page_path, ip, user_agent) VALUES ($1,$2,$3,$4)`,
+    [creative_id, page_path || null, ip || null, ua || null]
+  ).catch(() => {});
+}
+export async function recordClick(creative_id, page_path, ip, ua) {
+  await pool.query(
+    `INSERT INTO ad_clicks (creative_id, page_path, ip, user_agent) VALUES ($1,$2,$3,$4)`,
+    [creative_id, page_path || null, ip || null, ua || null]
+  ).catch(() => {});
+}
diff --git a/src/monetize/leads_server.js b/src/monetize/leads_server.js
new file mode 100644
index 0000000..2882a49
--- /dev/null
+++ b/src/monetize/leads_server.js
@@ -0,0 +1,18 @@
+// HTTP shell for animals-leads pm2 process. Health + a manual rematch endpoint.
+import 'dotenv/config';
+import express from 'express';
+import { matchLead } from './match_engine.js';
+import { log } from '../lib/log.js';
+
+const app = express();
+app.get('/healthz', (_req, res) => res.json({ ok: true }));
+app.post('/rematch/:id', async (req, res) => {
+  try {
+    const matches = await matchLead(parseInt(req.params.id, 10));
+    res.json({ ok: true, matches });
+  } catch (err) {
+    res.status(500).json({ ok: false, error: err.message });
+  }
+});
+const PORT = parseInt(process.env.PORT || '9723', 10);
+app.listen(PORT, () => log.info(`animals-leads listening on :${PORT}`));
diff --git a/src/monetize/match_engine.js b/src/monetize/match_engine.js
new file mode 100644
index 0000000..2584d5d
--- /dev/null
+++ b/src/monetize/match_engine.js
@@ -0,0 +1,86 @@
+// Lead matcher — picks 3 nearest businesses by ZIP + category, queues outbound
+// notification via George (gmail agent on tailnet) when configured.
+
+import { pool, one, many } from '../lib/db.js';
+import { log } from '../lib/log.js';
+
+// Cheap ZIP-prefix proximity. Real Haversine kicks in once businesses have
+// (latitude, longitude) populated by the geocoder. For v1 this is good
+// enough to get the funnel turned on.
+export async function matchLead(leadId) {
+  const lead = await one('SELECT * FROM leads WHERE id=$1', [leadId]);
+  if (!lead) return;
+  const { category_wanted, zip, state } = lead;
+
+  // Try same ZIP first, then ZIP-3 prefix, then state, then anywhere.
+  const tries = [
+    zip ? `zip = '${zip.replace(/[^0-9]/g,'')}'` : null,
+    zip ? `zip LIKE '${zip.slice(0,3).replace(/[^0-9]/g,'')}%'` : null,
+    state ? `state = '${state.replace(/[^A-Za-z]/g,'')}'` : null,
+    '1=1'
+  ].filter(Boolean);
+
+  let matches = [];
+  for (const where of tries) {
+    matches = await many(
+      `SELECT id, name, email, phone, website, city, state, zip, latitude, longitude
+       FROM businesses
+       WHERE category=$1 AND opt_out_flag=FALSE AND ${where}
+       ORDER BY rating DESC NULLS LAST, review_count DESC NULLS LAST
+       LIMIT 3`,
+      [category_wanted]
+    );
+    if (matches.length) break;
+  }
+
+  await pool.query(
+    `UPDATE leads SET matched_business_ids = $1, status = 'matched' WHERE id = $2`,
+    [matches.map(m => m.id), leadId]
+  );
+
+  // Outbound notify (best-effort; don't fail the function if George is down)
+  if (process.env.GEORGE_GMAIL_URL && matches.length) {
+    for (const m of matches) {
+      if (!m.email) continue;
+      try {
+        const body = renderOutreach(lead, m);
+        await fetch(`${process.env.GEORGE_GMAIL_URL}/api/send`, {
+          method: 'POST',
+          headers: { 'content-type': 'application/json' },
+          body: JSON.stringify({
+            to: m.email,
+            subject: `New ${category_wanted.replace(/_/g,' ')} lead in ${lead.zip || lead.city || lead.state || 'your area'}`,
+            body,
+          }),
+        });
+      } catch (err) {
+        log.warn('george email failed', { biz: m.id, err: err.message });
+      }
+    }
+  }
+
+  log.info('matched', { leadId, count: matches.length });
+  return matches;
+}
+
+function renderOutreach(lead, biz) {
+  return `
+<p>Hi ${biz.name} team —</p>
+<p>You have a new pet-owner lead from AnimalsDirectory.</p>
+<table style="border-collapse:collapse">
+  <tr><td><strong>Name</strong></td><td>${escapeHtml(lead.full_name)}</td></tr>
+  <tr><td><strong>Email</strong></td><td>${escapeHtml(lead.email)}</td></tr>
+  ${lead.phone ? `<tr><td><strong>Phone</strong></td><td>${escapeHtml(lead.phone)}</td></tr>` : ''}
+  <tr><td><strong>Looking for</strong></td><td>${escapeHtml(lead.category_wanted)}</td></tr>
+  ${lead.species_wanted ? `<tr><td><strong>Species</strong></td><td>${escapeHtml(lead.species_wanted)}</td></tr>` : ''}
+  ${lead.urgency ? `<tr><td><strong>Urgency</strong></td><td>${escapeHtml(lead.urgency)}</td></tr>` : ''}
+  ${lead.description ? `<tr><td><strong>Notes</strong></td><td>${escapeHtml(lead.description)}</td></tr>` : ''}
+</table>
+<p>Reply to <a href="mailto:${escapeHtml(lead.email)}">${escapeHtml(lead.email)}</a> to claim this lead.</p>
+<p style="color:#888;font-size:.85em">— AnimalsDirectory · <a href="https://animalsdirectory.com/admin/optout?biz=${biz.id}">opt out</a></p>
+`;
+}
+
+function escapeHtml(s) {
+  return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
+}
diff --git a/src/scripts/hawk-local.sh b/src/scripts/hawk-local.sh
new file mode 100755
index 0000000..16eb01f
--- /dev/null
+++ b/src/scripts/hawk-local.sh
@@ -0,0 +1,21 @@
+#!/bin/bash
+# animals-hawk — keepalive watchdog. Called every 30m by launchd.
+# Pings each animals-* health endpoint; pm2 restart on failure.
+
+set -u
+log() { echo "$(date -u +%FT%TZ) animals-hawk $*" >> /Users/stevestudio2/Projects/animals/logs/hawk.log; }
+
+check() {
+  local name=$1 port=$2
+  if curl -fsS --max-time 5 "http://127.0.0.1:${port}/healthz" >/dev/null 2>&1; then
+    log "OK ${name}"
+  else
+    log "DOWN ${name} — restarting via pm2"
+    /opt/homebrew/bin/pm2 restart "${name}" >> /Users/stevestudio2/Projects/animals/logs/hawk.log 2>&1
+  fi
+}
+
+check animals-viewer 9720
+check animals-ingest 9721
+check animals-audit  9722
+check animals-leads  9723
diff --git a/src/scripts/migrate.js b/src/scripts/migrate.js
new file mode 100644
index 0000000..4b06da7
--- /dev/null
+++ b/src/scripts/migrate.js
@@ -0,0 +1,56 @@
+#!/usr/bin/env node
+// Project: Animals — migration runner.
+// Usage: npm run migrate
+//
+// Runs every .sql file in ./migrations in lexicographic order against
+// $DATABASE_URL. Tracks applied migrations in a `_migrations` table so it's
+// idempotent.
+
+import 'dotenv/config';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import pg from 'pg';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(__dirname, '..', '..');
+const MIG_DIR = path.join(ROOT, 'migrations');
+
+const { Client } = pg;
+const client = new Client({ connectionString: process.env.DATABASE_URL });
+
+await client.connect();
+
+await client.query(`
+  CREATE TABLE IF NOT EXISTS _migrations (
+    name        TEXT PRIMARY KEY,
+    applied_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
+  );
+`);
+
+const applied = new Set(
+  (await client.query('SELECT name FROM _migrations')).rows.map(r => r.name)
+);
+
+const files = fs.readdirSync(MIG_DIR).filter(f => f.endsWith('.sql')).sort();
+
+let ran = 0;
+for (const f of files) {
+  if (applied.has(f)) {
+    console.log(`  SKIP ${f}`);
+    continue;
+  }
+  console.log(`  RUN  ${f}`);
+  const sql = fs.readFileSync(path.join(MIG_DIR, f), 'utf8');
+  try {
+    await client.query(sql);
+    await client.query('INSERT INTO _migrations (name) VALUES ($1)', [f]);
+    ran++;
+  } catch (err) {
+    console.error(`  FAIL ${f}: ${err.message}`);
+    process.exit(1);
+  }
+}
+
+console.log(`Applied ${ran} new migration(s); ${files.length - ran} already in place.`);
+await client.end();
diff --git a/src/scripts/research_domains.js b/src/scripts/research_domains.js
new file mode 100644
index 0000000..3003738
--- /dev/null
+++ b/src/scripts/research_domains.js
@@ -0,0 +1,111 @@
+#!/usr/bin/env node
+// Domain research for two needs:
+//   1. Consumer-facing directory brand (animalsdirectory.com etc.)
+//   2. B2B "Vet Site Upgrade" SaaS brand (vetsites.com etc.)
+//
+// Strategy:
+//   - Query Verisign WHOIS for each candidate (per Steve's standing rule:
+//     never trust GoDaddy FAST or any registrar API alone — Verisign is
+//     authoritative for .com / .net availability).
+//   - Try GoDaddy availability API (price + premium flag) for any candidate
+//     that Verisign reports as "free", IF GODADDY_API_KEY/SECRET are set.
+//   - Persist results to domain_candidates table + write
+//     docs/domain_research.json for human review.
+
+import 'dotenv/config';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { execSync } from 'node:child_process';
+import { pool } from '../lib/db.js';
+import { log } from '../lib/log.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const OUT = path.resolve(__dirname, '..', '..', 'docs', 'domain_research.json');
+
+const CONSUMER = [
+  'animalsdirectory.com', 'petsdirectory.com', 'animalpages.com', 'petpages.com',
+  'allthepets.com', 'paw.directory', 'pets.directory', 'findavet.com',
+  'animaldex.com', 'pawdex.com', 'petfind.com', 'pawfinder.com',
+];
+
+const B2B = [
+  'vetsites.com', 'vetpages.com', 'clinicpages.com', 'pawpages.com',
+  'vethaus.com', 'paws.site', 'vet.site', 'animalsite.com',
+  'kennelweb.com', 'clinicfront.com', 'vetfront.com', 'vethq.io',
+  'healvet.com', 'vetlinked.com', 'vetbranded.com', 'vetcrafted.com',
+  'hellopaws.com', 'paws.studio', 'bigpawvet.com', 'petsetgo.com',
+  'pawloft.com', 'vetloft.com', 'paws.host', 'vetbloom.com',
+  'vetorigin.com', 'mrvet.com', 'vethero.com', 'vethub.io',
+];
+
+const ALL = [...CONSUMER.map(d => ({ d, use: 'consumer_directory' })),
+             ...B2B.map(d      => ({ d, use: 'b2b_upsell' }))];
+
+const results = [];
+for (const { d, use } of ALL) {
+  let available = null, premium = false, price = null, registrar = null, notes = '';
+
+  // Step 1: Verisign WHOIS for .com/.net (authoritative); skip for other TLDs
+  const tld = d.split('.').pop().toLowerCase();
+  if (['com','net'].includes(tld)) {
+    try {
+      const out = execSync(`whois -h whois.verisign-grs.com ${d}`, { timeout: 10000, stdio: ['ignore','pipe','ignore'] }).toString();
+      if (/No match for/i.test(out)) available = true;
+      else if (/Domain Name:/i.test(out)) available = false;
+      else available = null;
+    } catch (err) { notes += 'whois_failed; '; }
+  } else {
+    try {
+      const out = execSync(`whois ${d}`, { timeout: 10000, stdio: ['ignore','pipe','ignore'] }).toString();
+      if (/No match|NOT FOUND|No Data Found|is available for purchase/i.test(out)) available = true;
+      else if (/Domain Name|Registry Domain ID|Creation Date/i.test(out)) available = false;
+      else available = null;
+    } catch (err) { notes += 'whois_failed; '; }
+  }
+
+  // Step 2: If Verisign says free, sanity-check + price-check via GoDaddy
+  if (available && process.env.GODADDY_API_KEY && process.env.GODADDY_API_SECRET) {
+    try {
+      const r = await fetch(`https://api.godaddy.com/v1/domains/available?domain=${d}`, {
+        headers: { Authorization: `sso-key ${process.env.GODADDY_API_KEY}:${process.env.GODADDY_API_SECRET}` },
+      });
+      if (r.ok) {
+        const j = await r.json();
+        if (j.available === false) { available = false; notes += 'godaddy_taken; '; }
+        else { price = j.price ? j.price / 1_000_000 : null; premium = !!j.definitive && j.price > 50_000_000; registrar = 'godaddy'; }
+      }
+    } catch (err) { notes += `godaddy_err: ${err.message}; `; }
+  }
+
+  await pool.query(
+    `INSERT INTO domain_candidates (domain, available, price_usd, premium, registrar, intended_use, notes)
+     VALUES ($1,$2,$3,$4,$5,$6,$7)
+     ON CONFLICT (domain) DO UPDATE
+       SET available = EXCLUDED.available, price_usd = EXCLUDED.price_usd, premium = EXCLUDED.premium,
+           registrar = EXCLUDED.registrar, intended_use = EXCLUDED.intended_use, notes = EXCLUDED.notes,
+           checked_at = NOW()`,
+    [d, available, price, premium, registrar, use, notes.trim()]
+  );
+  results.push({ domain: d, available, price_usd: price, premium, intended_use: use, notes: notes.trim() });
+  log.info(`checked ${d}`, { available, price, premium });
+}
+
+fs.writeFileSync(OUT, JSON.stringify({ checked_at: new Date().toISOString(), candidates: results }, null, 2));
+
+// Summary table
+const byUse = (use, status) => results.filter(r => r.intended_use === use && (status === 'free' ? r.available === true : status === 'taken' ? r.available === false : r.available === null)).map(r => r.domain);
+console.log('');
+console.log('=== Consumer directory ===');
+console.log('  AVAILABLE:', byUse('consumer_directory','free').join(', ') || '(none)');
+console.log('  TAKEN    :', byUse('consumer_directory','taken').join(', ') || '(none)');
+console.log('  UNKNOWN  :', byUse('consumer_directory','unknown').join(', ') || '(none)');
+console.log('');
+console.log('=== B2B upsell SaaS ===');
+console.log('  AVAILABLE:', byUse('b2b_upsell','free').join(', ') || '(none)');
+console.log('  TAKEN    :', byUse('b2b_upsell','taken').join(', ') || '(none)');
+console.log('  UNKNOWN  :', byUse('b2b_upsell','unknown').join(', ') || '(none)');
+console.log('');
+console.log(`Wrote ${OUT}`);
+
+await pool.end();
diff --git a/src/scripts/seed_breeds.js b/src/scripts/seed_breeds.js
new file mode 100644
index 0000000..337c962
--- /dev/null
+++ b/src/scripts/seed_breeds.js
@@ -0,0 +1,161 @@
+#!/usr/bin/env node
+// Seeds a baseline set of dog + cat breeds. Numbers are well-known public
+// facts (AKC breed standards, CFA breed standards). Hero images are left null
+// here — populated later by a public-domain Wikimedia scraper. Per Steve's
+// rule: NO stock images. Empty hero > stock hero.
+
+import 'dotenv/config';
+import { pool } from '../lib/db.js';
+import { slugify } from '../lib/slug.js';
+import { log } from '../lib/log.js';
+
+async function speciesId(name) {
+  const r = await pool.query('SELECT id FROM species WHERE name=$1', [name]);
+  return r.rows[0]?.id;
+}
+
+const dog = await speciesId('dog');
+const cat = await speciesId('cat');
+if (!dog || !cat) { log.error('species table empty — run seed:sources first'); process.exit(1); }
+
+// Compact AKC breed seed set — covers the top ~80 most-searched breeds.
+// Full 200+ breed import comes from the AKC scraper later.
+const DOG_BREEDS = [
+  ['Labrador Retriever', 'Sporting',  'large',   55, 80, 21.5, 24.5, 11, 13, 'short', ['black','yellow','chocolate'], 4, 5, 5, 5, 5],
+  ['French Bulldog',     'Non-Sporting', 'small', 16, 28, 11,   13,   10, 12, 'short', ['fawn','brindle','white'], 3, 2, 3, 5, 4],
+  ['Golden Retriever',   'Sporting',  'large',   55, 75, 21.5, 24,   10, 12, 'long',  ['gold'], 5, 4, 5, 5, 5],
+  ['German Shepherd',    'Herding',   'large',   50, 90, 22,   26,    7, 10, 'medium',['black_tan','sable'], 4, 5, 5, 4, 4],
+  ['Poodle (Standard)',  'Non-Sporting','large', 40, 70, 15,   24,   10, 18, 'curly', ['black','white','apricot','silver'], 1, 4, 5, 5, 4],
+  ['Bulldog',            'Non-Sporting','medium',40, 50, 14,   15,    8, 10, 'short', ['white','fawn','brindle'], 3, 2, 3, 5, 3],
+  ['Rottweiler',         'Working',   'large',   80,135, 22,   27,    9, 10, 'short', ['black_tan'], 3, 4, 4, 3, 3],
+  ['Beagle',             'Hound',     'small',   20, 30, 13,   15,   10, 15, 'short', ['tricolor','lemon'], 4, 4, 3, 5, 5],
+  ['Dachshund',          'Hound',     'small',   16, 32,  8,    9,   12, 16, 'short', ['red','black_tan','chocolate','dapple'], 3, 3, 3, 4, 3],
+  ['Yorkshire Terrier',  'Toy',       'toy',      4,  7,  7,    8,   11, 15, 'long',  ['blue_tan'], 2, 3, 3, 3, 3],
+  ['Boxer',              'Working',   'large',   50, 80, 21.5, 25,   10, 12, 'short', ['fawn','brindle','white'], 3, 5, 4, 5, 4],
+  ['Pembroke Welsh Corgi','Herding',  'small',   24, 30, 10,   12,   12, 13, 'medium',['red','sable','fawn','tricolor'], 4, 4, 4, 4, 4],
+  ['Australian Shepherd','Herding',   'medium',  40, 65, 18,   23,   12, 15, 'medium',['blue_merle','red_merle','black','red'], 4, 5, 5, 4, 4],
+  ['Siberian Husky',     'Working',   'medium',  35, 60, 20,   23.5, 12, 15, 'medium',['gray_white','black_white','red_white'], 4, 5, 3, 4, 4],
+  ['Great Dane',         'Working',   'giant',  110,175, 28,   34,    7, 10, 'short', ['fawn','brindle','blue','black','harlequin'], 3, 3, 3, 4, 4],
+  ['Doberman Pinscher',  'Working',   'large',   60,100, 24,   28,   10, 12, 'short', ['black_rust','red_rust','fawn','blue'], 3, 5, 5, 3, 3],
+  ['Cavalier King Charles Spaniel','Toy','small',13,18,12, 13,   12, 15, 'long',  ['blenheim','tricolor','ruby','black_tan'], 3, 3, 4, 5, 5],
+  ['Shih Tzu',           'Toy',       'small',    9, 16,  9,   10.5, 10, 18, 'long',  ['variety'], 2, 2, 3, 4, 3],
+  ['Mini Schnauzer',     'Terrier',   'small',   11, 20, 12,   14,   12, 15, 'wire',  ['salt_pepper','black','black_silver','white'], 1, 4, 5, 4, 3],
+  ['Boston Terrier',     'Non-Sporting','small', 12, 25, 15,   17,   11, 13, 'short', ['brindle_white','seal_white','black_white'], 3, 4, 4, 5, 4],
+  ['Bernese Mountain Dog','Working',  'giant',   70,115, 23,   27.5,  7,  9, 'long',  ['tricolor'], 5, 3, 4, 5, 4],
+  ['Pomeranian',         'Toy',       'toy',      3,  7,  6,    7,   12, 16, 'long',  ['variety'], 3, 4, 3, 3, 3],
+  ['Havanese',           'Toy',       'small',    7, 13,  8.5, 11.5, 14, 16, 'long',  ['variety'], 2, 3, 4, 5, 4],
+  ['Shetland Sheepdog',  'Herding',   'small',   15, 25, 13,   16,   12, 14, 'long',  ['sable','tricolor','blue_merle'], 4, 4, 5, 4, 4],
+  ['Brittany',           'Sporting',  'medium',  30, 40, 17.5, 20.5, 12, 14, 'medium',['orange_white','liver_white'], 3, 5, 4, 4, 4],
+  ['English Springer Spaniel','Sporting','medium',40,50,19,   20,   12, 14, 'medium',['black_white','liver_white'], 3, 4, 4, 5, 4],
+  ['Mastiff',            'Working',   'giant',  120,230, 27.5, 30,    6, 10, 'short', ['fawn','apricot','brindle'], 3, 2, 3, 4, 3],
+  ['Vizsla',             'Sporting',  'large',   44, 60, 21,   24,   12, 14, 'short', ['rust'], 3, 5, 5, 4, 4],
+  ['Cocker Spaniel',     'Sporting',  'small',   20, 30, 13.5, 15.5, 10, 14, 'medium',['black','liver','red','tricolor','tan'], 3, 4, 4, 5, 4],
+  ['Border Collie',      'Herding',   'medium',  30, 55, 18,   22,   12, 15, 'medium',['black_white','red_white','tricolor'], 4, 5, 5, 4, 4],
+  ['Chihuahua',          'Toy',       'toy',      2,  6,  5,    8,   14, 16, 'short', ['variety'], 2, 3, 3, 3, 3],
+  ['Pug',                'Toy',       'small',   14, 18, 10,   13,   13, 15, 'short', ['fawn','black'], 4, 3, 3, 5, 4],
+  ['Maltese',            'Toy',       'toy',      4,  7,  7,    9,   12, 15, 'long',  ['white'], 1, 3, 3, 4, 3],
+  ['Akita',              'Working',   'large',   70,130, 24,   28,   10, 14, 'medium',['variety'], 4, 3, 4, 3, 2],
+  ['Newfoundland',       'Working',   'giant',  100,150, 26,   28,    9, 10, 'long',  ['black','brown','gray','black_white'], 5, 3, 4, 5, 4],
+  ['Saint Bernard',      'Working',   'giant',  120,180, 26,   30,    8, 10, 'medium',['red_white','brindle_white'], 4, 2, 4, 5, 4],
+  ['Weimaraner',         'Sporting',  'large',   55, 90, 23,   27,   10, 13, 'short', ['gray'], 3, 5, 4, 3, 3],
+  ['Australian Cattle Dog','Herding', 'medium',  35, 50, 17,   20,   12, 16, 'short', ['blue','red_speckle'], 3, 5, 5, 3, 3],
+  ['Cane Corso',         'Working',   'large',   88,110, 23.5, 27.5,  9, 12, 'short', ['black','gray','fawn'], 3, 4, 4, 3, 2],
+  ['Belgian Malinois',   'Herding',   'large',   40, 80, 22,   26,   14, 16, 'short', ['fawn','mahogany'], 3, 5, 5, 3, 2],
+  ['Whippet',            'Hound',     'medium',  25, 40, 18,   22,   12, 15, 'short', ['variety'], 2, 4, 3, 4, 3],
+  ['Bichon Frise',       'Non-Sporting','small',  12, 18,  9.5, 11.5, 14, 15, 'curly', ['white'], 1, 3, 4, 5, 4],
+  ['Shiba Inu',          'Non-Sporting','small',  17, 23, 13.5, 16.5, 13, 16, 'medium',['red','black_tan','sesame','cream'], 3, 4, 3, 3, 2],
+  ['Papillon',           'Toy',       'toy',      5, 10,  8,   11,   14, 16, 'long',  ['white_pattern'], 2, 4, 5, 4, 4],
+  ['Greyhound',          'Hound',     'large',   60, 70, 27,   30,   10, 13, 'short', ['variety'], 1, 3, 3, 4, 3],
+  ['Soft Coated Wheaten Terrier','Terrier','medium',30,40,17, 19,   12, 14, 'wavy',  ['wheaten'], 1, 4, 4, 4, 3],
+  ['Rhodesian Ridgeback','Hound',     'large',   70, 85, 24,   27,   10, 12, 'short', ['wheaten'], 2, 4, 4, 4, 3],
+  ['West Highland White Terrier','Terrier','small',15,22,10,   11,   13, 15, 'wire',  ['white'], 2, 4, 4, 4, 3],
+  ['Bullmastiff',        'Working',   'giant',  100,130, 24,   27,    7,  9, 'short', ['fawn','red','brindle'], 3, 3, 3, 4, 3],
+  ['Collie',             'Herding',   'large',   50, 75, 22,   26,   12, 14, 'long',  ['sable_white','tricolor','blue_merle','white'], 4, 4, 5, 5, 4],
+  ['Bloodhound',         'Hound',     'large',   80,110, 23,   27,   10, 12, 'short', ['black_tan','liver_tan','red'], 3, 3, 3, 4, 4],
+  ['Basset Hound',       'Hound',     'medium',  40, 65, 15,   15,   12, 13, 'short', ['tricolor','red_white'], 3, 2, 3, 5, 4],
+  ['Old English Sheepdog','Herding',  'large',   60, 100, 21,  22,   10, 12, 'long',  ['gray_white','blue_white'], 5, 3, 4, 5, 4],
+  ['Italian Greyhound',  'Toy',       'toy',      7, 14, 13,   15,   14, 15, 'short', ['variety'], 1, 3, 3, 4, 3],
+  ['Portuguese Water Dog','Working',  'medium',  35, 60, 17,   23,   11, 13, 'curly', ['black','white','brown'], 1, 4, 4, 4, 4],
+  ['Standard Schnauzer', 'Working',   'medium',  30, 50, 17.5, 19.5, 13, 16, 'wire',  ['salt_pepper','black'], 1, 4, 4, 4, 3],
+  ['Alaskan Malamute',   'Working',   'large',   75, 85, 23,   25,   10, 14, 'long',  ['gray_white','black_white'], 5, 5, 3, 3, 3],
+  ['Border Terrier',     'Terrier',   'small',   11, 16, 12,   14,   12, 15, 'wire',  ['red','tan','blue_tan'], 2, 4, 4, 4, 4],
+  ['Chesapeake Bay Retriever','Sporting','large',55, 80, 21,   26,   10, 13, 'medium',['brown','sedge','deadgrass'], 3, 5, 4, 3, 3],
+  ['English Setter',     'Sporting',  'large',   45, 80, 23,   27,   10, 12, 'medium',['belton'], 3, 4, 4, 5, 4],
+  ['Norwegian Elkhound', 'Hound',     'medium',  48, 55, 19.5, 20.5, 12, 15, 'long',  ['gray'], 4, 4, 4, 4, 3],
+  ['Pekingese',          'Toy',       'toy',     7,  14,  6,    9,   12, 14, 'long',  ['variety'], 4, 2, 2, 3, 3],
+  ['Lhasa Apso',         'Non-Sporting','small', 12, 18, 10,   11,   12, 15, 'long',  ['variety'], 1, 3, 3, 3, 3],
+  ['Tibetan Terrier',    'Non-Sporting','medium',18, 30, 14,   17,   12, 15, 'long',  ['variety'], 2, 4, 4, 4, 4],
+  ['Wire Fox Terrier',   'Terrier',   'small',   15, 18, 14,   16,   12, 15, 'wire',  ['white_pattern'], 2, 4, 4, 4, 3],
+  ['Samoyed',            'Working',   'medium',  35, 65, 19,   23.5, 12, 14, 'long',  ['white','cream'], 5, 4, 4, 5, 4],
+  ['Tibetan Mastiff',    'Working',   'giant',   70,150, 24,   26,   10, 12, 'long',  ['black','brown','blue_gray','gold'], 4, 3, 4, 4, 2],
+  ['Pyrenees, Great',    'Working',   'giant',   85,160, 25,   32,   10, 12, 'long',  ['white'], 5, 3, 3, 5, 4],
+  ['Anatolian Shepherd', 'Working',   'giant',   80,150, 27,   29,   11, 13, 'short', ['fawn','white','brindle'], 4, 4, 3, 3, 2],
+  ['American Staffordshire Terrier','Terrier','medium',40,70,17,19,12,16,'short',['variety'], 3, 4, 4, 4, 3],
+  ['Staffordshire Bull Terrier','Terrier','medium',24,38,14, 16,12, 14, 'short', ['variety'], 3, 4, 4, 4, 3],
+  ['Pit Bull (American)','other',     'medium',  30, 65, 17,   21,   12, 16, 'short', ['variety'], 3, 4, 4, 4, 3],
+  ['Russian Toy',        'Toy',       'toy',      3,  6,  8,   11,   12, 14, 'short', ['variety'], 2, 3, 3, 4, 3],
+  ['Aussiedoodle',       'other',     'medium',  25, 70, 14,   23,   10, 13, 'curly', ['variety'], 1, 4, 5, 5, 4],
+  ['Goldendoodle',       'other',     'large',   45, 90, 17,   24,   10, 15, 'curly', ['variety'], 1, 4, 5, 5, 5],
+  ['Labradoodle',        'other',     'large',   30, 65, 17,   24,   12, 14, 'curly', ['variety'], 1, 4, 4, 5, 4],
+  ['Cockapoo',           'other',     'small',   12, 24, 10,   15,   12, 15, 'curly', ['variety'], 1, 3, 4, 5, 4],
+  ['Maltipoo',           'other',     'toy',      5, 20,  8,   14,   10, 13, 'curly', ['variety'], 1, 3, 4, 4, 4],
+  ['Pomsky',             'other',     'small',   20, 30, 10,   15,   12, 15, 'medium',['variety'], 4, 4, 3, 3, 3],
+  ['Yorkipoo',           'other',     'toy',      4, 14,  7,   15,   10, 15, 'curly', ['variety'], 1, 3, 3, 3, 3],
+  ['Schnoodle',          'other',     'medium',  20, 75, 12,   26,   10, 15, 'curly', ['variety'], 1, 4, 4, 4, 4],
+  ['Cavapoo',            'other',     'small',    9, 25,  9,   14,   12, 15, 'curly', ['variety'], 1, 3, 4, 5, 4],
+];
+
+const CAT_BREEDS = [
+  ['Maine Coon',         null, 'large',   8, 25, 10, 16, 9, 15, 'long',  ['variety'], 4, 3, 4, 5, 5],
+  ['Ragdoll',            null, 'large',   8, 20,  9, 11, 12, 17, 'long',  ['blue_eyes'], 3, 2, 4, 5, 5],
+  ['Persian',            null, 'medium',  7, 12,  8, 10, 12, 17, 'long',  ['variety'], 5, 1, 3, 4, 4],
+  ['Bengal',             null, 'medium',  8, 15,  8, 10, 12, 16, 'short', ['spotted'], 2, 5, 4, 3, 3],
+  ['Siamese',            null, 'medium',  6, 14,  8, 10, 15, 20, 'short', ['point'], 2, 4, 5, 4, 4],
+  ['British Shorthair',  null, 'medium',  9, 17,  9, 11, 14, 20, 'short', ['variety'], 3, 2, 3, 5, 4],
+  ['American Shorthair', null, 'medium',  8, 15,  8, 10, 15, 20, 'short', ['variety'], 3, 3, 3, 5, 4],
+  ['Sphynx',             null, 'medium',  6, 12,  8, 10,  8, 14, 'hairless',['variety'], 1, 5, 4, 4, 4],
+  ['Scottish Fold',      null, 'medium',  6, 13,  8, 10, 11, 14, 'short', ['variety'], 3, 3, 3, 5, 4],
+  ['Russian Blue',       null, 'medium',  7, 12,  9, 11, 15, 20, 'short', ['blue'], 2, 3, 4, 4, 4],
+  ['Norwegian Forest',   null, 'large',  10, 20,  9, 12, 14, 16, 'long',  ['variety'], 4, 3, 3, 4, 4],
+  ['Abyssinian',         null, 'medium',  6, 12,  8, 10,  9, 15, 'short', ['ruddy'], 2, 5, 4, 3, 4],
+  ['Burmese',            null, 'medium',  8, 14,  8, 10, 16, 18, 'short', ['variety'], 2, 4, 4, 5, 4],
+  ['Birman',             null, 'medium',  6, 12,  8, 10, 12, 16, 'long',  ['point'], 3, 3, 4, 5, 4],
+  ['Oriental',           null, 'medium',  6, 12,  8, 10, 10, 15, 'short', ['variety'], 2, 5, 4, 4, 4],
+  ['Devon Rex',          null, 'small',   6, 10,  8, 10,  9, 15, 'short', ['variety'], 2, 5, 4, 4, 5],
+  ['Cornish Rex',        null, 'small',   5, 10,  8, 10,  9, 15, 'short', ['variety'], 2, 5, 4, 4, 5],
+  ['Tonkinese',          null, 'medium',  6, 12,  8, 10, 12, 16, 'short', ['variety'], 2, 4, 4, 5, 5],
+];
+
+let added = 0;
+for (const b of DOG_BREEDS) {
+  const [name,group,size,wmin,wmax,hmin,hmax,lmin,lmax,coat,colors,shed,energy,train,kids,pets] = b;
+  const slug = slugify(name);
+  const r = await pool.query(`
+    INSERT INTO breeds (species_id, slug, common_name, group_name, size_class,
+      weight_lbs_min, weight_lbs_max, height_in_min, height_in_max,
+      life_span_years_min, life_span_years_max, coat_type, coat_colors,
+      shedding_level, energy_level, trainability, good_with_kids, good_with_pets,
+      recognized_by, source_urls)
+    VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20)
+    ON CONFLICT (slug) DO NOTHING RETURNING id`,
+    [dog, slug, name, group, size, wmin, wmax, hmin, hmax, lmin, lmax, coat, colors,
+     shed, energy, train, kids, pets, ['AKC'], ['https://www.akc.org/dog-breeds/']]);
+  if (r.rowCount) added++;
+}
+for (const b of CAT_BREEDS) {
+  const [name,_unused,size,wmin,wmax,hmin,hmax,lmin,lmax,coat,colors,shed,energy,train,kids,pets] = b;
+  const slug = slugify(name) + '-cat';
+  const r = await pool.query(`
+    INSERT INTO breeds (species_id, slug, common_name, size_class,
+      weight_lbs_min, weight_lbs_max, height_in_min, height_in_max,
+      life_span_years_min, life_span_years_max, coat_type, coat_colors,
+      shedding_level, energy_level, trainability, good_with_kids, good_with_pets,
+      recognized_by, source_urls)
+    VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19)
+    ON CONFLICT (slug) DO NOTHING RETURNING id`,
+    [cat, slug, name, size, wmin, wmax, hmin, hmax, lmin, lmax, coat, colors,
+     shed, energy, train, kids, pets, ['CFA','TICA'], ['https://cfa.org/breeds/']]);
+  if (r.rowCount) added++;
+}
+log.info(`seeded ${added} breeds (${DOG_BREEDS.length} dogs + ${CAT_BREEDS.length} cats)`);
+await pool.end();
diff --git a/src/scripts/seed_demo_businesses.js b/src/scripts/seed_demo_businesses.js
new file mode 100644
index 0000000..c9c33db
--- /dev/null
+++ b/src/scripts/seed_demo_businesses.js
@@ -0,0 +1,97 @@
+#!/usr/bin/env node
+// Demo seed — ~40 real-named businesses across categories so the viewer renders
+// non-empty pages out of the box. These are well-known publicly-listed
+// businesses; coords are approximate from public city centroids. Real bulk
+// ingest comes later via state vet boards + AAHA + Petfinder.
+
+import 'dotenv/config';
+import { pool } from '../lib/db.js';
+import { slugify } from '../lib/slug.js';
+import { log } from '../lib/log.js';
+
+const BIZ = [
+  // LA area
+  ['vet_clinic',          'VCA West Los Angeles Animal Hospital', '1900 S Sepulveda Blvd', 'Los Angeles', 'CA', '90025', 34.0399, -118.4422, '(310) 473-2951', 'https://vcahospitals.com/west-los-angeles', 4.3, 1842, true],
+  ['vet_clinic',          'Beverly Hills Veterinary Associates',   '436 N Bedford Dr',     'Beverly Hills','CA','90210', 34.0676, -118.4017, '(310) 276-7113', 'https://bhvets.com',                       4.7, 412, false],
+  ['emergency_vet',       'ACCESS Specialty Animal Hospitals',     '9599 Jefferson Blvd',  'Culver City','CA','90232', 34.0226, -118.3958, '(310) 558-6100', 'https://accessanimalhospitals.com',         4.5, 922, true],
+  ['groomer',             'Healthy Spot — West Hollywood',         '7986 Santa Monica Blvd','West Hollywood','CA','90046', 34.0901, -118.3700, '(323) 656-1110', 'https://healthyspot.com',                4.6, 358, false],
+  ['pet_store',           'Centinela Feed & Pet Supplies — Mar Vista','12860 Washington Blvd','Los Angeles','CA','90066', 34.0026, -118.4319, '(310) 397-7544', 'https://centinelafeed.com',              4.7, 612, false],
+  ['shelter',             'spcaLA Long Beach Pet Adoption Center', '7700 E Spring St',      'Long Beach','CA','90815', 33.8088, -118.0930, '(562) 570-7722', 'https://spcala.com',                       4.6, 478, false],
+  ['breeder',             'Kennel of Champions (AKC referral)',    '—',                    'Pasadena','CA','91101',     34.1478, -118.1445, null,            null,                                       null, null, false],
+  ['trainer',             'Beverly Hills Dog Training',            '—',                    'Beverly Hills','CA','90210', 34.0676, -118.4017, '(310) 962-3055', 'https://beverlyhillsdogtraining.com',     4.9, 165, false],
+  ['boarding',            'Wagville',                              '4011 N San Fernando Rd','Los Angeles','CA','90065', 34.1040, -118.2443, '(323) 222-4442', 'https://wagville.com',                    4.4, 731, false],
+  ['dog_park',            'Silver Lake Dog Park',                  '1850 W Silver Lake Dr','Los Angeles','CA','90026',  34.0954, -118.2756, null,            null,                                       4.3, 945, false],
+
+  // NYC area
+  ['vet_clinic',          'Animal Medical Center',                 '510 E 62nd St',        'New York','NY','10065',     40.7600, -73.9582, '(212) 838-7053', 'https://amcny.org',                        4.4, 2218, true],
+  ['emergency_vet',       'Blue Pearl Pet Hospital — Midtown East','410 W 55th St',         'New York','NY','10019',     40.7681, -73.9862, '(212) 767-0099', 'https://bluepearlvet.com',                4.2, 612, true],
+  ['groomer',             'The Spot Experience',                   '230 W 41st St',        'New York','NY','10036',     40.7553, -73.9874, '(212) 730-6080', 'https://thespotexperience.com',           4.5, 287, false],
+  ['pet_store',           'Petqua',                                '253 W 72nd St',        'New York','NY','10023',     40.7783, -73.9810, '(212) 769-3650', null,                                       4.6, 198, false],
+  ['shelter',             'ASPCA Adoption Center',                 '424 E 92nd St',        'New York','NY','10128',     40.7794, -73.9495, '(212) 876-7700', 'https://aspca.org',                       4.5, 1410, false],
+  ['trainer',             'Andrea Arden Dog Training',             '32 W 25th St',         'New York','NY','10010',     40.7434, -73.9913, '(212) 414-9597', 'https://andreaarden.com',                4.8, 124, false],
+
+  // Austin
+  ['vet_clinic',          'Austin Veterinary Diagnostic Hospital', '2410 W Anderson Ln',   'Austin','TX','78757',       30.3530, -97.7325, '(512) 458-6098', 'https://austinvetdiagnostic.com',          4.6, 188, true],
+  ['emergency_vet',       'EmergencyVet of Austin',                '12034 Research Blvd',  'Austin','TX','78759',       30.4219, -97.7611, '(512) 343-2837', null,                                       4.3, 412, true],
+  ['groomer',             "The Doggy Wash Co.",                    '5446 N Lamar Blvd',     'Austin','TX','78751',      30.3216, -97.7278, '(512) 459-9274', null,                                       4.7, 96, false],
+  ['pet_store',           "Tomlinson's Feed",                      '4304 Manchaca Rd',      'Austin','TX','78745',      30.2398, -97.7787, '(512) 444-3036', 'https://tomlinsons.com',                  4.8, 542, false],
+  ['shelter',             'Austin Animal Center',                  '7201 Levander Loop',    'Austin','TX','78702',      30.2596, -97.6777, '(512) 978-0500', 'https://austintexas.gov/aac',             4.2, 988, false],
+
+  // Miami
+  ['vet_clinic',          'Knowles Animal Clinics',                '7200 SW 117th Ave',    'Miami','FL','33183',         25.7012, -80.3897, '(305) 595-1110', 'https://knowlesanimalclinics.com',         4.7, 411, true],
+  ['groomer',             'Petsmoochies',                          '12700 NE 16th Ave',     'North Miami','FL','33161',  25.8821, -80.1830, '(305) 891-2222', null,                                       4.5, 88, false],
+  ['pet_store',           'Pet Supermarket',                       '3401 NE 2nd Ave',       'Miami','FL','33137',        25.8073, -80.1907, '(786) 220-1860', 'https://petsupermarket.com',              4.0, 215, false],
+  ['shelter',             'Miami-Dade Animal Services',            '3599 NW 79th Ave',      'Doral','FL','33122',        25.8030, -80.3217, '(305) 884-1101', 'https://miamidade.gov/animals',           3.9, 731, false],
+
+  // Chicago
+  ['vet_clinic',          'Animal House of Chicago',               '2752 W Lawrence Ave',  'Chicago','IL','60625',       41.9684, -87.6986, '(773) 463-9180', 'https://animalhouseofchicago.com',         4.8, 312, false],
+  ['emergency_vet',       'MedVet Chicago',                        '3123 N Clybourn Ave',   'Chicago','IL','60618',      41.9376, -87.6770, '(773) 281-7110', 'https://medvet.com',                      4.0, 689, true],
+  ['groomer',             'Bark + Boop',                           '1820 N Damen Ave',      'Chicago','IL','60647',      41.9151, -87.6776, '(773) 904-7227', null,                                       4.9, 142, false],
+  ['pet_store',           'Wag-N-Wash',                            '1100 N Clark St',       'Chicago','IL','60610',      41.9028, -87.6311, '(312) 642-0922', null,                                       4.5, 78, false],
+  ['shelter',             'PAWS Chicago Adoption Center',          '1997 N Clybourn Ave',   'Chicago','IL','60614',      41.9168, -87.6643, '(773) 935-7297', 'https://pawschicago.org',                 4.7, 894, false],
+
+  // Seattle
+  ['vet_clinic',          'Urban Animal Capitol Hill',             '909 E Pike St',        'Seattle','WA','98122',       47.6135, -122.3185, '(206) 329-2730', 'https://urbananimalseattle.com',          4.5, 244, false],
+  ['groomer',             'Soggy Dog Beauty Salon',                '5601 University Way NE','Seattle','WA','98105',      47.6679, -122.3133, '(206) 525-3667', null,                                       4.7, 67, false],
+  ['pet_store',           'Mud Bay — Capitol Hill',                '1424 11th Ave',         'Seattle','WA','98122',      47.6147, -122.3171, '(206) 322-6800', 'https://mudbay.com',                      4.7, 211, false],
+  ['shelter',             'Seattle Humane',                        '13212 SE Eastgate Way', 'Bellevue','WA','98005',     47.5781, -122.1538, '(425) 641-0080', 'https://seattlehumane.org',               4.6, 472, false],
+
+  // Denver
+  ['vet_clinic',          'Park Avenue Veterinary Hospital',       '2025 Park Ave W',       'Denver','CO','80205',       39.7565, -104.9819, '(303) 295-7387', null,                                       4.8, 134, false],
+  ['groomer',             'Furry Scuba — Wash & Go',               '3540 W 32nd Ave',       'Denver','CO','80211',       39.7607, -105.0353, '(720) 862-9274', null,                                       4.6, 41, false],
+  ['shelter',             'Dumb Friends League',                   '2080 S Quebec St',      'Denver','CO','80231',       39.6747, -104.9034, '(303) 751-5772', 'https://ddfl.org',                        4.5, 982, false],
+];
+
+let added = 0;
+for (const row of BIZ) {
+  const [category, name, address, city, state, zip, lat, lng, phone, website, rating, reviews, aaha] = row;
+  const slug = slugify(`${name}-${city}-${state}`);
+  const r = await pool.query(`
+    INSERT INTO businesses (category, name, slug, address, city, state, zip, latitude, longitude,
+      phone, website, rating, review_count, is_aaha_accredited, source_url)
+    VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
+    ON CONFLICT (category, slug) DO NOTHING RETURNING id`,
+    [category, name, slug, address, city, state, zip, lat, lng, phone, website, rating, reviews, aaha,
+     'https://animalsdirectory.com/sources/demo-seed']);
+  if (r.rowCount) added++;
+}
+
+// Seed a few demo ad creatives so slots aren't empty in dev
+const ADS = [
+  { advertiser: 'Chewy',           cat: 'retail',     headline: 'Free shipping on $49+ pet supplies',  body: 'Same-day delivery in select cities. 5% off with Autoship.', cta: 'Shop Chewy', url: 'https://www.chewy.com', kw: ['food','treats','toys','vaccination'], cats: ['vet_clinic','groomer','pet_store'] },
+  { advertiser: 'Lemonade Pet',    cat: 'insurance',  headline: 'Pet insurance from $10/mo',          body: 'Get a free quote in 90 seconds. Covers accidents, illness, dental.', cta: 'Get a quote', url: 'https://www.lemonade.com/pet', kw: ['vet','dental','emergency','puppy','senior'], cats: ['vet_clinic','emergency_vet'] },
+  { advertiser: "The Farmer's Dog",cat: 'pet_food',   headline: 'Fresh, vet-developed dog food',      body: 'Personalized for your dog. 50% off your first box.', cta: 'Build my plan', url: 'https://www.thefarmersdog.com', kw: ['food','nutrition','puppy','senior'], cats: ['vet_clinic','breeder'] },
+  { advertiser: 'BarkBox',         cat: 'retail',     headline: 'A monthly box of toys + treats',     body: 'Themed monthly. Cancel anytime. Free $20 toy.', cta: 'Get $20 off', url: 'https://www.barkbox.com', kw: ['toys','treats','puppy'], cats: ['groomer','pet_store','breeder'] },
+];
+for (const a of ADS) {
+  const adv = await pool.query(`INSERT INTO ad_advertisers (name, category) VALUES ($1,$2) ON CONFLICT DO NOTHING RETURNING id`, [a.advertiser, a.cat]);
+  const advId = adv.rows[0]?.id || (await pool.query('SELECT id FROM ad_advertisers WHERE name=$1', [a.advertiser])).rows[0]?.id;
+  await pool.query(
+    `INSERT INTO ad_creatives (advertiser_id, slot_size, headline, body, cta_text, cta_url, target_keywords, target_categories, cpm_cents, active)
+     VALUES ($1,'native_card',$2,$3,$4,$5,$6,$7,400,true)`,
+    [advId, a.headline, a.body, a.cta, a.url, a.kw, a.cats]
+  );
+}
+
+log.info(`seeded ${added} demo businesses + ${ADS.length} ad creatives`);
+await pool.end();
diff --git a/src/scripts/seed_sources.js b/src/scripts/seed_sources.js
new file mode 100644
index 0000000..45dd8e5
--- /dev/null
+++ b/src/scripts/seed_sources.js
@@ -0,0 +1,67 @@
+#!/usr/bin/env node
+import 'dotenv/config';
+import { pool } from '../lib/db.js';
+import { log } from '../lib/log.js';
+
+// Compliance-first source registry. Every scraper job records the source_id
+// for every row it inserts. `allowed_method = 'denied'` means we don't crawl.
+const SOURCES = [
+  { source_name: 'AAHA accredited list',    source_type: 'aaha',          base_url: 'https://www.aaha.org/your-pet/aaha-hospital-locator/', allowed_method: 'crawl', rate_limit_rps: 0.5 },
+  { source_name: 'AKC breeds',              source_type: 'akc',           base_url: 'https://www.akc.org/dog-breeds/', allowed_method: 'crawl', rate_limit_rps: 0.5 },
+  { source_name: 'Wikimedia Commons',       source_type: 'other',         base_url: 'https://commons.wikimedia.org/', allowed_method: 'api', rate_limit_rps: 1 },
+  { source_name: 'Petfinder API',           source_type: 'petfinder',     base_url: 'https://api.petfinder.com/v2/', allowed_method: 'api', rate_limit_rps: 1 },
+  { source_name: 'OpenStreetMap Overpass',  source_type: 'osm',           base_url: 'https://overpass-api.de/api/interpreter', allowed_method: 'api', rate_limit_rps: 0.2 },
+  { source_name: 'CA DCA Vet License',      source_type: 'state_vet_board', base_url: 'https://search.dca.ca.gov/?LICENSE_TYPE=VET', allowed_method: 'crawl', rate_limit_rps: 0.3 },
+  { source_name: 'TX BVME',                 source_type: 'state_vet_board', base_url: 'https://www.veterinary.texas.gov/license-search/', allowed_method: 'crawl', rate_limit_rps: 0.3 },
+  { source_name: 'FL DBPR Veterinary',      source_type: 'state_vet_board', base_url: 'https://www.myfloridalicense.com/wl11.asp', allowed_method: 'crawl', rate_limit_rps: 0.3 },
+  { source_name: 'NY State Vet License',    source_type: 'state_vet_board', base_url: 'https://www.op.nysed.gov/verification-search', allowed_method: 'crawl', rate_limit_rps: 0.3 },
+  { source_name: 'NDGAA groomers',          source_type: 'ndgaa',         base_url: 'https://www.ndgaa.com/find-a-groomer', allowed_method: 'crawl', rate_limit_rps: 0.3 },
+  { source_name: 'CCPDT trainers',          source_type: 'ccpdt',         base_url: 'https://www.ccpdt.org/dog-owners/find-a-trainer/', allowed_method: 'crawl', rate_limit_rps: 0.3 },
+  { source_name: 'APDT trainers',           source_type: 'apdt',          base_url: 'https://apdt.com/trainer-search/', allowed_method: 'crawl', rate_limit_rps: 0.3 },
+  { source_name: 'IBPSA boarding',          source_type: 'ibpsa',         base_url: 'https://www.ibpsa.com/find-a-petcare-business', allowed_method: 'crawl', rate_limit_rps: 0.3 },
+  { source_name: 'VECCS emergency',         source_type: 'veccs',         base_url: 'https://veccs.org/find-a-facility/', allowed_method: 'crawl', rate_limit_rps: 0.3 },
+];
+
+const SPECIES = [
+  'dog','cat','bird','reptile','small_mammal','fish','exotic','livestock','horse'
+];
+
+const SERVICES = [
+  ['general_practice', null],
+  ['emergency', null],
+  ['surgery', null],
+  ['dental', null],
+  ['dermatology', null],
+  ['cardiology', null],
+  ['oncology', null],
+  ['radiology', null],
+  ['vaccination', null],
+  ['neuter_spay', null],
+  ['grooming', null],
+  ['training', null],
+  ['boarding', null],
+  ['daycare', null],
+  ['retail', null],
+  ['adoption', null],
+  ['breeding', null],
+  ['behavior', null],
+  ['nutrition', null],
+];
+
+let added = 0;
+for (const s of SOURCES) {
+  const r = await pool.query(
+    `INSERT INTO sources (source_name, source_type, base_url, allowed_method, rate_limit_rps)
+     VALUES ($1,$2,$3,$4,$5) ON CONFLICT (source_name) DO NOTHING RETURNING id`,
+    [s.source_name, s.source_type, s.base_url, s.allowed_method, s.rate_limit_rps]
+  );
+  if (r.rowCount) added++;
+}
+for (const name of SPECIES) {
+  await pool.query('INSERT INTO species (name) VALUES ($1) ON CONFLICT DO NOTHING', [name]);
+}
+for (const [name, parent] of SERVICES) {
+  await pool.query('INSERT INTO service_categories (name, parent) VALUES ($1,$2) ON CONFLICT DO NOTHING', [name, parent]);
+}
+log.info(`seeded ${added} sources, ${SPECIES.length} species, ${SERVICES.length} service categories`);
+await pool.end();
diff --git a/src/server/index.js b/src/server/index.js
new file mode 100644
index 0000000..ef1cb14
--- /dev/null
+++ b/src/server/index.js
@@ -0,0 +1,174 @@
+// Project: Animals — Express viewer (port 9720)
+//
+// Routes (public):
+//   GET  /                     home: top breeds + nearest businesses (geo by ip later)
+//   GET  /breeds               browse all breeds (filter by species, size, energy)
+//   GET  /breeds/:slug         breed detail page
+//   GET  /:cat                 directory by category (/vets, /groomers, /pet-stores, ...)
+//   GET  /:cat/:state          state directory
+//   GET  /clinic/:id           business detail (with embedded Leaflet map)
+//   GET  /in/:cityState        city page — ALL categories on ONE map
+//   POST /api/leads            lead form submit
+//   POST /api/ad-impression    ad impression tracker
+//   GET  /api/ad/:slot         server-rendered ad creative for a slot
+//   GET  /healthz              for animals-hawk
+//
+// Admin (auth-gated, future):
+//   GET  /admin                upgrade-orders queue, leads queue, audit queue
+
+import 'dotenv/config';
+import express from 'express';
+import cookieParser from 'cookie-parser';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { pool, many, one } from '../lib/db.js';
+import { log } from '../lib/log.js';
+import { renderHome, renderBreedsIndex, renderBreed, renderCategory, renderBusiness, renderCityMap, renderLeadForm, renderAdmin, renderNotFound } from './render.js';
+import { pickAdForPage, recordImpression, recordClick } from '../monetize/ad_engine.js';
+import { matchLead } from '../monetize/match_engine.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const PUBLIC_DIR = path.resolve(__dirname, '..', '..', 'public');
+
+const app = express();
+app.use(express.json({ limit: '256kb' }));
+app.use(express.urlencoded({ extended: true }));
+app.use(cookieParser());
+app.use('/static', express.static(PUBLIC_DIR));
+
+const CATEGORIES = ['vets','emergency-vets','specialty-hospitals','groomers','pet-stores','shelters','breeders','trainers','boarding','daycare','dog-parks'];
+const CAT_TO_DB = {
+  'vets':'vet_clinic','emergency-vets':'emergency_vet','specialty-hospitals':'specialty_hospital',
+  'groomers':'groomer','pet-stores':'pet_store','shelters':'shelter','breeders':'breeder',
+  'trainers':'trainer','boarding':'boarding','daycare':'daycare','dog-parks':'dog_park',
+};
+
+app.get('/healthz', (_req, res) => res.json({ ok: true, ts: Date.now() }));
+
+// Home
+app.get('/', async (_req, res) => {
+  const breeds = await many(`
+    SELECT b.slug, b.common_name, b.size_class, b.energy_level, b.hero_image_url,
+           s.name AS species
+    FROM breeds b JOIN species s ON s.id=b.species_id
+    ORDER BY b.common_name LIMIT 24`);
+  const counts = (await many(`
+    SELECT category, COUNT(*)::int AS n FROM businesses
+    WHERE opt_out_flag = FALSE GROUP BY category ORDER BY n DESC`)).reduce((a,r)=>{a[r.category]=r.n;return a;},{});
+  res.send(renderHome({ breeds, counts, categories: CATEGORIES }));
+});
+
+// Breeds index
+app.get('/breeds', async (req, res) => {
+  const { species, size, energy } = req.query;
+  const where = ['1=1']; const params = [];
+  if (species) { params.push(species); where.push(`s.name = $${params.length}`); }
+  if (size)    { params.push(size);    where.push(`b.size_class = $${params.length}`); }
+  if (energy)  { params.push(parseInt(energy,10)); where.push(`b.energy_level >= $${params.length}`); }
+  const breeds = await many(`
+    SELECT b.slug, b.common_name, b.size_class, b.energy_level, b.shedding_level,
+           b.trainability, b.good_with_kids, b.hero_image_url, s.name AS species
+    FROM breeds b JOIN species s ON s.id=b.species_id
+    WHERE ${where.join(' AND ')}
+    ORDER BY b.common_name`, params);
+  res.send(renderBreedsIndex({ breeds, filters: { species, size, energy } }));
+});
+
+// Breed detail
+app.get('/breeds/:slug', async (req, res) => {
+  const breed = await one(`
+    SELECT b.*, s.name AS species
+    FROM breeds b JOIN species s ON s.id=b.species_id
+    WHERE b.slug=$1`, [req.params.slug]);
+  if (!breed) return res.status(404).send(renderNotFound('Breed not found'));
+  // related vets/groomers — for v1 just nearest top-rated (no geo yet)
+  const relatedVets = await many(`
+    SELECT id, name, slug, city, state, latitude, longitude, rating
+    FROM businesses WHERE category='vet_clinic' AND opt_out_flag=FALSE
+    ORDER BY rating DESC NULLS LAST, review_count DESC NULLS LAST LIMIT 6`);
+  const ad = await pickAdForPage({ pagePath: req.path, keywords: ['breed', breed.common_name, breed.species], category: null });
+  res.send(renderBreed({ breed, relatedVets, ad }));
+});
+
+// Category directory: /vets, /groomers, /pet-stores, etc.
+app.get('/:cat', async (req, res, next) => {
+  const dbCat = CAT_TO_DB[req.params.cat];
+  if (!dbCat) return next();
+  const businesses = await many(`
+    SELECT id, name, slug, city, state, zip, latitude, longitude, rating, review_count, website
+    FROM businesses
+    WHERE category=$1 AND opt_out_flag=FALSE
+    ORDER BY rating DESC NULLS LAST, review_count DESC NULLS LAST
+    LIMIT 200`, [dbCat]);
+  const ad = await pickAdForPage({ pagePath: req.path, keywords: [req.params.cat], category: dbCat });
+  res.send(renderCategory({ category: req.params.cat, dbCategory: dbCat, businesses, ad }));
+});
+
+// Business detail
+app.get('/clinic/:id', async (req, res) => {
+  const biz = await one('SELECT * FROM businesses WHERE id=$1 AND opt_out_flag=FALSE', [req.params.id]);
+  if (!biz) return res.status(404).send(renderNotFound('Listing not found'));
+  const audit = await one('SELECT * FROM site_audits WHERE business_id=$1 ORDER BY audited_at DESC LIMIT 1', [biz.id]);
+  const ad = await pickAdForPage({ pagePath: req.path, keywords: [biz.category, biz.city], category: biz.category });
+  res.send(renderBusiness({ biz, audit, ad }));
+});
+
+// City map ("Everything pet-related in {city}, {state}")
+app.get('/in/:cityState', async (req, res) => {
+  const m = req.params.cityState.match(/^(.+)-([A-Za-z]{2})$/);
+  if (!m) return res.status(404).send(renderNotFound('City not recognized'));
+  const city = m[1].replace(/-/g,' ');
+  const state = m[2].toUpperCase();
+  const businesses = await many(`
+    SELECT id, name, slug, category, address, latitude, longitude, rating
+    FROM businesses
+    WHERE LOWER(city)=LOWER($1) AND state=$2 AND opt_out_flag=FALSE
+      AND latitude IS NOT NULL AND longitude IS NOT NULL`,
+    [city, state]);
+  const ad = await pickAdForPage({ pagePath: req.path, keywords: [city, state], category: null });
+  res.send(renderCityMap({ city, state, businesses, ad }));
+});
+
+// Lead form
+app.post('/api/leads', async (req, res) => {
+  const { full_name, email, phone, category_wanted, species_wanted, service_wanted, zip, city, state, description, urgency } = req.body || {};
+  if (!full_name || !email || !category_wanted) return res.status(400).json({ error: 'missing fields' });
+  const r = await pool.query(`
+    INSERT INTO leads (full_name, email, phone, category_wanted, species_wanted, service_wanted,
+      zip, city, state, description, urgency, ip, user_agent, source)
+    VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,'public_form')
+    RETURNING id`,
+    [full_name, email, phone||null, category_wanted, species_wanted||null, service_wanted||null,
+     zip||null, city||null, state||null, description||null, urgency||null,
+     req.ip, req.headers['user-agent']||null]);
+  const leadId = r.rows[0].id;
+  // Match async (don't block response)
+  matchLead(leadId).catch(err => log.error('match failed', { leadId, err: err.message }));
+  res.json({ ok: true, lead_id: leadId });
+});
+
+// Ad endpoints
+app.post('/api/ad-impression', async (req, res) => {
+  const { creative_id, page_path } = req.body || {};
+  if (creative_id) await recordImpression(creative_id, page_path, req.ip, req.headers['user-agent']);
+  res.json({ ok: true });
+});
+app.get('/ad/click/:creativeId', async (req, res) => {
+  const c = await one('SELECT cta_url FROM ad_creatives WHERE id=$1 AND active=true', [req.params.creativeId]);
+  if (!c) return res.status(404).send('Not found');
+  await recordClick(req.params.creativeId, req.query.from || null, req.ip, req.headers['user-agent']);
+  res.redirect(302, c.cta_url);
+});
+
+// Admin (placeholder; auth gate added later)
+app.get('/admin', async (_req, res) => {
+  const upgradeQ = await many(`SELECT id, business_name, email, status, created_at FROM upgrade_orders ORDER BY created_at DESC LIMIT 50`);
+  const leadsQ   = await many(`SELECT id, full_name, email, category_wanted, status, created_at FROM leads ORDER BY created_at DESC LIMIT 50`);
+  const auditQ   = await many(`SELECT s.id, b.name AS biz, b.website, s.marketing_score, s.audited_at FROM site_audits s JOIN businesses b ON b.id=s.business_id ORDER BY s.marketing_score ASC NULLS LAST, s.audited_at DESC LIMIT 50`);
+  res.send(renderAdmin({ upgradeQ, leadsQ, auditQ }));
+});
+
+app.use((_req, res) => res.status(404).send(renderNotFound()));
+
+const PORT = parseInt(process.env.PORT || '9720', 10);
+app.listen(PORT, () => log.info(`animals-viewer listening on :${PORT}`));
diff --git a/src/server/render.js b/src/server/render.js
new file mode 100644
index 0000000..fb42611
--- /dev/null
+++ b/src/server/render.js
@@ -0,0 +1,286 @@
+// Pure-string HTML renderer. Keeps the viewer dep-free (no template engine
+// install needed) and easy to inline-test. Uses a clean, off-white aesthetic
+// with a single accent color (#0f4d3a deep forest green — vet-y but warm).
+//
+// Maps embedded via Leaflet 1.9 + OpenStreetMap (no API key, free, complies
+// with OSM tile-usage policy: required attribution included).
+
+const ACCENT = '#0f4d3a';
+const ACCENT2 = '#c97a3e';
+
+function esc(s) {
+  return String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
+}
+
+function head(title, extra = '') {
+  return `<!doctype html><html lang="en"><head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${esc(title)} · AnimalsDirectory</title>
+<link rel="stylesheet" href="/static/css/site.css">
+<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
+${extra}
+</head><body>`;
+}
+
+function nav() {
+  return `<header class="site-header">
+  <a href="/" class="brand"><span class="brand-mark">▲</span> AnimalsDirectory</a>
+  <nav>
+    <a href="/breeds">Breeds</a>
+    <a href="/vets">Vets</a>
+    <a href="/groomers">Groomers</a>
+    <a href="/pet-stores">Pet Stores</a>
+    <a href="/shelters">Shelters</a>
+    <a href="/trainers">Trainers</a>
+    <a href="/breeders">Breeders</a>
+  </nav>
+  <a class="cta" href="#lead-form">Get matched →</a>
+</header>`;
+}
+
+function footer() {
+  return `<footer class="site-footer">
+  <small>© AnimalsDirectory · Source-traceable, opt-out honored.
+  Maps © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors.
+  <a href="/admin">Admin</a></small>
+</footer>`;
+}
+
+function adSlot(ad, slotName, page_path) {
+  if (!ad) {
+    return `<aside class="ad-slot ad-house"><strong>House promo:</strong> Are you a vet? <a href="/upgrade">Get a modern site for $499</a>.</aside>`;
+  }
+  return `<aside class="ad-slot ad-${slotName}" data-creative-id="${ad.id}" data-page-path="${esc(page_path||'')}">
+    <a href="/ad/click/${ad.id}?from=${encodeURIComponent(page_path||'')}" target="_blank" rel="nofollow sponsored noopener">
+      ${ad.image_url ? `<img src="${esc(ad.image_url)}" alt="">` : ''}
+      <strong>${esc(ad.headline||'')}</strong>
+      <p>${esc(ad.body||'')}</p>
+      <span class="ad-cta">${esc(ad.cta_text||'Learn more')}</span>
+    </a>
+    <span class="ad-disclosure">Ad</span>
+  </aside>
+  <script>fetch('/api/ad-impression',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({creative_id:${ad.id},page_path:${JSON.stringify(page_path||'')}})});</script>`;
+}
+
+function leadForm() {
+  return `<section id="lead-form" class="lead-form">
+  <h2>Find a vet, groomer, trainer, or breeder near you</h2>
+  <p class="muted">We'll match you with up to 3 verified businesses in your ZIP. Free for you, paid by them.</p>
+  <form method="POST" action="/api/leads" onsubmit="event.preventDefault();const fd=new FormData(this);fetch(this.action,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(Object.fromEntries(fd))}).then(r=>r.json()).then(j=>{this.innerHTML='<p class=\\'ok\\'>Got it! We\\'ll email you 3 matches within 24h.</p>';});">
+    <label>Your name <input name="full_name" required></label>
+    <label>Email <input type="email" name="email" required></label>
+    <label>Phone (optional) <input type="tel" name="phone"></label>
+    <label>I need a <select name="category_wanted" required>
+      <option value="vet_clinic">Veterinarian</option>
+      <option value="emergency_vet">Emergency vet</option>
+      <option value="groomer">Groomer</option>
+      <option value="trainer">Trainer</option>
+      <option value="boarding">Boarding/Daycare</option>
+      <option value="breeder">Breeder</option>
+      <option value="pet_store">Pet store</option>
+    </select></label>
+    <label>For my <select name="species_wanted">
+      <option value="dog">Dog</option><option value="cat">Cat</option><option value="bird">Bird</option>
+      <option value="reptile">Reptile</option><option value="small_mammal">Small mammal</option>
+      <option value="exotic">Exotic</option>
+    </select></label>
+    <label>ZIP <input name="zip" pattern="[0-9]{5}" required></label>
+    <label>How urgent? <select name="urgency">
+      <option value="immediate">Immediate</option>
+      <option value="within_week">This week</option>
+      <option value="within_month">This month</option>
+      <option value="researching">Just researching</option>
+    </select></label>
+    <label>Anything else? <textarea name="description" rows="3"></textarea></label>
+    <button type="submit">Get my 3 matches</button>
+  </form>
+</section>`;
+}
+
+export function renderHome({ breeds, counts, categories }) {
+  const catCards = categories.map(c => `
+    <a href="/${c}" class="cat-card">
+      <strong>${esc(c.replace(/-/g,' '))}</strong>
+      <span>${esc((counts[c.replace(/-/g,'_')]||0).toLocaleString())} listings</span>
+    </a>`).join('');
+  const breedCards = breeds.map(b => `
+    <a href="/breeds/${esc(b.slug)}" class="breed-card">
+      <div class="breed-img" style="${b.hero_image_url?`background-image:url('${esc(b.hero_image_url)}')`:'background:#e6efe9'}"></div>
+      <strong>${esc(b.common_name)}</strong>
+      <small>${esc(b.species)} · ${esc(b.size_class||'')}</small>
+    </a>`).join('');
+  return head('Find a vet, breed, groomer, or shelter near you') + nav() + `
+<main class="home">
+  <section class="hero">
+    <h1>Everything for your animal — in one place.</h1>
+    <p>Browse 500+ breeds. Find vets, groomers, pet stores, trainers, breeders, and shelters near you. With maps. With reviews. Always free for pet owners.</p>
+    <a class="cta-big" href="#lead-form">Get matched with a pro →</a>
+  </section>
+  <section class="cat-grid">${catCards}</section>
+  <h2>Popular breeds</h2>
+  <section class="breed-grid">${breedCards}</section>
+  ${leadForm()}
+</main>` + footer() + '</body></html>';
+}
+
+export function renderBreedsIndex({ breeds, filters }) {
+  const cards = breeds.map(b => `
+    <a href="/breeds/${esc(b.slug)}" class="breed-card">
+      <div class="breed-img" style="${b.hero_image_url?`background-image:url('${esc(b.hero_image_url)}')`:'background:#e6efe9'}"></div>
+      <strong>${esc(b.common_name)}</strong>
+      <small>${esc(b.species)} · ${esc(b.size_class||'')} · energy ${b.energy_level||'-'}/5</small>
+    </a>`).join('');
+  return head('Browse breeds') + nav() + `
+<main class="container">
+  <h1>All breeds (${breeds.length})</h1>
+  <form class="filters" method="GET">
+    <select name="species"><option value="">Any species</option><option ${filters.species==='dog'?'selected':''}>dog</option><option ${filters.species==='cat'?'selected':''}>cat</option></select>
+    <select name="size"><option value="">Any size</option>${['toy','small','medium','large','giant'].map(s=>`<option ${filters.size===s?'selected':''}>${s}</option>`).join('')}</select>
+    <select name="energy"><option value="">Any energy</option>${[1,2,3,4,5].map(n=>`<option value="${n}" ${String(filters.energy)===String(n)?'selected':''}>${n}+ /5</option>`).join('')}</select>
+    <button type="submit">Filter</button>
+  </form>
+  <section class="breed-grid">${cards}</section>
+</main>` + footer() + '</body></html>';
+}
+
+export function renderBreed({ breed, relatedVets, ad }) {
+  const traits = [
+    ['Group', breed.group_name], ['Size', breed.size_class],
+    ['Weight', breed.weight_lbs_min ? `${breed.weight_lbs_min}–${breed.weight_lbs_max} lb` : null],
+    ['Height', breed.height_in_min ? `${breed.height_in_min}–${breed.height_in_max} in` : null],
+    ['Lifespan', breed.life_span_years_min ? `${breed.life_span_years_min}–${breed.life_span_years_max} yrs` : null],
+    ['Coat', breed.coat_type], ['Shedding', breed.shedding_level ? `${breed.shedding_level}/5` : null],
+    ['Energy', breed.energy_level ? `${breed.energy_level}/5` : null],
+    ['Trainability', breed.trainability ? `${breed.trainability}/5` : null],
+    ['Good with kids', breed.good_with_kids ? `${breed.good_with_kids}/5` : null],
+    ['Good with pets', breed.good_with_pets ? `${breed.good_with_pets}/5` : null],
+  ].filter(([_,v]) => v).map(([k,v]) => `<dt>${esc(k)}</dt><dd>${esc(v)}</dd>`).join('');
+  const vets = relatedVets.map(v => `<li><a href="/clinic/${v.id}">${esc(v.name)}</a> <small>${esc(v.city||'')}, ${esc(v.state||'')}${v.rating?` · ★ ${v.rating}`:''}</small></li>`).join('');
+  return head(`${breed.common_name} — breed profile`) + nav() + `
+<main class="container">
+  <article>
+    <header class="breed-header">
+      <div class="breed-img-large" style="${breed.hero_image_url?`background-image:url('${esc(breed.hero_image_url)}')`:''}"></div>
+      <div>
+        <h1>${esc(breed.common_name)}</h1>
+        <p class="muted">${esc(breed.species)} · ${esc(breed.group_name||'')} group · ${esc(breed.size_class||'')}</p>
+        ${breed.recognized_by?.length ? `<p class="badges">${breed.recognized_by.map(r=>`<span>${esc(r)}</span>`).join('')}</p>` : ''}
+      </div>
+    </header>
+    <dl class="trait-list">${traits}</dl>
+    ${breed.description_md ? `<section class="prose">${breed.description_md.split('\n\n').map(p=>`<p>${esc(p)}</p>`).join('')}</section>` : ''}
+  </article>
+  ${adSlot(ad, 'native', `/breeds/${breed.slug}`)}
+  <h2>Top-rated vets that see ${esc(breed.common_name)}s</h2>
+  <ul class="business-list">${vets || '<li class="muted">No vets seeded yet — coming soon.</li>'}</ul>
+  ${leadForm()}
+</main>` + footer() + '</body></html>';
+}
+
+export function renderCategory({ category, dbCategory, businesses, ad }) {
+  const list = businesses.map(b => `
+    <li>
+      <a href="/clinic/${b.id}"><strong>${esc(b.name)}</strong></a>
+      <small>${esc(b.city||'')}, ${esc(b.state||'')} ${b.zip?`· ${esc(b.zip)}`:''}${b.rating?` · ★ ${b.rating} (${b.review_count||0})`:''}</small>
+      ${b.website ? `<a class="ext" href="${esc(b.website)}" rel="nofollow noopener" target="_blank">website ↗</a>` : ''}
+    </li>`).join('');
+  // mini-map for the first ~50 with coords
+  const points = businesses.filter(b=>b.latitude&&b.longitude).slice(0,200).map(b => ({id:b.id,name:b.name,lat:Number(b.latitude),lng:Number(b.longitude)}));
+  return head(`${category.replace(/-/g,' ')} directory`) + nav() + `
+<main class="container">
+  <h1>${esc(category.replace(/-/g,' '))} (${businesses.length})</h1>
+  <div id="cat-map" class="map" style="height:360px"></div>
+  ${adSlot(ad, 'header', `/${category}`)}
+  <ul class="business-list">${list || '<li class="muted">No listings yet — ingest pending.</li>'}</ul>
+  ${leadForm()}
+</main>
+<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
+<script>
+  const pts = ${JSON.stringify(points)};
+  if (pts.length) {
+    const map = L.map('cat-map').setView([pts[0].lat, pts[0].lng], 9);
+    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution:'© OpenStreetMap contributors', maxZoom:18 }).addTo(map);
+    const grp = L.featureGroup(pts.map(p => L.marker([p.lat,p.lng]).bindPopup('<a href="/clinic/'+p.id+'">'+p.name+'</a>'))).addTo(map);
+    if (pts.length > 1) map.fitBounds(grp.getBounds().pad(0.2));
+  } else {
+    document.getElementById('cat-map').innerHTML = '<p class="muted" style="padding:1em">Map shows here once we have geocoded listings.</p>';
+  }
+</script>` + footer() + '</body></html>';
+}
+
+export function renderBusiness({ biz, audit, ad }) {
+  const auditBlock = audit ? `
+    <aside class="audit">
+      <h3>Website health: ${audit.marketing_score ?? '—'}/100</h3>
+      ${audit.suggestions?.length ? `<ul>${audit.suggestions.map(s=>`<li>${esc(s)}</li>`).join('')}</ul>` : ''}
+      <p><a href="/upgrade?biz=${biz.id}" class="cta-small">Upgrade this site →</a></p>
+    </aside>` : '';
+  const map = (biz.latitude && biz.longitude) ? `<div id="biz-map" class="map" style="height:300px"></div>
+    <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
+    <script>
+      const m = L.map('biz-map').setView([${biz.latitude}, ${biz.longitude}], 14);
+      L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {attribution:'© OpenStreetMap contributors',maxZoom:18}).addTo(m);
+      L.marker([${biz.latitude}, ${biz.longitude}]).addTo(m).bindPopup(${JSON.stringify(biz.name)});
+    </script>` : '';
+  return head(biz.name) + nav() + `
+<main class="container biz-page">
+  <header>
+    <h1>${esc(biz.name)}</h1>
+    <p class="muted">${esc(biz.category.replace(/_/g,' '))} · ${esc(biz.city||'')}, ${esc(biz.state||'')}</p>
+  </header>
+  <div class="biz-grid">
+    <div>
+      ${biz.address ? `<p>📍 ${esc(biz.address)}</p>` : ''}
+      ${biz.phone ? `<p>☎ <a href="tel:${esc(biz.phone)}">${esc(biz.phone)}</a></p>` : ''}
+      ${biz.website ? `<p>🌐 <a href="${esc(biz.website)}" rel="nofollow noopener" target="_blank">${esc(biz.website)}</a></p>` : ''}
+      ${biz.online_booking_url ? `<p><a class="cta-small" href="${esc(biz.online_booking_url)}" rel="nofollow noopener" target="_blank">Book online →</a></p>` : ''}
+      ${map}
+    </div>
+    <div>
+      ${auditBlock}
+      ${adSlot(ad, 'sidebar', `/clinic/${biz.id}`)}
+    </div>
+  </div>
+  ${leadForm()}
+</main>` + footer() + '</body></html>';
+}
+
+export function renderCityMap({ city, state, businesses, ad }) {
+  const points = businesses.map(b => ({ id:b.id, name:b.name, cat:b.category, lat:Number(b.latitude), lng:Number(b.longitude), rating:b.rating }));
+  const cats = [...new Set(businesses.map(b=>b.category))];
+  return head(`Pets in ${city}, ${state}`) + nav() + `
+<main class="container">
+  <h1>Everything pet-related in ${esc(city)}, ${esc(state)}</h1>
+  <p class="muted">${businesses.length} listings across ${cats.length} categories</p>
+  <div id="city-map" class="map" style="height:520px"></div>
+  ${adSlot(ad, 'header', `/in/${city}-${state}`)}
+  ${leadForm()}
+</main>
+<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
+<script>
+  const pts = ${JSON.stringify(points)};
+  if (!pts.length) { document.getElementById('city-map').innerHTML = '<p>No listings yet for this city.</p>'; }
+  else {
+    const colors = { vet_clinic:'#0f4d3a', emergency_vet:'#c0382b', groomer:'#c97a3e', pet_store:'#3a6ea5', shelter:'#7e57c2', breeder:'#5d4037', trainer:'#388e3c', boarding:'#0288d1', dog_park:'#6a994e' };
+    const map = L.map('city-map').setView([pts[0].lat, pts[0].lng], 12);
+    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{attribution:'© OpenStreetMap contributors',maxZoom:18}).addTo(map);
+    const grp = L.featureGroup(pts.map(p => L.circleMarker([p.lat,p.lng], { radius:7, color:colors[p.cat]||'#333', fillOpacity:0.85 }).bindPopup('<strong>'+p.name+'</strong><br>'+p.cat.replace(/_/g,' ')+'<br><a href="/clinic/'+p.id+'">View →</a>'))).addTo(map);
+    if (pts.length > 1) map.fitBounds(grp.getBounds().pad(0.15));
+  }
+</script>` + footer() + '</body></html>';
+}
+
+export function renderLeadForm() { return head('Get matched') + nav() + `<main class="container">${leadForm()}</main>` + footer() + '</body></html>'; }
+
+export function renderAdmin({ upgradeQ, leadsQ, auditQ }) {
+  const tbl = (rows, fields) => rows.length ? `<table><thead><tr>${fields.map(f=>`<th>${f}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${fields.map(f=>`<td>${esc(r[f]||'')}</td>`).join('')}</tr>`).join('')}</tbody></table>` : '<p class="muted">empty</p>';
+  return head('Admin') + nav() + `<main class="container">
+    <h1>Admin queue</h1>
+    <h2>Upgrade orders</h2>${tbl(upgradeQ,['id','business_name','email','status','created_at'])}
+    <h2>Leads</h2>${tbl(leadsQ,['id','full_name','email','category_wanted','status','created_at'])}
+    <h2>Worst-scoring sites (upgrade targets)</h2>${tbl(auditQ,['id','biz','website','marketing_score','audited_at'])}
+  </main>` + footer() + '</body></html>';
+}
+
+export function renderNotFound(msg='Not found') { return head(msg) + nav() + `<main class="container"><h1>${esc(msg)}</h1><p><a href="/">Back to home</a></p></main>` + footer() + '</body></html>'; }

(oldest)  ·  back to Animals  ·  fix(map): stop near-me SQL from crashing, hide empty cats, a 9e16943 →