← 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
A .env.exampleA .gitignoreA PLAN.mdA README.mdA docs/domain_research.jsonA docs/monetization.mdA ecosystem.config.cjsA migrations/001_initial_schema.sqlA package-lock.jsonA package.jsonA public/css/site.cssA public/mockups/biz-13-a.htmlA public/mockups/biz-13-b.htmlA public/mockups/biz-13-c.htmlA public/mockups/biz-4-a.htmlA public/mockups/biz-4-b.htmlA public/mockups/biz-4-c.htmlA public/mockups/biz-5-a.htmlA public/mockups/biz-5-b.htmlA public/mockups/biz-5-c.htmlA public/mockups/biz-8-a.htmlA public/mockups/biz-8-b.htmlA public/mockups/biz-8-c.htmlA src/audit/generate_mockups.jsA src/audit/server.jsA src/audit/site_audit.jsA src/ingest/server.jsA src/lib/db.jsA src/lib/log.jsA src/lib/slug.jsA src/monetize/ad_engine.jsA src/monetize/leads_server.jsA src/monetize/match_engine.jsA src/scripts/hawk-local.shA src/scripts/migrate.jsA src/scripts/research_domains.jsA src/scripts/seed_breeds.jsA src/scripts/seed_demo_businesses.jsA src/scripts/seed_sources.jsA src/server/index.jsA src/server/render.js
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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 →