[object Object]

← back to Ventura Corridor

Initial commit · Ventura corridor v1 · 224 businesses · 113 audits · 3 surfaces (2D/3D/wall)

91c0ed0fd1661c9db0d473d45d52fb53bf99708b · 2026-05-05 06:49:06 -0700 · ventura-corridor

Files touched

Diff

commit 91c0ed0fd1661c9db0d473d45d52fb53bf99708b
Author: ventura-corridor <local@ventura-corridor>
Date:   Tue May 5 06:49:06 2026 -0700

    Initial commit · Ventura corridor v1 · 224 businesses · 113 audits · 3 surfaces (2D/3D/wall)
---
 .env.bak                              |   23 +
 .env.example                          |   23 +
 .gitignore                            |    8 +
 README.md                             |   92 ++
 data/3d-cam-fix.png                   |  Bin 0 -> 277875 bytes
 data/3d-snapshot.png                  |  Bin 0 -> 64063 bytes
 data/3d-with-screenshots.png          |  Bin 0 -> 219895 bytes
 data/after-review-2d.png              |  Bin 0 -> 1019405 bytes
 data/after-review-3d-post-overlay.png |  Bin 0 -> 263869 bytes
 data/after-review-3d.png              |  Bin 0 -> 265593 bytes
 data/after-review-wall.png            |  Bin 0 -> 454065 bytes
 data/ventura-blvd-polyline.json       |  864 ++++++++++++++
 data/wall-snapshot.png                |  Bin 0 -> 594990 bytes
 db/migrate.ts                         |   54 +
 db/migrations/001_init.sql            |  125 ++
 db/migrations/002_enrichment.sql      |   75 ++
 db/pool.ts                            |   25 +
 db/seed_segments.sql                  |   31 +
 ecosystem.config.cjs                  |   19 +
 package-lock.json                     | 2005 +++++++++++++++++++++++++++++++++
 package.json                          |   34 +
 public/3d.html                        |  544 +++++++++
 public/index.html                     |  354 ++++++
 public/wall.html                      |  178 +++
 scripts/foursquare_signup.cjs         |  208 ++++
 src/crawl/front_page.ts               |  287 +++++
 src/enrich/classify_ownership.ts      |  211 ++++
 src/enrich/restaurant_owners.ts       |  302 +++++
 src/ingest/foursquare.ts              |  243 ++++
 src/ingest/here_discover.ts           |  232 ++++
 src/ingest/osm_overpass.ts            |  212 ++++
 src/score/seo_score.ts                |  218 ++++
 src/server/index.ts                   |  172 +++
 tsconfig.json                         |   14 +
 34 files changed, 6553 insertions(+)

diff --git a/.env.bak b/.env.bak
new file mode 100644
index 0000000..69be496
--- /dev/null
+++ b/.env.bak
@@ -0,0 +1,23 @@
+# Ventura Corridor — local-only
+
+# Server
+PORT=9780
+
+# Database (local PG via Unix socket)
+DATABASE_URL=postgresql:///ventura_corridor?host=/tmp
+
+# HERE Maps (free 30k req/mo). Get one at https://platform.here.com or via /secrets.
+HERE_API_KEY=
+
+# Ventura Blvd corridor bounds (for HERE Discover bbox queries)
+# Western anchor: Ventura Blvd at Calabasas Pkwy (~34.146, -118.610)
+# Eastern anchor: Ventura Blvd at Cahuenga Blvd, NoHo (~34.139, -118.382)
+# We tile the 17-mi corridor in 1km segments along the polyline.
+VENTURA_BBOX_WEST=-118.620
+VENTURA_BBOX_EAST=-118.370
+VENTURA_BBOX_SOUTH=34.130
+VENTURA_BBOX_NORTH=34.165
+
+# Crawl tuning
+CRAWL_CONCURRENCY=20
+CRAWL_TIMEOUT_MS=15000
diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..945dbcb
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,23 @@
+# Ventura Corridor — local-only
+
+# Server
+PORT=9780
+
+# Database (local PG via Unix socket)
+DATABASE_URL=postgresql:///ventura_corridor?host=/tmp
+
+# HERE Maps (free 30k req/mo). Get one at https://platform.here.com or via /secrets.
+HERE_API_KEY=
+
+# Ventura Blvd corridor bounds (for HERE Discover bbox queries)
+# Western anchor: Ventura Blvd at Calabasas Pkwy (~34.146, -118.610)
+# Eastern anchor: Ventura Blvd at Cahuenga Blvd, NoHo (~34.139, -118.382)
+# We tile the 17-mi corridor in 1km segments along the polyline.
+VENTURA_BBOX_WEST=-118.620
+VENTURA_BBOX_EAST=-118.370
+VENTURA_BBOX_SOUTH=34.130
+VENTURA_BBOX_NORTH=34.165
+
+# Crawl tuning
+CRAWL_CONCURRENCY=4
+CRAWL_TIMEOUT_MS=15000
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..0cb9eaa
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env
+.env.local
+logs/
+data/raw/
+data/screenshots/
+*.log
+.DS_Store
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..b283dbf
--- /dev/null
+++ b/README.md
@@ -0,0 +1,92 @@
+# Ventura Corridor
+
+Local-only directory of every business on Ventura Blvd (Calabasas → North Hollywood). Inventory + front-page audit + SEO scoring + ownership classification + three view surfaces.
+
+**Loopback only** — bound to `127.0.0.1:9780`, never exposed to LAN/tailnet/internet.
+
+## Surfaces
+
+| URL | Purpose |
+|---|---|
+| `http://127.0.0.1:9780/` | 2D Leaflet map · noir-themed · grid-search · live build-status strip |
+| `http://127.0.0.1:9780/3d.html` | Three.js "signal corridor" · screenshot billboards on real Ventura Blvd polyline · drive-cam |
+| `http://127.0.0.1:9780/wall.html` | Pinterest-grid of every captured front-page screenshot |
+
+## Pipeline
+
+```
+ingest (OSM Overpass, free, no key) → 224 businesses
+  ↓
+crawl (Playwright, conc 20) → 113 front-page audits + screenshots
+  ↓
+score (12-signal SEO model) → tier A/B/C/D + per-category rank
+  ↓
+enrich:classify (heuristic ownership) → 180 indep / 23 corp / 20 franchise
+  ↓
+viewers (2D / 3D / wall)
+```
+
+## Layout
+
+```
+~/Projects/ventura-corridor
+├── src/
+│   ├── server/index.ts        # Express on 127.0.0.1:9780
+│   ├── ingest/
+│   │   ├── osm_overpass.ts    # primary (free, no key)
+│   │   ├── here_discover.ts   # secondary (HERE_API_KEY required)
+│   │   └── foursquare.ts      # secondary (FOURSQUARE_API_KEY required)
+│   ├── crawl/front_page.ts    # Playwright crawler
+│   ├── score/seo_score.ts     # 12-signal scorer
+│   └── enrich/
+│       ├── classify_ownership.ts
+│       └── restaurant_owners.ts  # DDG-search; rate-limited, low yield
+├── public/                    # 2D, 3D, wall
+├── data/
+│   ├── ventura-blvd-polyline.json   # 214-pt corridor centerline
+│   ├── screenshots/                  # firm-NNN.jpg × 113
+│   ├── raw/                          # firm-NNN.html × 113
+│   └── last-verified.json
+├── db/migrations/             # 001_init.sql · 002_enrichment.sql
+├── ecosystem.config.cjs       # pm2
+└── .env                       # local config
+```
+
+## Run
+
+```bash
+npm install
+npm run migrate                 # apply schema
+npm run ingest:overpass         # pull businesses
+npm run crawl                   # capture front-page audits
+npm run score                   # compute SEO score per business
+npm run enrich:classify         # tag ownership class
+pm2 start ecosystem.config.cjs  # start the read API + viewers
+pm2 save
+```
+
+Health: `curl http://127.0.0.1:9780/api/health`
+
+## Schema
+
+- `businesses` (anchor) — 1 row per business · `(source, source_id)` UNIQUE
+- `corridor_segments` — 26 polyline-segment rows for the boulevard
+- `front_page_audits` — 1 row per crawl pass (HTTPS, viewport, schema.org count, alt-coverage, etc.)
+- `seo_scores` — 1 row per scoring pass (12 weighted signals → total / tier / category_rank)
+- `business_enrichment` — 1 row per business · ownership_class · parent_brand · best_contact_path
+- `business_contacts` — many per business · LinkedIn / website-about / GMB sources
+- `ingest_runs` — provenance log
+
+## Standing rules in this codebase
+
+- **Local-only.** Never bind to `0.0.0.0`. Never deploy to Kamatera without explicit deploy-intent confirmation.
+- **No fabricated stats next to real entity names.** Same compliance discipline as the lawyer/doctor projects.
+- **No customer-routing language anywhere.** This is a directory, not a referral service.
+- **Grid-search is mandatory** on every surface (per MEMORY.md `feedback_every_site_grid_search.md`).
+- **Color-dot indicator pattern is DW-micro-site only.** Not used here.
+
+## Known gaps
+
+- OSM coverage west of Tarzana is sparse (1 row in Woodland Hills, 0 in Calabasas/Tarzana). Layer HERE or Foursquare ingest when the keys are available.
+- 26 sites failed the front-page crawl (DNS errors on chain-locator URLs, anti-bot walls). Re-crawl with longer timeouts is wired (`npm run crawl -- --rescore`).
+- LinkedIn enrichment via DDG hit DDG's rate limit at request 40 of 89; only 1 contact captured. Apollo.io free tier (50 credits) would close this if Steve wants.
diff --git a/data/3d-cam-fix.png b/data/3d-cam-fix.png
new file mode 100644
index 0000000..bf8933a
Binary files /dev/null and b/data/3d-cam-fix.png differ
diff --git a/data/3d-snapshot.png b/data/3d-snapshot.png
new file mode 100644
index 0000000..ff7336c
Binary files /dev/null and b/data/3d-snapshot.png differ
diff --git a/data/3d-with-screenshots.png b/data/3d-with-screenshots.png
new file mode 100644
index 0000000..6b351bd
Binary files /dev/null and b/data/3d-with-screenshots.png differ
diff --git a/data/after-review-2d.png b/data/after-review-2d.png
new file mode 100644
index 0000000..81791be
Binary files /dev/null and b/data/after-review-2d.png differ
diff --git a/data/after-review-3d-post-overlay.png b/data/after-review-3d-post-overlay.png
new file mode 100644
index 0000000..7adef4b
Binary files /dev/null and b/data/after-review-3d-post-overlay.png differ
diff --git a/data/after-review-3d.png b/data/after-review-3d.png
new file mode 100644
index 0000000..b0ba663
Binary files /dev/null and b/data/after-review-3d.png differ
diff --git a/data/after-review-wall.png b/data/after-review-wall.png
new file mode 100644
index 0000000..06442e9
Binary files /dev/null and b/data/after-review-wall.png differ
diff --git a/data/ventura-blvd-polyline.json b/data/ventura-blvd-polyline.json
new file mode 100644
index 0000000..a684706
--- /dev/null
+++ b/data/ventura-blvd-polyline.json
@@ -0,0 +1,864 @@
+{
+  "name": "Ventura Boulevard centerline",
+  "source": "OSM Overpass",
+  "fetched_at": "2026-05-05T03:36:12Z",
+  "point_count": 214,
+  "points_lat_lng": [
+    [
+      34.165335,
+      -118.526909
+    ],
+    [
+      34.165164,
+      -118.52637
+    ],
+    [
+      34.164868,
+      -118.525446
+    ],
+    [
+      34.164522,
+      -118.524534
+    ],
+    [
+      34.164167,
+      -118.523627
+    ],
+    [
+      34.163934,
+      -118.523029
+    ],
+    [
+      34.16368,
+      -118.522373
+    ],
+    [
+      34.163416,
+      -118.521698
+    ],
+    [
+      34.163151,
+      -118.521008
+    ],
+    [
+      34.162986,
+      -118.52047
+    ],
+    [
+      34.162808,
+      -118.519872
+    ],
+    [
+      34.162611,
+      -118.51919
+    ],
+    [
+      34.162449,
+      -118.518591
+    ],
+    [
+      34.162288,
+      -118.517992
+    ],
+    [
+      34.162141,
+      -118.51744
+    ],
+    [
+      34.161953,
+      -118.51675
+    ],
+    [
+      34.161768,
+      -118.515981
+    ],
+    [
+      34.161661,
+      -118.515351
+    ],
+    [
+      34.161564,
+      -118.514637
+    ],
+    [
+      34.161485,
+      -118.514097
+    ],
+    [
+      34.161406,
+      -118.513534
+    ],
+    [
+      34.16126,
+      -118.512539
+    ],
+    [
+      34.161134,
+      -118.51165
+    ],
+    [
+      34.16105,
+      -118.511083
+    ],
+    [
+      34.160963,
+      -118.510489
+    ],
+    [
+      34.160874,
+      -118.509827
+    ],
+    [
+      34.160781,
+      -118.509186
+    ],
+    [
+      34.160646,
+      -118.508285
+    ],
+    [
+      34.160548,
+      -118.507621
+    ],
+    [
+      34.160425,
+      -118.506765
+    ],
+    [
+      34.160343,
+      -118.506183
+    ],
+    [
+      34.160257,
+      -118.505554
+    ],
+    [
+      34.160084,
+      -118.504623
+    ],
+    [
+      34.159935,
+      -118.50396
+    ],
+    [
+      34.159802,
+      -118.503381
+    ],
+    [
+      34.159537,
+      -118.502273
+    ],
+    [
+      34.159371,
+      -118.501527
+    ],
+    [
+      34.159112,
+      -118.500468
+    ],
+    [
+      34.158982,
+      -118.499923
+    ],
+    [
+      34.158832,
+      -118.499274
+    ],
+    [
+      34.158704,
+      -118.498716
+    ],
+    [
+      34.158571,
+      -118.498143
+    ],
+    [
+      34.158417,
+      -118.497482
+    ],
+    [
+      34.158219,
+      -118.496632
+    ],
+    [
+      34.158103,
+      -118.496067
+    ],
+    [
+      34.157998,
+      -118.495473
+    ],
+    [
+      34.157913,
+      -118.494906
+    ],
+    [
+      34.157806,
+      -118.494108
+    ],
+    [
+      34.157707,
+      -118.493398
+    ],
+    [
+      34.157589,
+      -118.49252
+    ],
+    [
+      34.157456,
+      -118.491554
+    ],
+    [
+      34.157357,
+      -118.49082
+    ],
+    [
+      34.157281,
+      -118.490243
+    ],
+    [
+      34.157201,
+      -118.489623
+    ],
+    [
+      34.157113,
+      -118.489012
+    ],
+    [
+      34.156989,
+      -118.488112
+    ],
+    [
+      34.15691,
+      -118.487542
+    ],
+    [
+      34.156815,
+      -118.486851
+    ],
+    [
+      34.156732,
+      -118.486246
+    ],
+    [
+      34.156615,
+      -118.485398
+    ],
+    [
+      34.156445,
+      -118.484145
+    ],
+    [
+      34.156366,
+      -118.483544
+    ],
+    [
+      34.156118,
+      -118.481755
+    ],
+    [
+      34.15603,
+      -118.481107
+    ],
+    [
+      34.155926,
+      -118.480345
+    ],
+    [
+      34.155834,
+      -118.479681
+    ],
+    [
+      34.1557,
+      -118.478731
+    ],
+    [
+      34.155614,
+      -118.478102
+    ],
+    [
+      34.155534,
+      -118.477524
+    ],
+    [
+      34.155351,
+      -118.476168
+    ],
+    [
+      34.155246,
+      -118.475405
+    ],
+    [
+      34.155172,
+      -118.474843
+    ],
+    [
+      34.155079,
+      -118.474175
+    ],
+    [
+      34.155005,
+      -118.473634
+    ],
+    [
+      34.154931,
+      -118.473073
+    ],
+    [
+      34.154801,
+      -118.472089
+    ],
+    [
+      34.15453,
+      -118.470459
+    ],
+    [
+      34.154359,
+      -118.469423
+    ],
+    [
+      34.154213,
+      -118.468554
+    ],
+    [
+      34.154041,
+      -118.467547
+    ],
+    [
+      34.153933,
+      -118.46688
+    ],
+    [
+      34.15382,
+      -118.466211
+    ],
+    [
+      34.153696,
+      -118.465472
+    ],
+    [
+      34.153601,
+      -118.464883
+    ],
+    [
+      34.153466,
+      -118.464047
+    ],
+    [
+      34.153282,
+      -118.462932
+    ],
+    [
+      34.153157,
+      -118.462177
+    ],
+    [
+      34.153052,
+      -118.461542
+    ],
+    [
+      34.152955,
+      -118.46096
+    ],
+    [
+      34.15274,
+      -118.459658
+    ],
+    [
+      34.152622,
+      -118.458932
+    ],
+    [
+      34.152488,
+      -118.458118
+    ],
+    [
+      34.1524,
+      -118.457585
+    ],
+    [
+      34.152299,
+      -118.456963
+    ],
+    [
+      34.152177,
+      -118.456248
+    ],
+    [
+      34.152032,
+      -118.455391
+    ],
+    [
+      34.151926,
+      -118.45477
+    ],
+    [
+      34.151747,
+      -118.453711
+    ],
+    [
+      34.151646,
+      -118.453082
+    ],
+    [
+      34.15152,
+      -118.45231
+    ],
+    [
+      34.151409,
+      -118.45167
+    ],
+    [
+      34.151297,
+      -118.450951
+    ],
+    [
+      34.151132,
+      -118.449955
+    ],
+    [
+      34.151009,
+      -118.449212
+    ],
+    [
+      34.150904,
+      -118.44858
+    ],
+    [
+      34.15079,
+      -118.447872
+    ],
+    [
+      34.150604,
+      -118.446722
+    ],
+    [
+      34.150514,
+      -118.446167
+    ],
+    [
+      34.150343,
+      -118.44514
+    ],
+    [
+      34.150231,
+      -118.444477
+    ],
+    [
+      34.150128,
+      -118.443865
+    ],
+    [
+      34.150008,
+      -118.443153
+    ],
+    [
+      34.149848,
+      -118.442196
+    ],
+    [
+      34.149667,
+      -118.44112
+    ],
+    [
+      34.149568,
+      -118.440521
+    ],
+    [
+      34.149474,
+      -118.439977
+    ],
+    [
+      34.149379,
+      -118.439403
+    ],
+    [
+      34.149263,
+      -118.438698
+    ],
+    [
+      34.149144,
+      -118.437982
+    ],
+    [
+      34.149006,
+      -118.437157
+    ],
+    [
+      34.148881,
+      -118.436408
+    ],
+    [
+      34.148784,
+      -118.435787
+    ],
+    [
+      34.148637,
+      -118.434908
+    ],
+    [
+      34.148503,
+      -118.434075
+    ],
+    [
+      34.1484,
+      -118.433488
+    ],
+    [
+      34.1483,
+      -118.432883
+    ],
+    [
+      34.148132,
+      -118.431883
+    ],
+    [
+      34.148042,
+      -118.431339
+    ],
+    [
+      34.147889,
+      -118.430394
+    ],
+    [
+      34.147778,
+      -118.429716
+    ],
+    [
+      34.147635,
+      -118.428835
+    ],
+    [
+      34.147534,
+      -118.428225
+    ],
+    [
+      34.147426,
+      -118.427567
+    ],
+    [
+      34.147335,
+      -118.427028
+    ],
+    [
+      34.14723,
+      -118.426396
+    ],
+    [
+      34.147124,
+      -118.425763
+    ],
+    [
+      34.146993,
+      -118.424994
+    ],
+    [
+      34.146836,
+      -118.424073
+    ],
+    [
+      34.146727,
+      -118.42342
+    ],
+    [
+      34.146603,
+      -118.422633
+    ],
+    [
+      34.146466,
+      -118.421803
+    ],
+    [
+      34.146142,
+      -118.421353
+    ],
+    [
+      34.146304,
+      -118.420817
+    ],
+    [
+      34.146172,
+      -118.420033
+    ],
+    [
+      34.145833,
+      -118.419511
+    ],
+    [
+      34.145954,
+      -118.41872
+    ],
+    [
+      34.145628,
+      -118.41828
+    ],
+    [
+      34.145749,
+      -118.417447
+    ],
+    [
+      34.145601,
+      -118.416576
+    ],
+    [
+      34.145171,
+      -118.415526
+    ],
+    [
+      34.14528,
+      -118.414657
+    ],
+    [
+      34.145149,
+      -118.413918
+    ],
+    [
+      34.145058,
+      -118.413359
+    ],
+    [
+      34.144914,
+      -118.412494
+    ],
+    [
+      34.1448,
+      -118.411812
+    ],
+    [
+      34.144734,
+      -118.411261
+    ],
+    [
+      34.144753,
+      -118.4106
+    ],
+    [
+      34.144737,
+      -118.410039
+    ],
+    [
+      34.144625,
+      -118.409461
+    ],
+    [
+      34.144431,
+      -118.408942
+    ],
+    [
+      34.144076,
+      -118.408404
+    ],
+    [
+      34.143575,
+      -118.407766
+    ],
+    [
+      34.143189,
+      -118.407167
+    ],
+    [
+      34.142913,
+      -118.406502
+    ],
+    [
+      34.14277,
+      -118.405907
+    ],
+    [
+      34.142713,
+      -118.405329
+    ],
+    [
+      34.142752,
+      -118.404567
+    ],
+    [
+      34.142852,
+      -118.40345
+    ],
+    [
+      34.142927,
+      -118.402573
+    ],
+    [
+      34.143031,
+      -118.401378
+    ],
+    [
+      34.143083,
+      -118.400806
+    ],
+    [
+      34.143137,
+      -118.400211
+    ],
+    [
+      34.143211,
+      -118.399365
+    ],
+    [
+      34.143261,
+      -118.398795
+    ],
+    [
+      34.143347,
+      -118.39787
+    ],
+    [
+      34.143428,
+      -118.396912
+    ],
+    [
+      34.143561,
+      -118.395441
+    ],
+    [
+      34.143631,
+      -118.394634
+    ],
+    [
+      34.14369,
+      -118.393961
+    ],
+    [
+      34.143719,
+      -118.393379
+    ],
+    [
+      34.143624,
+      -118.392844
+    ],
+    [
+      34.143361,
+      -118.392391
+    ],
+    [
+      34.143224,
+      -118.391869
+    ],
+    [
+      34.142837,
+      -118.391276
+    ],
+    [
+      34.142665,
+      -118.390723
+    ],
+    [
+      34.142286,
+      -118.389916
+    ],
+    [
+      34.141773,
+      -118.388803
+    ],
+    [
+      34.141533,
+      -118.38829
+    ],
+    [
+      34.141319,
+      -118.387789
+    ],
+    [
+      34.14109,
+      -118.387156
+    ],
+    [
+      34.140907,
+      -118.386616
+    ],
+    [
+      34.140289,
+      -118.384789
+    ],
+    [
+      34.139986,
+      -118.383881
+    ],
+    [
+      34.139833,
+      -118.383297
+    ],
+    [
+      34.1397,
+      -118.382057
+    ],
+    [
+      34.139635,
+      -118.381455
+    ],
+    [
+      34.139574,
+      -118.38087
+    ],
+    [
+      34.139604,
+      -118.380206
+    ],
+    [
+      34.139833,
+      -118.379258
+    ],
+    [
+      34.140008,
+      -118.37857
+    ],
+    [
+      34.140114,
+      -118.377988
+    ],
+    [
+      34.140214,
+      -118.377359
+    ],
+    [
+      34.140308,
+      -118.376723
+    ],
+    [
+      34.140524,
+      -118.376144
+    ],
+    [
+      34.140881,
+      -118.375695
+    ],
+    [
+      34.14138,
+      -118.375342
+    ],
+    [
+      34.141703,
+      -118.374926
+    ],
+    [
+      34.141862,
+      -118.374342
+    ],
+    [
+      34.141796,
+      -118.373681
+    ],
+    [
+      34.141216,
+      -118.372203
+    ],
+    [
+      34.140981,
+      -118.37158
+    ],
+    [
+      34.140771,
+      -118.371018
+    ],
+    [
+      34.140541,
+      -118.370383
+    ],
+    [
+      34.140259,
+      -118.369682
+    ]
+  ]
+}
\ No newline at end of file
diff --git a/data/wall-snapshot.png b/data/wall-snapshot.png
new file mode 100644
index 0000000..f9aac86
Binary files /dev/null and b/data/wall-snapshot.png differ
diff --git a/db/migrate.ts b/db/migrate.ts
new file mode 100644
index 0000000..f1de83e
--- /dev/null
+++ b/db/migrate.ts
@@ -0,0 +1,54 @@
+/**
+ * Runs every .sql file in db/migrations/ in lexical order, idempotent.
+ * Records applied filenames in schema_migrations.
+ */
+import 'dotenv/config';
+import { readFileSync, readdirSync } from 'node:fs';
+import { join, dirname } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { pool } from './pool.ts';
+
+const __dirname = dirname(fileURLToPath(import.meta.url));
+const migrationsDir = join(__dirname, 'migrations');
+
+async function ensureLedger() {
+  await pool.query(`
+    CREATE TABLE IF NOT EXISTS schema_migrations (
+      filename TEXT PRIMARY KEY,
+      applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+    )
+  `);
+}
+
+async function applied(): Promise<Set<string>> {
+  const r = await pool.query<{ filename: string }>(`SELECT filename FROM schema_migrations`);
+  return new Set(r.rows.map((x) => x.filename));
+}
+
+async function main() {
+  await ensureLedger();
+  const seen = await applied();
+  const files = readdirSync(migrationsDir).filter((f) => f.endsWith('.sql')).sort();
+  for (const f of files) {
+    if (seen.has(f)) {
+      console.log(`[skip]   ${f}`);
+      continue;
+    }
+    const sql = readFileSync(join(migrationsDir, f), 'utf8');
+    console.log(`[apply]  ${f}`);
+    try {
+      await pool.query(sql);
+      console.log(`[done]   ${f}`);
+    } catch (e: any) {
+      console.error(`[fail]   ${f}: ${e.message}`);
+      process.exit(1);
+    }
+  }
+  await pool.end();
+  console.log('migrations complete');
+}
+
+main().catch((e) => {
+  console.error(e);
+  process.exit(1);
+});
diff --git a/db/migrations/001_init.sql b/db/migrations/001_init.sql
new file mode 100644
index 0000000..9dbe0f6
--- /dev/null
+++ b/db/migrations/001_init.sql
@@ -0,0 +1,125 @@
+-- Ventura Blvd corridor — initial schema.
+-- Local-only PG. Mirrors lawyer/doctor pattern: organizations as anchor, plus
+-- audit/score tables, plus a corridor_segments table for the 17-mile polyline.
+
+BEGIN;
+
+CREATE EXTENSION IF NOT EXISTS pg_trgm;
+CREATE EXTENSION IF NOT EXISTS unaccent;
+
+-- ─── Businesses ──────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS businesses (
+  id              BIGSERIAL PRIMARY KEY,
+  source          TEXT NOT NULL,           -- 'here' | 'osm' | 'la_county' | 'manual'
+  source_id       TEXT NOT NULL,           -- the upstream ID (HERE ID, OSM way/node id, etc.)
+  name            TEXT NOT NULL,
+  category        TEXT,                    -- normalized top-level (food, retail, services, ...)
+  category_raw    TEXT,                    -- upstream label, before normalization
+  category_naics  TEXT,                    -- best-effort NAICS bucket if derivable
+  address         TEXT,
+  street_number   TEXT,                    -- parsed for matching to corridor segments
+  street_name     TEXT,                    -- normalized "VENTURA BLVD" if on the corridor
+  city            TEXT,                    -- "Studio City", "Sherman Oaks", ...
+  zip             TEXT,
+  lat             DOUBLE PRECISION,
+  lng             DOUBLE PRECISION,
+  phone           TEXT,
+  website         TEXT,                    -- canonical URL after normalization
+  on_corridor     BOOLEAN NOT NULL DEFAULT FALSE,  -- true iff geocoded onto Ventura Blvd
+  corridor_block  INTEGER,                 -- 100-block address (e.g., 12100 → 12100)
+  segment_id      INTEGER,                 -- FK to corridor_segments below
+  first_seen_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  last_seen_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  raw             JSONB,                   -- the upstream row, kept for audit
+  UNIQUE (source, source_id)
+);
+CREATE INDEX IF NOT EXISTS idx_businesses_corridor ON businesses (on_corridor) WHERE on_corridor;
+CREATE INDEX IF NOT EXISTS idx_businesses_category ON businesses (category) WHERE on_corridor;
+CREATE INDEX IF NOT EXISTS idx_businesses_geo ON businesses (lat, lng) WHERE lat IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_businesses_name_trgm ON businesses USING gin (name gin_trgm_ops);
+
+-- ─── Corridor segments (17-mi Ventura Blvd polyline, ~1km tiles) ────────
+CREATE TABLE IF NOT EXISTS corridor_segments (
+  id          SERIAL PRIMARY KEY,
+  ord         INTEGER NOT NULL,            -- 1..N, west→east
+  city        TEXT,                        -- which city the segment falls in
+  west_lat    DOUBLE PRECISION NOT NULL,
+  west_lng    DOUBLE PRECISION NOT NULL,
+  east_lat    DOUBLE PRECISION NOT NULL,
+  east_lng    DOUBLE PRECISION NOT NULL,
+  block_low   INTEGER,                     -- e.g., 22000 (Calabasas end)
+  block_high  INTEGER                      -- e.g., 23999
+);
+CREATE INDEX IF NOT EXISTS idx_corridor_segments_ord ON corridor_segments (ord);
+
+-- ─── Front-page audits (one row per crawl pass) ─────────────────────────
+CREATE TABLE IF NOT EXISTS front_page_audits (
+  id              BIGSERIAL PRIMARY KEY,
+  business_id     BIGINT NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
+  audited_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  fetched_url     TEXT,                    -- where we ended up (after redirects)
+  status_code     INTEGER,
+  http_protocol   TEXT,                    -- 'http' | 'https'
+  ttfb_ms         INTEGER,
+  load_ms         INTEGER,
+  bytes_html      INTEGER,
+  bytes_total     INTEGER,
+  has_https       BOOLEAN,
+  has_viewport    BOOLEAN,                 -- mobile-viewport meta tag
+  has_title       BOOLEAN,
+  title_text      TEXT,
+  meta_desc       TEXT,
+  has_h1          BOOLEAN,
+  h1_text         TEXT,
+  schema_org_count INTEGER,                -- # of <script type="application/ld+json"> blocks
+  schema_org_types TEXT[],                 -- e.g., ['Organization','LocalBusiness']
+  og_image        TEXT,                    -- og:image URL if present
+  alt_coverage    NUMERIC(4,2),            -- 0.00–1.00 fraction of <img> with non-empty alt
+  word_count      INTEGER,
+  outbound_links  INTEGER,
+  internal_links  INTEGER,
+  screenshot_path TEXT,                    -- local file relative to data/screenshots/
+  error_message   TEXT,
+  raw_html_path   TEXT,                    -- relative to data/raw/
+  raw_lighthouse  JSONB                    -- if Lighthouse runs ever wired in
+);
+CREATE INDEX IF NOT EXISTS idx_audits_business ON front_page_audits (business_id, audited_at DESC);
+
+-- ─── SEO scores (one row per audit pass) ────────────────────────────────
+-- 12 signals, weighted, mirrors lawyer/doctor methodology v1.0.
+CREATE TABLE IF NOT EXISTS seo_scores (
+  id            BIGSERIAL PRIMARY KEY,
+  audit_id      BIGINT NOT NULL REFERENCES front_page_audits(id) ON DELETE CASCADE,
+  business_id   BIGINT NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
+  scored_at     TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  total         INTEGER NOT NULL,           -- 0..100
+  signals       JSONB NOT NULL,             -- {has_https: 10, has_viewport: 8, ...}
+  tier          TEXT NOT NULL,              -- 'A' | 'B' | 'C' | 'D'
+  category_rank INTEGER,                    -- rank within category at score time
+  category_n    INTEGER                     -- size of competitor cohort
+);
+CREATE INDEX IF NOT EXISTS idx_seo_scores_business ON seo_scores (business_id, scored_at DESC);
+CREATE INDEX IF NOT EXISTS idx_seo_scores_total ON seo_scores (total DESC);
+
+-- ─── Ingest run log ─────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS ingest_runs (
+  id          BIGSERIAL PRIMARY KEY,
+  source      TEXT NOT NULL,
+  started_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  finished_at TIMESTAMPTZ,
+  rows_in     INTEGER,
+  rows_new    INTEGER,
+  rows_updated INTEGER,
+  error_message TEXT,
+  notes       TEXT
+);
+
+-- ─── Migrations ledger ───────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS schema_migrations (
+  filename    TEXT PRIMARY KEY,
+  applied_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+INSERT INTO schema_migrations (filename) VALUES ('001_init.sql')
+  ON CONFLICT (filename) DO NOTHING;
+
+COMMIT;
diff --git a/db/migrations/002_enrichment.sql b/db/migrations/002_enrichment.sql
new file mode 100644
index 0000000..220a504
--- /dev/null
+++ b/db/migrations/002_enrichment.sql
@@ -0,0 +1,75 @@
+-- Phase v: enrichment for "who to contact and how" analysis.
+-- Two new tables: business_enrichment (1:1 with business) and business_contacts
+-- (1:many — each named human associated with the business).
+
+BEGIN;
+
+-- ─── business_enrichment ─────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS business_enrichment (
+  business_id        BIGINT PRIMARY KEY REFERENCES businesses(id) ON DELETE CASCADE,
+  enriched_at        TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  -- Ownership / structure
+  ownership_class    TEXT,                  -- 'corporate' | 'franchise' | 'independent' | 'nonprofit' | 'unknown'
+  parent_company     TEXT,                  -- e.g., "CVS Health Corporation"
+  parent_brand       TEXT,                  -- e.g., "CVS"
+  corporate_domain   TEXT,                  -- e.g., "cvshealth.com" (often different from cvs.com)
+  is_franchise       BOOLEAN DEFAULT FALSE,
+  is_chain           BOOLEAN DEFAULT FALSE,
+  -- Best contact route inferred from the data we have
+  best_contact_path  TEXT,                  -- 'local-manager' | 'corporate-pr' | 'owner-direct' | 'franchisee' | 'unknown'
+  best_contact_note  TEXT,
+  -- Niche metadata
+  cuisine            TEXT,                  -- restaurants only
+  price_band         TEXT,                  -- '$' | '$$' | '$$$' | '$$$$'
+  hours_json         JSONB,                 -- weekly hours if extracted
+  -- Social
+  linkedin_company   TEXT,                  -- LinkedIn company page URL
+  facebook_url       TEXT,
+  instagram_handle   TEXT,
+  twitter_handle     TEXT,
+  -- Reviews
+  rating_google      NUMERIC(3,2),
+  rating_yelp        NUMERIC(3,2),
+  review_count_total INTEGER,
+  -- Provenance + quality
+  enrichment_sources TEXT[],                -- which agents touched this row
+  confidence         NUMERIC(3,2),          -- 0..1 overall confidence
+  raw_signals        JSONB                  -- full detail blob for debugging
+);
+CREATE INDEX IF NOT EXISTS idx_enrichment_class ON business_enrichment (ownership_class);
+CREATE INDEX IF NOT EXISTS idx_enrichment_chain ON business_enrichment (is_chain) WHERE is_chain;
+
+-- ─── business_contacts ───────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS business_contacts (
+  id                 BIGSERIAL PRIMARY KEY,
+  business_id        BIGINT NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
+  -- Role within the business
+  role               TEXT NOT NULL,         -- 'owner' | 'co-owner' | 'gm' | 'chef' | 'manager' | 'franchisee'
+                                            --  | 'marketing' | 'corporate_local_rep' | 'pr' | 'media-relations'
+  person_name        TEXT,
+  person_title       TEXT,                  -- raw title pulled from source ("Owner", "Executive Chef", "GM")
+  person_linkedin    TEXT,
+  person_email       TEXT,                  -- only when sourced cleanly (Hunter, About page)
+  person_phone       TEXT,
+  -- Provenance
+  source             TEXT NOT NULL,         -- 'linkedin' | 'website-about' | 'gmb' | 'yelp' | 'manual' | 'other'
+  source_url         TEXT,
+  source_snippet     TEXT,                  -- the bit of text we pulled the contact from
+  confidence         NUMERIC(3,2),          -- 0..1
+  first_seen_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+  last_verified_at   TIMESTAMPTZ,
+  notes              TEXT,
+  -- Synthesised dedupe key (functional indexes can't be UNIQUE inline in PG;
+  -- compute it here and use a unique index below).
+  dedupe_key         TEXT GENERATED ALWAYS AS (COALESCE(person_linkedin, person_name, '')) STORED
+);
+CREATE UNIQUE INDEX IF NOT EXISTS uq_contacts_business_source_dedupe
+  ON business_contacts (business_id, source, dedupe_key);
+CREATE INDEX IF NOT EXISTS idx_contacts_business ON business_contacts (business_id);
+CREATE INDEX IF NOT EXISTS idx_contacts_role ON business_contacts (role);
+
+-- ─── Migrations ledger ───────────────────────────────────────────────
+INSERT INTO schema_migrations (filename) VALUES ('002_enrichment.sql')
+  ON CONFLICT (filename) DO NOTHING;
+
+COMMIT;
diff --git a/db/pool.ts b/db/pool.ts
new file mode 100644
index 0000000..603b9eb
--- /dev/null
+++ b/db/pool.ts
@@ -0,0 +1,25 @@
+/**
+ * PG connection pool — single instance, used everywhere.
+ * Reads DATABASE_URL from .env (Unix-socket path on local Mac).
+ */
+import 'dotenv/config';
+import pg from 'pg';
+
+const { Pool } = pg;
+
+export const pool = new Pool({
+  connectionString: process.env.DATABASE_URL || 'postgresql:///ventura_corridor?host=/tmp',
+  max: 8,
+  idleTimeoutMillis: 30_000,
+});
+
+pool.on('error', (err) => {
+  console.error('[pg pool error]', err);
+});
+
+export async function query<T extends pg.QueryResultRow = pg.QueryResultRow>(
+  text: string,
+  params?: unknown[],
+): Promise<pg.QueryResult<T>> {
+  return pool.query<T>(text, params);
+}
diff --git a/db/seed_segments.sql b/db/seed_segments.sql
new file mode 100644
index 0000000..174df2d
--- /dev/null
+++ b/db/seed_segments.sql
@@ -0,0 +1,31 @@
+-- Seed corridor_segments with the Ventura Blvd centerline polyline
+-- (same 27 anchor points used by the HERE/Foursquare ingest, west→east).
+TRUNCATE corridor_segments RESTART IDENTITY;
+
+INSERT INTO corridor_segments (ord, city, west_lat, west_lng, east_lat, east_lng) VALUES
+( 1, 'Calabasas',       34.1466, -118.6086, 34.1505, -118.6005),
+( 2, 'Calabasas',       34.1505, -118.6005, 34.1530, -118.5915),
+( 3, 'Woodland Hills',  34.1530, -118.5915, 34.1545, -118.5810),
+( 4, 'Woodland Hills',  34.1545, -118.5810, 34.1556, -118.5705),
+( 5, 'Woodland Hills',  34.1556, -118.5705, 34.1563, -118.5605),
+( 6, 'Woodland Hills',  34.1563, -118.5605, 34.1568, -118.5500),
+( 7, 'Woodland Hills',  34.1568, -118.5500, 34.1572, -118.5400),
+( 8, 'Woodland Hills',  34.1572, -118.5400, 34.1575, -118.5305),
+( 9, 'Tarzana',         34.1575, -118.5305, 34.1577, -118.5210),
+(10, 'Tarzana',         34.1577, -118.5210, 34.1580, -118.5115),
+(11, 'Tarzana',         34.1580, -118.5115, 34.1583, -118.5025),
+(12, 'Encino',          34.1583, -118.5025, 34.1586, -118.4930),
+(13, 'Encino',          34.1586, -118.4930, 34.1590, -118.4835),
+(14, 'Encino',          34.1590, -118.4835, 34.1593, -118.4740),
+(15, 'Encino',          34.1593, -118.4740, 34.1597, -118.4645),
+(16, 'Encino',          34.1597, -118.4645, 34.1600, -118.4550),
+(17, 'Sherman Oaks',    34.1600, -118.4550, 34.1604, -118.4455),
+(18, 'Sherman Oaks',    34.1604, -118.4455, 34.1606, -118.4360),
+(19, 'Sherman Oaks',    34.1606, -118.4360, 34.1599, -118.4270),
+(20, 'Sherman Oaks',    34.1599, -118.4270, 34.1565, -118.4200),
+(21, 'Sherman Oaks',    34.1565, -118.4200, 34.1505, -118.4140),
+(22, 'Studio City',     34.1505, -118.4140, 34.1450, -118.4070),
+(23, 'Studio City',     34.1450, -118.4070, 34.1421, -118.3985),
+(24, 'Studio City',     34.1421, -118.3985, 34.1402, -118.3895),
+(25, 'Studio City',     34.1402, -118.3895, 34.1385, -118.3820),
+(26, 'Studio City',     34.1385, -118.3820, 34.1370, -118.3760);
diff --git a/ecosystem.config.cjs b/ecosystem.config.cjs
new file mode 100644
index 0000000..fdf17f9
--- /dev/null
+++ b/ecosystem.config.cjs
@@ -0,0 +1,19 @@
+/**
+ * Ventura Corridor — PM2 process config.
+ * Local-only Mac. Server binds 127.0.0.1:9780.
+ */
+module.exports = {
+  apps: [
+    {
+      name: 'ventura-corridor',
+      script: 'node_modules/.bin/tsx',
+      args: 'src/server/index.ts',
+      cwd: __dirname,
+      env: { NODE_ENV: 'production' },
+      autorestart: true,
+      max_memory_restart: '512M',
+      out_file: 'logs/server.out.log',
+      error_file: 'logs/server.err.log',
+    },
+  ],
+};
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..cd0ae18
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,2005 @@
+{
+  "name": "ventura-corridor",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "ventura-corridor",
+      "version": "0.1.0",
+      "dependencies": {
+        "dotenv": "^16.4.5",
+        "express": "^4.21.1",
+        "helmet": "^8.1.0",
+        "pg": "^8.13.1",
+        "playwright": "^1.49.1"
+      },
+      "devDependencies": {
+        "@browserbasehq/sdk": "^2.10.0",
+        "@types/express": "^5.0.0",
+        "@types/node": "^22.10.2",
+        "@types/pg": "^8.11.10",
+        "playwright-core": "^1.59.1",
+        "tsx": "^4.19.2",
+        "typescript": "^5.7.2"
+      }
+    },
+    "node_modules/@browserbasehq/sdk": {
+      "version": "2.10.0",
+      "resolved": "https://registry.npmjs.org/@browserbasehq/sdk/-/sdk-2.10.0.tgz",
+      "integrity": "sha512-pOL4yW8P8AI2+N5y6zEP6XXKqIXtYyKunr1JXppqQDOyKLxxvZEDqQCHJXWUzqgx3R1tGWpn7m9AjXN7MeYInA==",
+      "dev": true,
+      "dependencies": {
+        "@types/node": "^18.11.18",
+        "@types/node-fetch": "^2.6.4",
+        "abort-controller": "^3.0.0",
+        "agentkeepalive": "^4.2.1",
+        "form-data-encoder": "1.7.2",
+        "formdata-node": "^4.3.2",
+        "node-fetch": "^2.6.7"
+      }
+    },
+    "node_modules/@browserbasehq/sdk/node_modules/@types/node": {
+      "version": "18.19.130",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
+      "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~5.26.4"
+      }
+    },
+    "node_modules/@browserbasehq/sdk/node_modules/undici-types": {
+      "version": "5.26.5",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+      "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz",
+      "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz",
+      "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz",
+      "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz",
+      "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz",
+      "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz",
+      "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz",
+      "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz",
+      "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz",
+      "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz",
+      "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz",
+      "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz",
+      "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz",
+      "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz",
+      "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz",
+      "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz",
+      "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz",
+      "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz",
+      "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz",
+      "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz",
+      "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz",
+      "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz",
+      "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz",
+      "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz",
+      "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz",
+      "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz",
+      "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@types/body-parser": {
+      "version": "1.19.6",
+      "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
+      "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/connect": "*",
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/connect": {
+      "version": "3.4.38",
+      "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
+      "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/express": {
+      "version": "5.0.6",
+      "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
+      "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/body-parser": "*",
+        "@types/express-serve-static-core": "^5.0.0",
+        "@types/serve-static": "^2"
+      }
+    },
+    "node_modules/@types/express-serve-static-core": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
+      "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*",
+        "@types/qs": "*",
+        "@types/range-parser": "*",
+        "@types/send": "*"
+      }
+    },
+    "node_modules/@types/http-errors": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
+      "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/node": {
+      "version": "22.19.17",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz",
+      "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~6.21.0"
+      }
+    },
+    "node_modules/@types/node-fetch": {
+      "version": "2.6.13",
+      "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
+      "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*",
+        "form-data": "^4.0.4"
+      }
+    },
+    "node_modules/@types/pg": {
+      "version": "8.20.0",
+      "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
+      "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*",
+        "pg-protocol": "*",
+        "pg-types": "^2.2.0"
+      }
+    },
+    "node_modules/@types/qs": {
+      "version": "6.15.0",
+      "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
+      "integrity": "sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/range-parser": {
+      "version": "1.2.7",
+      "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
+      "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/send": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
+      "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/serve-static": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
+      "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/http-errors": "*",
+        "@types/node": "*"
+      }
+    },
+    "node_modules/abort-controller": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+      "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "event-target-shim": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=6.5"
+      }
+    },
+    "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/agentkeepalive": {
+      "version": "4.6.0",
+      "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
+      "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "humanize-ms": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 8.0.0"
+      }
+    },
+    "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/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "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/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/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/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "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-signature": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+      "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+      "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/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4.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/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/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/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/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.27.7",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
+      "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.27.7",
+        "@esbuild/android-arm": "0.27.7",
+        "@esbuild/android-arm64": "0.27.7",
+        "@esbuild/android-x64": "0.27.7",
+        "@esbuild/darwin-arm64": "0.27.7",
+        "@esbuild/darwin-x64": "0.27.7",
+        "@esbuild/freebsd-arm64": "0.27.7",
+        "@esbuild/freebsd-x64": "0.27.7",
+        "@esbuild/linux-arm": "0.27.7",
+        "@esbuild/linux-arm64": "0.27.7",
+        "@esbuild/linux-ia32": "0.27.7",
+        "@esbuild/linux-loong64": "0.27.7",
+        "@esbuild/linux-mips64el": "0.27.7",
+        "@esbuild/linux-ppc64": "0.27.7",
+        "@esbuild/linux-riscv64": "0.27.7",
+        "@esbuild/linux-s390x": "0.27.7",
+        "@esbuild/linux-x64": "0.27.7",
+        "@esbuild/netbsd-arm64": "0.27.7",
+        "@esbuild/netbsd-x64": "0.27.7",
+        "@esbuild/openbsd-arm64": "0.27.7",
+        "@esbuild/openbsd-x64": "0.27.7",
+        "@esbuild/openharmony-arm64": "0.27.7",
+        "@esbuild/sunos-x64": "0.27.7",
+        "@esbuild/win32-arm64": "0.27.7",
+        "@esbuild/win32-ia32": "0.27.7",
+        "@esbuild/win32-x64": "0.27.7"
+      }
+    },
+    "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/event-target-shim": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+      "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=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/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/form-data": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+      "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "hasown": "^2.0.2",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/form-data-encoder": {
+      "version": "1.7.2",
+      "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
+      "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/formdata-node": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
+      "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "node-domexception": "1.0.0",
+        "web-streams-polyfill": "4.0.0-beta.3"
+      },
+      "engines": {
+        "node": ">= 12.20"
+      }
+    },
+    "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/get-tsconfig": {
+      "version": "4.14.0",
+      "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
+      "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "resolve-pkg-maps": "^1.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
+      }
+    },
+    "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/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "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/helmet": {
+      "version": "8.1.0",
+      "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
+      "integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=18.0.0"
+      }
+    },
+    "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/humanize-ms": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
+      "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.0.0"
+      }
+    },
+    "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/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/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/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/node-domexception": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+      "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
+      "deprecated": "Use your platform's native DOMException instead",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/jimmywarting"
+        },
+        {
+          "type": "github",
+          "url": "https://paypal.me/jimmywarting"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.5.0"
+      }
+    },
+    "node_modules/node-fetch": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+      "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "whatwg-url": "^5.0.0"
+      },
+      "engines": {
+        "node": "4.x || >=6.0.0"
+      },
+      "peerDependencies": {
+        "encoding": "^0.1.0"
+      },
+      "peerDependenciesMeta": {
+        "encoding": {
+          "optional": true
+        }
+      }
+    },
+    "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/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/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/resolve-pkg-maps": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+      "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+      }
+    },
+    "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/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/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/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/tr46": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+      "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/tsx": {
+      "version": "4.21.0",
+      "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
+      "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "~0.27.0",
+        "get-tsconfig": "^4.7.5"
+      },
+      "bin": {
+        "tsx": "dist/cli.mjs"
+      },
+      "engines": {
+        "node": ">=18.0.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      }
+    },
+    "node_modules/tsx/node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "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/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "6.21.0",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "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/web-streams-polyfill": {
+      "version": "4.0.0-beta.3",
+      "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
+      "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/webidl-conversions": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+      "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+      "dev": true,
+      "license": "BSD-2-Clause"
+    },
+    "node_modules/whatwg-url": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+      "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "tr46": "~0.0.3",
+        "webidl-conversions": "^3.0.0"
+      }
+    },
+    "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..b4d9fc3
--- /dev/null
+++ b/package.json
@@ -0,0 +1,34 @@
+{
+  "name": "ventura-corridor",
+  "version": "0.1.0",
+  "private": true,
+  "type": "module",
+  "description": "Ventura Blvd corridor business directory — inventory, front-page audit, SEO scoring, mind-map view. Local-only (Mac).",
+  "scripts": {
+    "server": "tsx src/server/index.ts",
+    "ingest:here": "tsx src/ingest/here_discover.ts",
+    "ingest:overpass": "tsx src/ingest/osm_overpass.ts",
+    "ingest:foursquare": "tsx src/ingest/foursquare.ts",
+    "crawl": "tsx src/crawl/front_page.ts",
+    "score": "tsx src/score/seo_score.ts",
+    "enrich:classify": "tsx src/enrich/classify_ownership.ts",
+    "enrich:owners": "tsx src/enrich/restaurant_owners.ts",
+    "migrate": "tsx db/migrate.ts"
+  },
+  "dependencies": {
+    "dotenv": "^16.4.5",
+    "express": "^4.21.1",
+    "helmet": "^8.1.0",
+    "pg": "^8.13.1",
+    "playwright": "^1.49.1"
+  },
+  "devDependencies": {
+    "@browserbasehq/sdk": "^2.10.0",
+    "@types/express": "^5.0.0",
+    "@types/node": "^22.10.2",
+    "@types/pg": "^8.11.10",
+    "playwright-core": "^1.59.1",
+    "tsx": "^4.19.2",
+    "typescript": "^5.7.2"
+  }
+}
diff --git a/public/3d.html b/public/3d.html
new file mode 100644
index 0000000..52dc529
--- /dev/null
+++ b/public/3d.html
@@ -0,0 +1,544 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Ventura Blvd · 3D · signal corridor</title>
+<style>
+  :root { --noir: #0a0a0c; --noir-rise: #131316; --rule: #2a2724; --ink: #f4f1ea; --ink-soft: #d8d2c5; --ink-mute: #8b857a; --metal: #b89968; --metal-glow: #d4b683; --rust: #6b3a2a; --serif: "Cormorant Garamond", Georgia, serif; --sans: -apple-system, "SF Pro Text", system-ui, sans-serif; }
+  *, *::before, *::after { box-sizing: border-box; }
+  html, body { margin: 0; height: 100%; background: var(--noir); color: var(--ink); font-family: var(--sans); font-weight: 300; overflow: hidden; }
+  header { position: fixed; top: 0; left: 0; right: 0; z-index: 100; padding: 18px 24px; display: flex; justify-content: space-between; align-items: baseline; gap: 24px; pointer-events: none; }
+  header h1 { font-family: var(--serif); font-weight: 300; font-size: 22px; margin: 0; letter-spacing: -0.005em; pointer-events: auto; }
+  header h1 em { font-style: italic; color: var(--metal); }
+  header .stage { font-size: 10px; letter-spacing: .28em; text-transform: uppercase; color: var(--ink-mute); pointer-events: auto; font-style: italic; font-family: var(--serif); }
+  .preview { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); z-index: 100; background: var(--noir-rise); border: 1px solid var(--rule); padding: 18px 24px; max-width: 520px; min-width: 280px; pointer-events: none; opacity: 0; transition: opacity 280ms ease; }
+  .preview.show { opacity: 1; pointer-events: auto; }
+  .preview .eyebrow { font-size: 9px; letter-spacing: .3em; text-transform: uppercase; color: var(--metal); font-weight: 500; margin: 0 0 8px; }
+  .preview .name { font-family: var(--serif); font-size: 22px; color: var(--metal-glow); margin: 0 0 6px; line-height: 1.18; letter-spacing: -.005em; }
+  .preview .meta { font-size: 11px; letter-spacing: .04em; color: var(--ink-mute); margin: 0 0 10px; line-height: 1.5; }
+  .preview .url { font-family: 'SFMono-Regular', Menlo, Consolas, monospace; font-size: 10px; letter-spacing: 0; color: var(--metal); word-break: break-all; }
+  #scene { position: absolute; inset: 0; }
+  .legend { position: fixed; top: 60px; right: 16px; z-index: 100; background: var(--noir-rise); border: 1px solid var(--rule); padding: 14px 18px; font-size: 10px; letter-spacing: .04em; color: var(--ink-mute); line-height: 1.6; max-width: 240px; }
+  .legend b { color: var(--metal); font-family: var(--serif); font-style: italic; font-weight: 400; font-size: 12px; letter-spacing: 0; }
+  .legend .row { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
+  .legend .swatch { width: 12px; height: 12px; border: 1px solid var(--rule); }
+  .legend .kbd { display: inline-block; padding: 1px 5px; border: 1px solid var(--rule); font-family: 'SFMono-Regular', monospace; font-size: 9px; color: var(--ink-soft); background: var(--noir); }
+  .home { position: fixed; bottom: 24px; left: 22px; z-index: 100; padding: 10px 16px; border: 1px solid var(--metal); color: var(--metal-glow); font-size: 10px; letter-spacing: .22em; text-transform: uppercase; text-decoration: none; }
+  .home:hover { background: var(--metal); color: var(--noir); }
+  .footer-disclaim { position: fixed; bottom: 0; left: 0; right: 0; padding: 6px 24px; z-index: 99; font-family: var(--serif); font-style: italic; color: var(--ink-mute); font-size: 10px; text-align: center; pointer-events: none; opacity: .65; }
+  .cam-mode { position: fixed; top: 60px; left: 22px; z-index: 100; padding: 8px 14px; border: 1px solid var(--rule); color: var(--metal); font-size: 9px; letter-spacing: .28em; text-transform: uppercase; background: var(--noir-rise); }
+
+  /* On-load orientation overlay — 3s auto-fade so first-time visitors get a moment to orient. */
+  #orient { position: fixed; inset: 0; z-index: 200; display: flex; align-items: center; justify-content: center; pointer-events: none; background: radial-gradient(ellipse at center, rgba(10,10,12,0.0) 30%, rgba(10,10,12,0.55) 100%); transition: opacity 700ms cubic-bezier(.16,.84,.3,1); }
+  #orient.hide { opacity: 0; }
+  #orient .card { font-family: var(--serif); font-style: italic; font-weight: 300; color: var(--metal-glow); text-align: center; padding: 22px 36px; border: 1px solid var(--rule); background: rgba(19,19,22,0.92); backdrop-filter: blur(8px); }
+  #orient .l1 { font-size: 22px; line-height: 1.3; margin: 0 0 6px; letter-spacing: -0.005em; }
+  #orient .l2 { font-style: normal; font-family: var(--sans); font-weight: 500; font-size: 10px; letter-spacing: .26em; color: var(--ink-mute); text-transform: uppercase; }
+  #orient .l2 .kbd { display: inline-block; padding: 1px 6px; margin: 0 4px; border: 1px solid var(--rule); font-family: 'SFMono-Regular', monospace; font-size: 9px; color: var(--ink-soft); background: var(--noir); letter-spacing: 0; text-transform: none; }
+</style>
+</head>
+<body>
+<header>
+  <h1>Ventura <em>Boulevard</em> · the signal corridor</h1>
+  <span class="stage">Three.js</span>
+</header>
+<div id="orient" aria-hidden="true">
+  <div class="card">
+    <p class="l1">Drive mode active.</p>
+    <p class="l2"><span class="kbd">Space</span> to orbit · <span class="kbd">click</span> any building to expand its site</p>
+  </div>
+</div>
+<div class="cam-mode" id="cam-mode">Drive · auto</div>
+<div class="legend">
+  <b>How to read this</b><br>
+  <div style="margin: 8px 0 4px; font-style: italic; color: var(--ink-soft); font-family: var(--serif); font-size: 11px;">Every front-page signal on Ventura Blvd, lit by SEO score.</div>
+  <div class="row"><span class="swatch" style="background: var(--metal-glow);"></span> Independent</div>
+  <div class="row"><span class="swatch" style="background: var(--rust);"></span> Franchise</div>
+  <div class="row"><span class="swatch" style="background: var(--noir-rise);"></span> Corporate / chain</div>
+  <div class="row"><span class="swatch" style="background: #2a2724;"></span> No website / unscored</div>
+  <div style="margin-top: 10px; padding-top: 8px; border-top: 1px solid var(--rule);">
+    <span class="kbd">Space</span> · drive ⇄ orbit<br>
+    <span class="kbd">click</span> a car · expand site
+  </div>
+</div>
+<a class="home" href="/">← 2D map</a>
+<div class="preview" id="preview" role="status">
+  <p class="eyebrow" id="pv-eyebrow">—</p>
+  <p class="name" id="pv-name">—</p>
+  <p class="meta" id="pv-meta">—</p>
+  <p class="url" id="pv-url">—</p>
+</div>
+<p class="footer-disclaim">Directory only. No referrals, no fee-splits, no representation.</p>
+<div id="scene"></div>
+
+<script type="importmap">
+{
+  "imports": {
+    "three": "https://unpkg.com/three@0.168.0/build/three.module.js",
+    "three/addons/": "https://unpkg.com/three@0.168.0/examples/jsm/"
+  }
+}
+</script>
+
+<script type="module">
+import * as THREE from 'three';
+import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
+
+const root = document.getElementById('scene');
+const scene = new THREE.Scene();
+scene.fog = new THREE.FogExp2(0x0a0a0c, 0.008);
+scene.background = new THREE.Color(0x0a0a0c);
+
+const camera = new THREE.PerspectiveCamera(58, root.clientWidth / root.clientHeight, 0.1, 4000);
+camera.position.set(0, 18, 50);
+
+const renderer = new THREE.WebGLRenderer({ antialias: true });
+renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
+renderer.setSize(root.clientWidth, root.clientHeight);
+renderer.outputColorSpace = THREE.SRGBColorSpace;
+root.appendChild(renderer.domElement);
+
+const controls = new OrbitControls(camera, renderer.domElement);
+controls.enableDamping = true;
+controls.dampingFactor = 0.06;
+controls.minDistance = 6;
+controls.maxDistance = 800;
+controls.enabled = false;       // start in DRIVE mode
+
+// Lighting
+scene.add(new THREE.AmbientLight(0x1c1916, 0.55));
+const key = new THREE.DirectionalLight(0xd4b683, 1.15);
+key.position.set(160, 200, 80);
+scene.add(key);
+const rim = new THREE.DirectionalLight(0x4060a0, 0.3);
+rim.position.set(-180, 60, -120);
+scene.add(rim);
+
+// Ground
+const ground = new THREE.Mesh(
+  new THREE.PlaneGeometry(2400, 800),
+  new THREE.MeshStandardMaterial({ color: 0x131316, roughness: 0.95, metalness: 0.1 })
+);
+ground.rotation.x = -Math.PI / 2;
+scene.add(ground);
+
+// ─── Geo projection: lat/lng → scene units ───
+// Choose origin = polyline midpoint. 1 scene unit ≈ 25 m.
+const SCALE_METERS_PER_UNIT = 25;
+let origin = { lat: 34.15, lng: -118.45 };
+function projectLatLng(lat, lng) {
+  const dLat = (lat - origin.lat) * 111000;                                 // m north
+  const dLng = (lng - origin.lng) * 111000 * Math.cos(origin.lat * Math.PI / 180); // m east
+  return new THREE.Vector3(dLng / SCALE_METERS_PER_UNIT, 0, -dLat / SCALE_METERS_PER_UNIT);
+}
+
+// ─── Load + build the corridor curve, then everything else ───
+const cars = [];
+const buildings = [];
+const raycaster = new THREE.Raycaster();
+const pointer = new THREE.Vector2();
+const previewEl = document.getElementById('preview');
+
+function ownershipColor(o) {
+  if (o === 'corporate') return 0x131316;
+  if (o === 'franchise') return 0x6b3a2a;
+  if (o === 'independent') return 0xb89968;
+  return 0x2a2724;
+}
+function ownershipEmissive(o) {
+  if (o === 'corporate') return 0x1a1815;
+  if (o === 'franchise') return 0x3a1c10;
+  if (o === 'independent') return 0x5a4220;
+  return 0x0a0a0c;
+}
+
+let curve = null;
+
+async function init() {
+  // Polyline first — it sets the curve everything else hangs on.
+  const poly = await fetch('/api/polyline').then(r => r.json());
+  const pts = poly.points_lat_lng || [];
+  if (!pts.length) return;
+  // Re-center origin on polyline midpoint
+  origin = { lat: pts[Math.floor(pts.length/2)][0], lng: pts[Math.floor(pts.length/2)][1] };
+  const curvePts = pts.map(([la, ln]) => projectLatLng(la, ln));
+  curve = new THREE.CatmullRomCurve3(curvePts, false, 'centripetal', 0.5);
+
+  // Sample the curve into a smooth tube for the road
+  const tubeSeg = Math.min(800, curvePts.length * 8);
+  const tube = new THREE.TubeGeometry(curve, tubeSeg, 1.4, 8, false);
+  const roadMat = new THREE.MeshStandardMaterial({ color: 0x1a1815, roughness: 0.85, metalness: 0.18 });
+  const road = new THREE.Mesh(tube, roadMat);
+  road.position.y = 0.15;
+  scene.add(road);
+
+  // Champagne hairline along the road's centerline (visible from above)
+  const linePts = [];
+  for (let i = 0; i <= tubeSeg; i++) {
+    const p = curve.getPointAt(i / tubeSeg);
+    linePts.push(new THREE.Vector3(p.x, 0.32, p.z));
+  }
+  const lineGeo = new THREE.BufferGeometry().setFromPoints(linePts);
+  const lineMat = new THREE.LineBasicMaterial({ color: 0xd4b683, transparent: true, opacity: 0.78 });
+  scene.add(new THREE.Line(lineGeo, lineMat));
+
+  // ─── Mile markers (Roman numerals I-XVII along the curve) ───
+  // Total length ≈ length of the Catmull curve in scene units; each segment
+  // covers ~25m, so 17 markers = corridor.length / 17 spacing.
+  function romanFor(n) {
+    const map = ['I','II','III','IV','V','VI','VII','VIII','IX','X','XI','XII','XIII','XIV','XV','XVI','XVII'];
+    return map[n - 1] || String(n);
+  }
+  function makeTextSprite(text, size = 256, fillCSS = '#b89968') {
+    const cv = document.createElement('canvas');
+    cv.width = size; cv.height = size;
+    const ctx = cv.getContext('2d');
+    ctx.clearRect(0, 0, size, size);
+    ctx.fillStyle = fillCSS;
+    ctx.font = `italic 300 ${Math.round(size * 0.62)}px "Cormorant Garamond", Georgia, serif`;
+    ctx.textAlign = 'center';
+    ctx.textBaseline = 'middle';
+    ctx.fillText(text, size / 2, size / 2 + size * 0.04);
+    const tex = new THREE.CanvasTexture(cv);
+    tex.colorSpace = THREE.SRGBColorSpace;
+    return new THREE.SpriteMaterial({ map: tex, transparent: true, opacity: 0.92 });
+  }
+  for (let i = 1; i <= 17; i++) {
+    const t = (i - 1) / 16;     // 17 evenly-spaced positions across the polyline
+    const p = curve.getPointAt(t);
+    const tan = curve.getTangentAt(t);
+    const perp = new THREE.Vector3(-tan.z, 0, tan.x).normalize();
+    // Floating sprite ~14m off the south side of the road
+    const off = 14 / SCALE_METERS_PER_UNIT * 25;     // ~14 scene units
+    const spr = new THREE.Sprite(makeTextSprite(romanFor(i)));
+    spr.position.set(p.x + perp.x * off, 8, p.z + perp.z * off);
+    spr.scale.set(8, 8, 1);
+    scene.add(spr);
+    // Plinth — a tiny black-marble pedestal
+    const plinth = new THREE.Mesh(
+      new THREE.BoxGeometry(1.5, 4.5, 1.5),
+      new THREE.MeshStandardMaterial({ color: 0x131316, roughness: 0.55, metalness: 0.4 }),
+    );
+    plinth.position.set(p.x + perp.x * off, 2.25, p.z + perp.z * off);
+    scene.add(plinth);
+    // Champagne hairline cap on the plinth
+    const cap = new THREE.Mesh(
+      new THREE.BoxGeometry(1.7, 0.08, 1.7),
+      new THREE.MeshStandardMaterial({ color: 0xb89968, emissive: 0x6b5230, emissiveIntensity: 0.4 }),
+    );
+    cap.position.set(p.x + perp.x * off, 4.55, p.z + perp.z * off);
+    scene.add(cap);
+  }
+
+  // ─── Polyline → t lookup table (used by city plinths AND building projection) ───
+  const polyTs = [];
+  for (let i = 0; i < pts.length; i++) {
+    polyTs.push({ t: i / (pts.length - 1), pos: curvePts[i] });
+  }
+
+  // ─── City-boundary plinths ───
+  // Approximate city transitions along Ventura Blvd by longitude:
+  //   Tarzana → Encino   ≈ -118.535
+  //   Encino → Sherman Oaks ≈ -118.466
+  //   Sherman Oaks → Studio City ≈ -118.405
+  //   Studio City → North Hollywood ≈ -118.379
+  const cityBoundaries = [
+    { lng: -118.535, label: 'TARZANA · ENCINO' },
+    { lng: -118.466, label: 'ENCINO · SHERMAN OAKS' },
+    { lng: -118.405, label: 'SHERMAN OAKS · STUDIO CITY' },
+    { lng: -118.379, label: 'STUDIO CITY · NOHO' },
+  ];
+  for (const cb of cityBoundaries) {
+    // Find polyline t closest to that longitude
+    let bestT = 0, bestDx = Infinity;
+    for (let i = 0; i < polyTs.length; i++) {
+      const lat = pts[i][0]; const lng = pts[i][1];
+      const dx = Math.abs(lng - cb.lng);
+      if (dx < bestDx) { bestDx = dx; bestT = polyTs[i].t; }
+    }
+    const p = curve.getPointAt(bestT);
+    const tan = curve.getTangentAt(bestT);
+    const perp = new THREE.Vector3(-tan.z, 0, tan.x).normalize();
+    // Two plinths flanking the road (north + south side)
+    for (const side of [1, -1]) {
+      const off = 5;
+      const obelisk = new THREE.Mesh(
+        new THREE.ConeGeometry(0.55, 6, 4),
+        new THREE.MeshStandardMaterial({ color: 0x6b5230, roughness: 0.55, metalness: 0.55, emissive: 0x3a2e1a, emissiveIntensity: 0.25 }),
+      );
+      obelisk.rotation.y = Math.PI / 4;        // square base aligns to cardinal
+      obelisk.position.set(p.x + perp.x * off * side, 3, p.z + perp.z * off * side);
+      scene.add(obelisk);
+    }
+    // Small ground-plate text label
+    const lbl = new THREE.Sprite(makeTextSprite(cb.label, 512, '#8b857a'));
+    const labelOff = 8;
+    lbl.position.set(p.x, 0.4, p.z + labelOff);
+    lbl.scale.set(14, 3.5, 1);
+    scene.add(lbl);
+  }
+
+  // Buildings — one per business, projected onto curve, alternating sides
+  const r = await fetch('/api/businesses?limit=400').then(r => r.json());
+  const businesses = (r.rows || []).filter(b => b.lat != null && b.lng != null);
+
+  // Spatial-index helper for projecting buildings (polyTs already built above).
+  function nearestT(pos) {
+    let best = polyTs[0]; let bestDist = Infinity;
+    for (const p of polyTs) {
+      const dx = p.pos.x - pos.x, dz = p.pos.z - pos.z;
+      const d = dx*dx + dz*dz;
+      if (d < bestDist) { bestDist = d; best = p; }
+    }
+    return best.t;
+  }
+
+  // ─── Screenshot billboards (THE main visual) ───
+  // For every business with a captured screenshot, stand a 16:10 plane
+  // along the road textured with the actual front-page screenshot.
+  // For businesses without a screenshot, fall back to a low ownership-colored
+  // pedestal so they still register as a presence on the corridor.
+  const texLoader = new THREE.TextureLoader();
+  // A frame mesh wraps each screenshot — bronze-edged "billboard".
+  const frameMat = new THREE.MeshStandardMaterial({
+    color: 0x2a2724, roughness: 0.6, metalness: 0.6,
+    emissive: 0x6b5230, emissiveIntensity: 0.08,
+  });
+  const postMat = new THREE.MeshStandardMaterial({
+    color: 0x131316, roughness: 0.5, metalness: 0.55,
+  });
+  // Shadow / cap below screenshot
+  const capMat = new THREE.MeshStandardMaterial({
+    color: 0xb89968, emissive: 0x6b5230, emissiveIntensity: 0.4,
+  });
+
+  let buildingIdx = 0;
+  for (const b of businesses) {
+    const pos = projectLatLng(b.lat, b.lng);
+    const t = nearestT(pos);
+    const onCurve = curve.getPointAt(t);
+    const tangent = curve.getTangentAt(t);
+    const perp = new THREE.Vector3(-tangent.z, 0, tangent.x).normalize();
+    const side = (buildingIdx++ % 2 === 0) ? 1 : -1;
+    const offset = 5.5 + Math.random() * 3.5;
+    const baseX = onCurve.x + perp.x * offset * side;
+    const baseZ = onCurve.z + perp.z * offset * side;
+
+    if (b.screenshot_path) {
+      // ─── Screenshot billboard ───
+      // Plane is 16:10 = 8w × 5h units, sitting on a 0.6h post + 0.4h cap.
+      const W = 8, H = 5;
+      const group = new THREE.Group();
+      group.position.set(baseX, 0, baseZ);
+
+      // Face perpendicular to the road (flat side faces the boulevard)
+      // Direction the plane should face = from the building toward the road
+      const faceVec = new THREE.Vector3(-perp.x * side, 0, -perp.z * side);
+      const yaw = Math.atan2(faceVec.x, faceVec.z);
+      group.rotation.y = yaw;
+
+      // Posts (two thin black-marble verticals supporting the billboard)
+      for (const px of [-W / 2 + 0.15, W / 2 - 0.15]) {
+        const post = new THREE.Mesh(new THREE.BoxGeometry(0.18, 4.5, 0.18), postMat);
+        post.position.set(px, 2.25, 0);
+        group.add(post);
+      }
+      // Frame (subtle bronze backplate, slightly larger than screenshot)
+      const frame = new THREE.Mesh(new THREE.BoxGeometry(W + 0.2, H + 0.2, 0.08), frameMat);
+      frame.position.set(0, 4.5 + H / 2, -0.05);
+      group.add(frame);
+      // Champagne hairline cap rod under the billboard
+      const cap = new THREE.Mesh(new THREE.BoxGeometry(W + 0.1, 0.06, 0.16), capMat);
+      cap.position.set(0, 4.45, 0);
+      group.add(cap);
+      // Screenshot plane — front face only, double-sided so back glows softly too
+      const screenshotUrl = '/' + b.screenshot_path;
+      const tex = texLoader.load(screenshotUrl, (t) => {
+        t.colorSpace = THREE.SRGBColorSpace;
+        t.minFilter = THREE.LinearMipmapLinearFilter;
+        t.generateMipmaps = true;
+        t.anisotropy = 8;
+      });
+      const planeMat = new THREE.MeshBasicMaterial({ map: tex, side: THREE.FrontSide });
+      const plane = new THREE.Mesh(new THREE.PlaneGeometry(W, H), planeMat);
+      plane.position.set(0, 4.5 + H / 2, 0.03);
+      plane.userData = { business: b };
+      group.add(plane);
+      // Tier dot — small champagne emissive ball above the cap, sized by SEO tier
+      const dotR = b.seo_tier === 'A' ? 0.25 : b.seo_tier === 'B' ? 0.18 : 0.13;
+      const dotColor = b.seo_score >= 90 ? 0xd4b683 : b.seo_score >= 70 ? 0xb89968 : 0xa07a40;
+      const dot = new THREE.Mesh(new THREE.SphereGeometry(dotR, 12, 12),
+        new THREE.MeshBasicMaterial({ color: dotColor }));
+      dot.position.set(0, 4.5 + H + 0.4, 0);
+      group.add(dot);
+
+      group.userData = { business: b };
+      scene.add(group);
+      buildings.push(plane);   // raycaster targets the screenshot plane
+    } else {
+      // ─── Pedestal fallback for businesses without screenshots ───
+      const h = b.website ? 2.2 : 1.4;
+      const color = b.website ? ownershipColor(b.ownership_class) : 0x1a1815;
+      const emiss = b.website ? ownershipEmissive(b.ownership_class) : 0x0a0a0c;
+      const m = new THREE.Mesh(
+        new THREE.BoxGeometry(2.2, h, 2.2),
+        new THREE.MeshStandardMaterial({ color, emissive: emiss, emissiveIntensity: 0.08, roughness: 0.85, metalness: 0.18 }),
+      );
+      m.position.set(baseX, h / 2, baseZ);
+      m.userData = { business: b };
+      scene.add(m);
+      buildings.push(m);
+    }
+  }
+
+  // Cars — one per business that has a website, traveling along the curve
+  const drivers = businesses.filter(b => b.website).slice(0, 60);
+  drivers.forEach((b, i) => {
+    const carGroup = new THREE.Group();
+    const tier = b.seo_score >= 90 ? 0xd4b683 : b.seo_score >= 70 ? 0xb89968 : b.seo_score >= 50 ? 0xa07a40 : 0x5a4220;
+    const body = new THREE.Mesh(
+      new THREE.BoxGeometry(1.6, 0.5, 0.75),
+      new THREE.MeshStandardMaterial({ color: 0x0a0a0c, roughness: 0.4, metalness: 0.65, emissive: tier, emissiveIntensity: 0.45 }),
+    );
+    body.position.y = 0.4;
+    carGroup.add(body);
+    const cabin = new THREE.Mesh(
+      new THREE.BoxGeometry(0.95, 0.32, 0.62),
+      new THREE.MeshStandardMaterial({ color: 0x2a2724, roughness: 0.5, metalness: 0.4 }),
+    );
+    cabin.position.set(-0.05, 0.82, 0);
+    carGroup.add(cabin);
+    const head = new THREE.Mesh(new THREE.SphereGeometry(0.09, 8, 8), new THREE.MeshBasicMaterial({ color: 0xd4b683 }));
+    head.position.set(0.85, 0.4, 0);
+    carGroup.add(head);
+
+    carGroup.userData = {
+      business: b,
+      t: Math.random(),
+      speed: 0.0035 + Math.random() * 0.005,
+      direction: i % 2 === 0 ? 1 : -1,
+      laneOffset: i % 2 === 0 ? -1 : 1,
+    };
+    scene.add(carGroup);
+    cars.push(carGroup);
+  });
+}
+
+init();
+
+// Auto-fade the orientation overlay 3.2s after the scene mounts.
+setTimeout(() => {
+  const o = document.getElementById('orient');
+  if (o) o.classList.add('hide');
+}, 3200);
+
+// ─── Interaction ───
+const pvEyebrow = document.getElementById('pv-eyebrow');
+const pvName = document.getElementById('pv-name');
+const pvMeta = document.getElementById('pv-meta');
+const pvUrl = document.getElementById('pv-url');
+
+function showPreview(b) {
+  pvEyebrow.textContent = (b.ownership_class || 'unclassified') + (b.parent_brand ? ' · ' + b.parent_brand : '');
+  pvName.textContent = b.name;
+  const tier = b.seo_tier ? `tier ${b.seo_tier}` : 'unscored';
+  const rank = b.category_rank ? `· rank ${b.category_rank}/${b.category_n}` : '';
+  pvMeta.textContent = `${b.category || ''} · ${b.city || ''} · ${tier} ${rank}`.trim();
+  pvUrl.textContent = b.website || '—';
+  previewEl.classList.add('show');
+  // Auto-dismiss after 7s of no further click
+  clearTimeout(showPreview._t);
+  showPreview._t = setTimeout(() => previewEl.classList.remove('show'), 7000);
+}
+
+renderer.domElement.addEventListener('pointermove', (e) => {
+  const rect = renderer.domElement.getBoundingClientRect();
+  pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
+  pointer.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
+});
+renderer.domElement.addEventListener('click', () => {
+  raycaster.setFromCamera(pointer, camera);
+  const hits = raycaster.intersectObjects([...cars, ...buildings], true);
+  for (const h of hits) {
+    let g = h.object;
+    while (g && !g.userData?.business) g = g.parent;
+    if (g && g.userData?.business) { showPreview(g.userData.business); return; }
+  }
+});
+
+// ─── Camera mode: DRIVE (chase) by default; SPACE toggles to ORBIT ───
+let camMode = 'drive';
+const camLabel = document.getElementById('cam-mode');
+let driveT = 0.05;
+const camOffset = new THREE.Vector3(0, 6, -14);
+const camLook = new THREE.Vector3();
+
+function setMode(m) {
+  camMode = m;
+  controls.enabled = (m === 'orbit');
+  camLabel.textContent = m === 'drive' ? 'Drive · auto' : 'Orbit · manual';
+}
+window.addEventListener('keydown', (e) => {
+  if (e.code === 'Space') { e.preventDefault(); setMode(camMode === 'drive' ? 'orbit' : 'drive'); }
+});
+let lastInteractionAt = Date.now();
+controls.addEventListener('start', () => { lastInteractionAt = Date.now(); });
+
+window.addEventListener('resize', () => {
+  camera.aspect = root.clientWidth / root.clientHeight;
+  camera.updateProjectionMatrix();
+  renderer.setSize(root.clientWidth, root.clientHeight);
+});
+
+// ─── Animate ───
+const clock = new THREE.Clock();
+function loop() {
+  const dt = clock.getDelta();
+
+  // Cars travel along the curve
+  if (curve) {
+    for (const c of cars) {
+      c.userData.t += c.userData.speed * c.userData.direction * dt * 30;
+      if (c.userData.t > 1) c.userData.t -= 1;
+      if (c.userData.t < 0) c.userData.t += 1;
+      const p = curve.getPointAt(c.userData.t);
+      const tan = curve.getTangentAt(c.userData.t);
+      // Lane offset perpendicular to road
+      const perp = new THREE.Vector3(-tan.z, 0, tan.x).normalize();
+      c.position.set(
+        p.x + perp.x * c.userData.laneOffset,
+        0,
+        p.z + perp.z * c.userData.laneOffset,
+      );
+      // Face direction of travel
+      const facing = c.userData.direction === 1 ? tan : tan.clone().multiplyScalar(-1);
+      c.lookAt(c.position.x + facing.x, c.position.y, c.position.z + facing.z);
+    }
+
+    // Drive-cam: slow east-bound chase along the curve
+    if (camMode === 'drive') {
+      driveT += dt * 0.012;
+      if (driveT > 1) driveT -= 1;
+      const p = curve.getPointAt(driveT);
+      const tan = curve.getTangentAt(driveT);
+      // y=7.5 puts the eye line right at the screenshot center (4.5 + 5/2 = 7).
+      camera.position.set(p.x - tan.x * 14, 7.5 + Math.sin(driveT * Math.PI * 4) * 0.4, p.z - tan.z * 14);
+      camLook.set(p.x + tan.x * 8, 7.0, p.z + tan.z * 8);
+      camera.lookAt(camLook);
+    }
+  }
+
+  // Idle drift back to drive mode after 8s
+  if (camMode === 'orbit' && Date.now() - lastInteractionAt > 8000) {
+    setMode('drive');
+  }
+
+  controls.update();
+  renderer.render(scene, camera);
+  requestAnimationFrame(loop);
+}
+loop();
+</script>
+</body>
+</html>
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..c6e17c6
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,354 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Ventura Blvd Corridor — local viewer</title>
+<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin="">
+<style>
+  :root {
+    --noir: #0a0a0c; --noir-rise: #131316; --rule: #2a2724;
+    --ink: #f4f1ea; --ink-soft: #d8d2c5; --ink-mute: #8b857a;
+    --metal: #b89968; --metal-glow: #d4b683; --metal-deep: #8a7044;
+    --serif: "Cormorant Garamond", Georgia, serif;
+    --sans: -apple-system, "SF Pro Text", "Inter", system-ui, sans-serif;
+  }
+  *, *::before, *::after { box-sizing: border-box; }
+  html, body { margin: 0; height: 100%; background: var(--noir); color: var(--ink); font-family: var(--sans); font-weight: 300; }
+  header { position: fixed; top: 0; left: 0; right: 0; z-index: 1000; padding: 16px 22px; background: linear-gradient(180deg, rgba(10,10,12,.92), rgba(10,10,12,0)); backdrop-filter: blur(8px); display: flex; align-items: baseline; justify-content: space-between; gap: 24px; }
+  h1 { font-family: var(--serif); font-weight: 300; font-size: 22px; margin: 0; letter-spacing: -0.005em; }
+  h1 em { font-style: italic; color: var(--metal); }
+  .stats { font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--ink-mute); display: flex; gap: 22px; flex-wrap: wrap; }
+  .stats b { color: var(--metal); font-family: var(--serif); font-style: italic; font-weight: 400; font-size: 14px; letter-spacing: 0.01em; text-transform: none; margin-right: 6px; font-variant-numeric: tabular-nums; }
+
+  #map { position: absolute; inset: 0; }
+  .leaflet-tile-pane { filter: invert(1) hue-rotate(180deg) saturate(0.6) brightness(0.92) contrast(0.92); }
+
+  /* Grid search */
+  .gs-wrap { position: fixed; top: 56px; left: 22px; z-index: 950; width: clamp(280px, 32vw, 420px); }
+  .gs-input { width: 100%; padding: 14px 18px; background: var(--noir-rise); border: 1px solid var(--rule); color: var(--ink); font-family: var(--sans); font-size: 14px; font-weight: 300; letter-spacing: 0.005em; outline: none; transition: border-color 200ms ease; }
+  .gs-input::placeholder { color: var(--ink-mute); font-style: italic; font-family: var(--serif); }
+  .gs-input:focus { border-color: var(--metal); box-shadow: 0 0 0 3px rgba(184,153,104,0.12); }
+  .gs-results { background: var(--noir-rise); border: 1px solid var(--rule); border-top: none; max-height: 60vh; overflow-y: auto; display: none; }
+  .gs-results.show { display: block; }
+  .gs-empty { padding: 18px; font-family: var(--serif); font-style: italic; color: var(--ink-mute); font-size: 13px; }
+  .gs-grid { display: grid; grid-template-columns: 1fr; gap: 0; }
+  /* Search cells — clean editorial: name on top, meta beneath. No dots
+     (the color-dot pattern is a DW-micro-site convention, not universal). */
+  .gs-cell { padding: 14px 18px; border-bottom: 1px solid var(--rule); cursor: pointer; transition: background 160ms ease, border-color 160ms ease; display: flex; flex-direction: column; gap: 4px; }
+  .gs-cell:last-child { border-bottom: none; }
+  .gs-cell:hover { background: rgba(184,153,104,0.04); border-bottom-color: var(--metal); }
+  .gs-cell-name { font-family: var(--serif); font-weight: 400; color: var(--ink); font-size: 15px; line-height: 1.18; letter-spacing: -0.005em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+  .gs-cell-meta { font-size: 11px; color: var(--ink-mute); letter-spacing: 0.04em; line-height: 1.4; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+  .gs-cell-meta .pin { color: var(--metal); font-style: italic; font-family: var(--serif); font-size: 12px; margin-right: 4px; }
+  .gs-cell-meta .domain { color: var(--metal); font-family: 'SFMono-Regular', Menlo, Consolas, monospace; font-size: 10px; letter-spacing: 0; text-transform: lowercase; margin-left: 6px; opacity: 0.85; }
+  .gs-count { padding: 8px 18px; font-size: 9px; letter-spacing: 0.28em; text-transform: uppercase; color: var(--ink-mute); border-bottom: 1px solid var(--rule); background: rgba(0,0,0,0.2); }
+
+  /* Detail panel */
+  #panel { position: fixed; top: 56px; right: 0; bottom: 0; width: 380px; max-width: 90vw; z-index: 900; background: var(--noir-rise); border-left: 1px solid var(--rule); transform: translateX(100%); transition: transform 360ms cubic-bezier(.16,.84,.3,1); overflow-y: auto; }
+  #panel.show { transform: none; }
+  #panel .close { position: absolute; top: 14px; right: 16px; background: transparent; color: var(--ink-mute); border: 1px solid var(--rule); padding: 6px 10px; font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; font-weight: 500; cursor: pointer; }
+  #panel .close:hover { color: var(--metal); border-color: var(--metal); }
+  .pn-body { padding: 28px 28px 40px; }
+  .pn-eyebrow { font-size: 10px; letter-spacing: 0.28em; text-transform: uppercase; color: var(--metal); font-weight: 500; margin: 0 0 10px; }
+  .pn-name { font-family: var(--serif); font-weight: 400; font-size: 24px; line-height: 1.18; color: var(--ink); margin: 0 0 8px; letter-spacing: -0.005em; }
+  .pn-meta { font-size: 12px; color: var(--ink-mute); line-height: 1.6; margin: 0 0 18px; }
+  .pn-row { display: flex; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid var(--rule); font-size: 13px; }
+  .pn-row .k { color: var(--ink-mute); letter-spacing: 0.04em; }
+  .pn-row .v { color: var(--ink); font-family: var(--serif); font-style: italic; font-weight: 400; font-size: 14px; }
+  .pn-row .v.metal { color: var(--metal); }
+  .pn-website { display: block; margin-top: 18px; padding: 14px 18px; border: 1px solid var(--metal); color: var(--metal-glow); font-size: 11px; letter-spacing: 0.22em; text-transform: uppercase; font-weight: 500; text-align: center; text-decoration: none; transition: background 200ms ease, color 200ms ease; }
+  .pn-website:hover { background: var(--metal); color: var(--noir); }
+  .pn-empty { font-style: italic; color: var(--ink-mute); font-family: var(--serif); font-size: 14px; }
+
+  .leaflet-popup-content-wrapper { background: var(--noir-rise); color: var(--ink); border-radius: 0; border: 1px solid var(--rule); }
+  .leaflet-popup-content { margin: 12px 16px; font-family: var(--sans); font-size: 12px; }
+  .leaflet-popup-content b { font-family: var(--serif); font-weight: 400; font-size: 14px; color: var(--metal-glow); display: block; margin-bottom: 4px; }
+  .leaflet-popup-tip { background: var(--noir-rise); border: 1px solid var(--rule); }
+
+  /* Live build-status strip — bottom-right */
+  #build { position: fixed; bottom: 16px; right: 16px; z-index: 950; background: var(--noir-rise); border: 1px solid var(--rule); padding: 14px 18px; max-width: 360px; font-size: 11px; line-height: 1.5; color: var(--ink-soft); transition: opacity 220ms ease; }
+  #build.refreshing { opacity: 0.6; }
+  #build .b-eyebrow { font-size: 9px; letter-spacing: .3em; text-transform: uppercase; color: var(--metal); font-weight: 500; margin: 0 0 8px; display: flex; justify-content: space-between; align-items: center; }
+  #build .b-eyebrow .ts { color: var(--ink-mute); letter-spacing: .12em; font-style: italic; font-family: var(--serif); text-transform: none; font-size: 10px; }
+  #build .b-grid { display: grid; grid-template-columns: 1fr auto; gap: 4px 14px; font-variant-numeric: tabular-nums; }
+  #build .b-grid b { font-family: var(--serif); font-style: italic; font-weight: 400; color: var(--metal); font-size: 13px; text-align: right; letter-spacing: 0.005em; }
+  #build .b-grid span { color: var(--ink-mute); }
+  #build .b-divider { height: 1px; background: var(--rule); margin: 10px 0 8px; }
+  #build .b-tier { display: flex; gap: 8px; margin-top: 6px; }
+  #build .b-tier .t { flex: 1; padding: 6px 4px; text-align: center; border: 1px solid var(--rule); }
+  #build .b-tier .t b { display: block; font-family: var(--serif); font-style: italic; font-size: 14px; font-weight: 400; }
+  #build .b-tier .t.A { color: var(--metal-glow); border-color: var(--metal); }
+  #build .b-tier .t.B { color: var(--metal); }
+  #build .b-tier .t.C { color: #a07a40; }
+  #build .b-tier .t.D { color: #5a4220; opacity: 0.85; }
+  #build .b-tier .t span { display: block; font-size: 8px; letter-spacing: .22em; text-transform: uppercase; color: var(--ink-mute); margin-top: 2px; }
+  #build a.b-3d { display: block; margin-top: 12px; padding: 8px 12px; border: 1px solid var(--metal); color: var(--metal-glow); text-align: center; font-size: 10px; letter-spacing: .22em; text-transform: uppercase; text-decoration: none; transition: background 220ms ease, color 220ms ease; }
+  #build a.b-3d:hover { background: var(--metal); color: var(--noir); }
+</style>
+</head>
+<body>
+<header>
+  <h1>Ventura <em>Boulevard</em> · corridor view</h1>
+  <div class="stats" id="stats">
+    <span><b id="s-total">—</b> on corridor</span>
+    <span><b id="s-site">—</b> with website</span>
+    <span><b id="s-scored">—</b> scored</span>
+  </div>
+</header>
+
+<div class="gs-wrap">
+  <input type="search" class="gs-input" id="gs-input" placeholder="Search Ventura Blvd businesses…" aria-label="Search businesses" autocomplete="off">
+  <div class="gs-results" id="gs-results">
+    <div class="gs-empty">Type to search the corridor.</div>
+  </div>
+</div>
+
+<div id="map"></div>
+
+<div id="build" aria-live="polite">
+  <p class="b-eyebrow"><span>Build status</span><span class="ts" id="b-ts">—</span></p>
+  <div class="b-grid">
+    <span>Businesses on corridor</span><b id="b-businesses">—</b>
+    <span>With website</span><b id="b-with-site">—</b>
+    <span>Front-page audited</span><b id="b-audited">—</b>
+    <span>SEO scored</span><b id="b-scored">—</b>
+    <span>Ownership classified</span><b id="b-enriched">—</b>
+    <span>With LinkedIn contact</span><b id="b-owner">—</b>
+  </div>
+  <div class="b-divider"></div>
+  <div class="b-tier">
+    <div class="t A"><b id="b-A">—</b><span>A · 80+</span></div>
+    <div class="t B"><b id="b-B">—</b><span>B · 60+</span></div>
+    <div class="t C"><b id="b-C">—</b><span>C · 40+</span></div>
+    <div class="t D"><b id="b-D">—</b><span>D · &lt;40</span></div>
+  </div>
+  <a class="b-3d" href="/3d.html">Three.js corridor ↗</a>
+  <a class="b-3d" href="/wall.html" style="margin-top:6px">The wall — every front page ↗</a>
+</div>
+
+<aside id="panel">
+  <button class="close" aria-label="Close panel" onclick="document.getElementById('panel').classList.remove('show')"><span aria-hidden="true">close ×</span></button>
+  <div class="pn-body" id="panel-body">
+    <p class="pn-empty">Click a marker to see business details.</p>
+  </div>
+</aside>
+
+<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
+<script>
+const map = L.map('map', { zoomControl: true }).setView([34.155, -118.495], 12);
+L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+  maxZoom: 19, attribution: '&copy; OpenStreetMap'
+}).addTo(map);
+
+async function loadStats() {
+  try {
+    const r = await fetch('/api/stats').then(r => r.json());
+    document.getElementById('s-total').textContent = (r.businesses_corridor || 0).toLocaleString();
+    document.getElementById('s-site').textContent = (r.businesses_with_site || 0).toLocaleString();
+    document.getElementById('s-scored').textContent = (r.scored_total || 0).toLocaleString();
+  } catch (e) { /* no data yet */ }
+}
+
+function tierColor(score) {
+  if (score == null) return '#5a4220';
+  if (score >= 90) return '#d4b683';
+  if (score >= 70) return '#b89968';
+  if (score >= 50) return '#a07a40';
+  return '#5a4220';
+}
+
+// Pull just the host out of a URL — never expose path/query in the search row.
+// Defensive: returns '' on bad input rather than throwing.
+function hostOnly(url) {
+  try {
+    const u = new URL(/^https?:\/\//.test(url) ? url : 'https://' + url);
+    return u.hostname.replace(/^www\./, '');
+  } catch { return ''; }
+}
+
+function escapeHtml(s) {
+  return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
+}
+
+// Cached dataset for grid-search filtering (loaded once on page open)
+let DATASET = [];
+const MARKERS = new Map(); // business id → L.circleMarker
+
+async function loadBusinesses() {
+  try {
+    const r = await fetch('/api/businesses').then(r => r.json());
+    if (!r.rows || !r.rows.length) return;
+    DATASET = r.rows;
+    const cluster = L.featureGroup().addTo(map);
+    for (const b of r.rows) {
+      if (b.lat == null || b.lng == null) continue;
+      const m = L.circleMarker([b.lat, b.lng], {
+        radius: 5, color: tierColor(b.seo_score), weight: 1, fillOpacity: 0.8,
+      });
+      // Single interaction → single response: click goes straight to the side panel.
+      // (Was previously firing a Leaflet popup AND the panel; the dual response
+      // split attention and created a z-layering conflict.)
+      m.on('click', () => showPanel(b.id));
+      m.bindTooltip(b.name || '(no name)', { direction: 'top', opacity: 0.9 });
+      cluster.addLayer(m);
+      MARKERS.set(b.id, m);
+    }
+    if (r.rows.length) map.fitBounds(cluster.getBounds(), { padding: [40, 40] });
+  } catch (e) { console.error(e); }
+}
+
+// ─── Grid search — live as-you-type filter over loaded dataset ─────────
+const gsInput = document.getElementById('gs-input');
+const gsResults = document.getElementById('gs-results');
+let gsDebounce;
+
+function gsRender(rows, q) {
+  if (!q) {
+    gsResults.classList.remove('show');
+    return;
+  }
+  if (!rows.length) {
+    gsResults.classList.add('show');
+    gsResults.innerHTML = `<div class="gs-empty">No businesses match "${q}".</div>`;
+    return;
+  }
+  // Each result shows the business's OWN domain only — never cross-link.
+  const cells = rows.slice(0, 60).map(b => {
+    const block = b.corridor_block ? `№ ${b.corridor_block}` : '';
+    const meta = [b.category, b.city, block].filter(Boolean).join(' · ');
+    const domain = b.website ? hostOnly(b.website) : '';
+    return `
+      <div class="gs-cell" data-id="${b.id}">
+        <span class="gs-cell-name">${escapeHtml(b.name || '(no name)')}${domain ? '<span class="domain">' + escapeHtml(domain) + '</span>' : ''}</span>
+        <span class="gs-cell-meta">${escapeHtml(meta)}</span>
+      </div>`;
+  }).join('');
+  gsResults.classList.add('show');
+  gsResults.innerHTML = `
+    <div class="gs-count">${rows.length} match${rows.length === 1 ? '' : 'es'}${rows.length > 60 ? ' · top 60 shown' : ''}</div>
+    <div class="gs-grid">${cells}</div>`;
+  gsResults.querySelectorAll('.gs-cell').forEach(el => {
+    el.addEventListener('click', () => {
+      const id = parseInt(el.dataset.id, 10);
+      const b = DATASET.find(x => x.id === id);
+      if (b && b.lat != null) map.flyTo([b.lat, b.lng], 17, { duration: 0.6 });
+      const m = MARKERS.get(id);
+      if (m) m.openPopup();
+      showPanel(id);
+    });
+  });
+}
+
+function gsSearch(q) {
+  q = q.trim().toLowerCase();
+  if (!q) { gsRender([], ''); return; }
+  const matches = DATASET.filter(b => {
+    if ((b.name || '').toLowerCase().includes(q)) return true;
+    if ((b.category || '').toLowerCase().includes(q)) return true;
+    if ((b.address || '').toLowerCase().includes(q)) return true;
+    if ((b.city || '').toLowerCase().includes(q)) return true;
+    return false;
+  });
+  gsRender(matches, q);
+}
+
+gsInput.addEventListener('input', e => {
+  clearTimeout(gsDebounce);
+  gsDebounce = setTimeout(() => gsSearch(e.target.value), 150);
+});
+gsInput.addEventListener('focus', e => { if (e.target.value) gsSearch(e.target.value); });
+document.addEventListener('keydown', e => {
+  if (e.key === 'Escape') { gsInput.value = ''; gsRender([], ''); gsInput.blur(); }
+});
+// Click outside the search wrapper to dismiss results
+document.addEventListener('click', e => {
+  if (!e.target.closest('.gs-wrap')) gsResults.classList.remove('show');
+});
+
+async function showPanel(id) {
+  const panel = document.getElementById('panel');
+  const body = document.getElementById('panel-body');
+  body.innerHTML = '<p class="pn-empty">Loading…</p>';
+  panel.classList.add('show');
+  try {
+    const r = await fetch('/api/businesses/' + id).then(r => r.json());
+    const b = r.business;
+    const a = r.latest_audit;
+    const s = r.latest_score;
+    const websiteHtml = b.website
+      ? `<a class="pn-website" href="${b.website}" target="_blank" rel="noopener">Visit website ↗</a>`
+      : '<p class="pn-empty" style="margin-top:12px">No website on record.</p>';
+    const shotUrl = a && a.screenshot_path ? '/' + a.screenshot_path : null;
+    const shotHtml = shotUrl
+      ? `<a href="${b.website || '#'}" target="_blank" rel="noopener" style="display:block;margin-bottom:14px;border:1px solid var(--rule);overflow:hidden;line-height:0"><img src="${shotUrl}" alt="${b.name} front-page screenshot" style="width:100%;height:auto;display:block;transition:opacity 220ms ease" onload="this.style.opacity=1" onerror="this.style.display='none'"></a>`
+      : '';
+    const ownerLine = r.enrichment ? `<div class="pn-row"><span class="k">Ownership</span><span class="v metal">${r.enrichment.ownership_class}${r.enrichment.parent_brand ? ' · ' + r.enrichment.parent_brand : ''}</span></div>` : '';
+    const contactLines = (r.contacts || []).map(c => `<div class="pn-row"><span class="k">${c.role}</span><span class="v">${c.person_name || c.person_linkedin}</span></div>`).join('');
+    body.innerHTML = `
+      ${shotHtml}
+      <p class="pn-eyebrow">${b.category || 'Uncategorized'}</p>
+      <h3 class="pn-name">${b.name}</h3>
+      <p class="pn-meta">${[b.address, b.city, b.zip].filter(Boolean).join(' · ')}</p>
+      ${ownerLine}
+      <div class="pn-row"><span class="k">Block</span><span class="v">${b.corridor_block ?? '—'}</span></div>
+      <div class="pn-row"><span class="k">Phone</span><span class="v">${b.phone || '—'}</span></div>
+      <div class="pn-row"><span class="k">SEO score</span><span class="v metal">${s ? s.total : '— not scored'}</span></div>
+      <div class="pn-row"><span class="k">Tier</span><span class="v metal">${s ? s.tier : '—'}</span></div>
+      <div class="pn-row"><span class="k">Rank in category</span><span class="v">${s && s.category_rank ? `${s.category_rank} / ${s.category_n}` : '—'}</span></div>
+      <div class="pn-row"><span class="k">Last audit</span><span class="v">${a ? new Date(a.audited_at).toLocaleDateString() : '— not audited'}</span></div>
+      ${contactLines}
+      ${websiteHtml}
+    `;
+  } catch (e) {
+    body.innerHTML = '<p class="pn-empty">Failed to load.</p>';
+  }
+}
+
+async function loadCorridor() {
+  try {
+    const r = await fetch('/api/segments').then(r => r.json());
+    if (!r.rows || !r.rows.length) return;
+    // Build a single polyline through every segment endpoint.
+    const points = [];
+    for (const s of r.rows) {
+      if (!points.length) points.push([s.west_lat, s.west_lng]);
+      points.push([s.east_lat, s.east_lng]);
+    }
+    L.polyline(points, { color: '#b89968', weight: 3, opacity: 0.55, dashArray: null }).addTo(map);
+    L.polyline(points, { color: '#d4b683', weight: 1, opacity: 0.85 }).addTo(map);
+  } catch (e) { /* no data yet */ }
+}
+
+async function loadBuildStatus() {
+  const el = document.getElementById('build');
+  if (el) el.classList.add('refreshing');
+  try {
+    const r = await fetch('/api/build-status').then(r => r.json());
+    const c = r.counts || {};
+    const set = (id, v) => { const e = document.getElementById(id); if (e) e.textContent = (v ?? 0).toLocaleString(); };
+    set('b-businesses', c.businesses);
+    set('b-with-site', c.with_site);
+    set('b-audited', c.audited);
+    set('b-scored', c.scored);
+    set('b-enriched', c.enriched);
+    set('b-owner', c.with_owner_contact);
+    set('b-A', c.tier_a);
+    set('b-B', c.tier_b);
+    set('b-C', c.tier_c);
+    set('b-D', c.tier_d);
+    const ts = new Date(r.ts || Date.now());
+    document.getElementById('b-ts').textContent = ts.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
+  } catch {}
+  if (el) el.classList.remove('refreshing');
+}
+
+loadStats();
+loadCorridor();
+loadBusinesses();
+loadBuildStatus();
+// Live refresh every 5s while ingest/crawl/score is running.
+setInterval(loadBuildStatus, 5000);
+</script>
+</body>
+</html>
diff --git a/public/wall.html b/public/wall.html
new file mode 100644
index 0000000..eedc4ba
--- /dev/null
+++ b/public/wall.html
@@ -0,0 +1,178 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Ventura Blvd · the wall · every front page</title>
+<style>
+  :root { --noir: #0a0a0c; --noir-rise: #131316; --rule: #2a2724; --ink: #f4f1ea; --ink-soft: #d8d2c5; --ink-mute: #8b857a; --metal: #b89968; --metal-glow: #d4b683; --rust: #6b3a2a; --serif: "Cormorant Garamond", Georgia, serif; --sans: -apple-system, "SF Pro Text", system-ui, sans-serif; }
+  *, *::before, *::after { box-sizing: border-box; }
+  html, body { margin: 0; background: var(--noir); color: var(--ink); font-family: var(--sans); font-weight: 300; }
+  header { padding: 28px 32px 24px; border-bottom: 1px solid var(--rule); display: flex; justify-content: space-between; align-items: baseline; gap: 24px; flex-wrap: wrap; position: sticky; top: 0; z-index: 50; background: linear-gradient(180deg, rgba(10,10,12,.96) 80%, rgba(10,10,12,.0) 100%); backdrop-filter: blur(8px); }
+  header h1 { font-family: var(--serif); font-weight: 300; font-size: clamp(26px, 3vw, 38px); margin: 0; letter-spacing: -0.012em; }
+  header h1 em { font-style: italic; color: var(--metal); }
+  header .meta { font-size: 11px; letter-spacing: .04em; color: var(--ink-mute); display: flex; gap: 22px; flex-wrap: wrap; }
+  header .meta b { color: var(--metal); font-family: var(--serif); font-style: italic; font-weight: 400; font-size: 14px; letter-spacing: 0; margin-right: 4px; font-variant-numeric: tabular-nums; }
+  header .controls { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; }
+  header .filters { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; }
+  header .controls > nav.tabs { padding-left: 14px; border-left: 1px solid var(--rule); margin-left: 0; }
+  @media (max-width: 720px) { header .controls > nav.tabs { padding-left: 0; border-left: 0; margin-top: 4px; } }
+  header input { padding: 10px 14px; background: var(--noir-rise); border: 1px solid var(--rule); color: var(--ink); font-family: var(--sans); font-size: 13px; min-width: 240px; outline: none; }
+  header input::placeholder { color: var(--ink-mute); font-style: italic; font-family: var(--serif); }
+  header input:focus { border-color: var(--metal); }
+  header select { padding: 10px 14px; background: var(--noir-rise); border: 1px solid var(--rule); color: var(--ink-soft); font-family: var(--sans); font-size: 11px; letter-spacing: .14em; text-transform: uppercase; outline: none; cursor: pointer; }
+  nav.tabs { display: flex; gap: 2px; }
+  nav.tabs a { padding: 8px 14px; border: 1px solid var(--rule); color: var(--ink-mute); text-decoration: none; font-size: 10px; letter-spacing: .22em; text-transform: uppercase; }
+  nav.tabs a.active { color: var(--metal); border-color: var(--metal); }
+  nav.tabs a:hover { color: var(--metal-glow); }
+  main { padding: 28px 24px 80px; }
+  /* Pinterest-style masonry-ish grid via CSS columns */
+  .wall { columns: 280px; column-gap: 16px; max-width: 1700px; margin: 0 auto; }
+  .card { display: block; break-inside: avoid; margin: 0 0 16px; background: var(--noir-rise); border: 1px solid var(--rule); position: relative; transition: border-color 220ms ease, transform 220ms ease; cursor: pointer; text-decoration: none; color: inherit; }
+  .card:hover { border-color: var(--metal); transform: translateY(-2px); }
+  .card .shot { display: block; width: 100%; height: auto; aspect-ratio: 1280/900; background: var(--noir-deep); object-fit: cover; }
+  .card .body { padding: 14px 16px 16px; }
+  .card .name { font-family: var(--serif); font-weight: 400; font-size: 17px; line-height: 1.18; color: var(--ink); margin: 0 0 6px; letter-spacing: -0.005em; }
+  .card .meta { font-size: 11px; color: var(--ink-mute); letter-spacing: 0.02em; line-height: 1.55; }
+  .card .meta .dot { color: var(--metal); margin: 0 5px; opacity: 0.75; }
+  .card .stripe { display: flex; align-items: center; gap: 8px; padding: 0 16px 14px; }
+  .card .tier { font-family: var(--serif); font-style: italic; color: var(--metal); font-size: 14px; font-weight: 400; }
+  .card .tier.A { color: var(--metal-glow); }
+  .card .rank { font-size: 9px; letter-spacing: .22em; text-transform: uppercase; color: var(--ink-mute); margin-left: auto; }
+  .card .own { padding: 3px 8px; border: 1px solid var(--rule); font-size: 9px; letter-spacing: .18em; text-transform: uppercase; color: var(--ink-mute); }
+  .card .own.independent { color: var(--metal-glow); border-color: rgba(184,153,104,0.4); }
+  .card .own.franchise { color: #a07a40; border-color: rgba(160,122,64,0.4); }
+  .card .own.corporate { color: var(--ink-soft); border-color: rgba(216,210,197,0.18); }
+  .card .domain { font-family: 'SFMono-Regular', Menlo, Consolas, monospace; font-size: 9px; color: var(--metal); padding: 0 16px 12px; opacity: 0.85; word-break: break-all; }
+  .empty { padding: 80px 32px; text-align: center; font-family: var(--serif); font-style: italic; color: var(--ink-mute); }
+  footer { padding: 24px 32px; border-top: 1px solid var(--rule); font-family: var(--serif); font-style: italic; color: var(--ink-mute); font-size: 12px; text-align: center; }
+</style>
+</head>
+<body>
+
+<header>
+  <div>
+    <h1>Ventura <em>Boulevard</em> · the wall</h1>
+    <div class="meta" id="m">
+      <span><b id="m-n">—</b> front pages</span>
+      <span><b id="m-A">—</b>tier A</span>
+      <span><b id="m-B">—</b>tier B</span>
+      <span><b id="m-C">—</b>tier C / D</span>
+    </div>
+  </div>
+  <div class="controls">
+    <div class="filters" role="group" aria-label="Filter and sort the wall">
+      <input type="search" id="q" placeholder="Search name, category, city…" autocomplete="off" aria-label="Search wall">
+      <select id="sort" aria-label="Sort order">
+        <option value="score-desc">Highest score</option>
+        <option value="score-asc">Lowest score</option>
+        <option value="name-asc">Name A–Z</option>
+        <option value="block-asc">West → East</option>
+        <option value="block-desc">East → West</option>
+      </select>
+      <select id="own" aria-label="Filter by ownership">
+        <option value="">All ownership</option>
+        <option value="independent">Independent</option>
+        <option value="franchise">Franchise</option>
+        <option value="corporate">Corporate</option>
+      </select>
+    </div>
+    <nav class="tabs" aria-label="Surface navigation">
+      <a href="/">2D map</a>
+      <a href="/3d.html">3D corridor</a>
+      <a href="/wall.html" class="active" aria-current="page">Wall</a>
+    </nav>
+  </div>
+</header>
+
+<main>
+  <div class="wall" id="wall"></div>
+  <div class="empty" id="empty" style="display:none">No matching front pages.</div>
+</main>
+
+<footer>Directory only. No referrals, no fee-splits, no representation. Local view of public-record businesses on Ventura Blvd.</footer>
+
+<script>
+let DATA = [];
+
+function tierClass(tier) { return ['A', 'B', 'C', 'D'].includes(tier) ? tier : ''; }
+
+function ownClass(own) { return ['independent', 'franchise', 'corporate'].includes(own) ? own : ''; }
+
+function hostOnly(url) {
+  try { const u = new URL(/^https?:\/\//.test(url) ? url : 'https://' + url); return u.hostname.replace(/^www\./, ''); }
+  catch { return ''; }
+}
+
+function escapeHtml(s) {
+  return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
+}
+
+function render() {
+  const q = (document.getElementById('q').value || '').trim().toLowerCase();
+  const sort = document.getElementById('sort').value;
+  const own = document.getElementById('own').value;
+  let rows = DATA.filter(r => r.screenshot_path);     // wall = only with screenshots
+
+  if (q) {
+    rows = rows.filter(r => {
+      const hay = [r.name, r.category, r.city, r.address, r.parent_brand].filter(Boolean).join(' ').toLowerCase();
+      return hay.includes(q);
+    });
+  }
+  if (own) rows = rows.filter(r => r.ownership_class === own);
+
+  rows.sort((a, b) => {
+    if (sort === 'score-desc') return (b.seo_score || 0) - (a.seo_score || 0);
+    if (sort === 'score-asc')  return (a.seo_score || 0) - (b.seo_score || 0);
+    if (sort === 'name-asc')   return (a.name || '').localeCompare(b.name || '');
+    if (sort === 'block-asc')  return (a.corridor_block || 0) - (b.corridor_block || 0);
+    if (sort === 'block-desc') return (b.corridor_block || 0) - (a.corridor_block || 0);
+    return 0;
+  });
+
+  const wall = document.getElementById('wall');
+  const empty = document.getElementById('empty');
+  if (!rows.length) { wall.innerHTML = ''; empty.style.display = 'block'; return; }
+  empty.style.display = 'none';
+
+  wall.innerHTML = rows.map(r => {
+    const tier = r.seo_tier || '';
+    const score = r.seo_score != null ? `<span class="tier ${tierClass(tier)}">${r.seo_score} <em style="font-style:normal;color:var(--ink-mute);font-size:10px;letter-spacing:.18em;text-transform:uppercase;margin-left:6px">${tier}</em></span>` : '<span class="tier" style="color:var(--ink-mute);font-style:italic">unscored</span>';
+    const rank = r.category_rank ? `<span class="rank">№ ${r.category_rank} / ${r.category_n}</span>` : '';
+    const own = r.ownership_class ? `<span class="own ${ownClass(r.ownership_class)}">${r.ownership_class}</span>` : '';
+    const meta = [r.category, r.city].filter(Boolean).map(escapeHtml).join('<span class="dot">·</span>');
+    const dom = r.website ? hostOnly(r.website) : '';
+    return `
+      <a class="card" href="${r.website || '#'}" target="_blank" rel="noopener" data-id="${r.id}">
+        <img class="shot" src="/${r.screenshot_path}" loading="lazy" alt="${escapeHtml(r.name)} front page" onerror="this.style.background='var(--rule)'">
+        <div class="body">
+          <p class="name">${escapeHtml(r.name)}</p>
+          <p class="meta">${meta}</p>
+        </div>
+        <div class="stripe">${score} ${own} ${rank}</div>
+        ${dom ? `<div class="domain">${escapeHtml(dom)}</div>` : ''}
+      </a>`;
+  }).join('');
+}
+
+async function load() {
+  const r = await fetch('/api/businesses?limit=400').then(r => r.json());
+  DATA = r.rows || [];
+  // Stats
+  const withShot = DATA.filter(x => x.screenshot_path);
+  document.getElementById('m-n').textContent = withShot.length;
+  document.getElementById('m-A').textContent = DATA.filter(x => x.seo_tier === 'A').length;
+  document.getElementById('m-B').textContent = DATA.filter(x => x.seo_tier === 'B').length;
+  document.getElementById('m-C').textContent = DATA.filter(x => ['C','D'].includes(x.seo_tier)).length;
+  render();
+}
+
+document.getElementById('q').addEventListener('input', () => { clearTimeout(window._gt); window._gt = setTimeout(render, 120); });
+document.getElementById('sort').addEventListener('change', render);
+document.getElementById('own').addEventListener('change', render);
+
+load();
+</script>
+</body>
+</html>
diff --git a/scripts/foursquare_signup.cjs b/scripts/foursquare_signup.cjs
new file mode 100644
index 0000000..f81a882
--- /dev/null
+++ b/scripts/foursquare_signup.cjs
@@ -0,0 +1,208 @@
+/**
+ * Autonomous Foursquare developer-portal signup via browserbase.
+ *
+ * Hard stops (abort + report immediately):
+ *   - reCAPTCHA / hCaptcha / Cloudflare Turnstile
+ *   - SMS / phone verification
+ *   - Credit card requirement
+ *
+ * On success: prints the page state and (if reachable) the API key.
+ * On any hard stop: prints a clean message + the live debugger URL so the
+ *   human can finish the last step in a real browser.
+ */
+require('dotenv').config({ path: require('os').homedir() + '/.claude/skills/browserbase/.env' });
+const Browserbase = require('@browserbasehq/sdk').default;
+const { chromium } = require('playwright-core');
+const fs = require('fs');
+const path = require('path');
+
+const PROJECT_ID = process.env.BROWSERBASE_PROJECT_ID;
+const API_KEY = process.env.BROWSERBASE_API_KEY;
+if (!PROJECT_ID || !API_KEY) {
+  console.error('[fsq-signup] missing BROWSERBASE_PROJECT_ID / BROWSERBASE_API_KEY in ~/.claude/skills/browserbase/.env');
+  process.exit(1);
+}
+
+// Pull the portal password from secrets-manager .env (not echoed).
+const SECRETS_ENV = require('os').homedir() + '/Projects/secrets-manager/.env';
+let PORTAL_PW = '';
+try {
+  const txt = fs.readFileSync(SECRETS_ENV, 'utf8');
+  const m = txt.match(/^FOURSQUARE_PORTAL_PASSWORD=(.+)$/m);
+  if (m) PORTAL_PW = m[1].trim();
+} catch {}
+if (!PORTAL_PW) {
+  console.error('[fsq-signup] FOURSQUARE_PORTAL_PASSWORD not found in secrets-manager .env');
+  process.exit(1);
+}
+
+const EMAIL = 'steveabramsdesigns@gmail.com';
+const FIRST_NAME = 'Steve';
+const LAST_NAME = 'Abrams';
+const SIGNUP_URL = 'https://foursquare.com/developers/signup/';
+
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+async function detectHardStop(page) {
+  const html = await page.content();
+  const lc = html.toLowerCase();
+  if (/cf-turnstile|turnstile|cloudflare/i.test(html) && /challenge|captcha|verify you are human/i.test(html)) {
+    return 'cloudflare-turnstile';
+  }
+  if (/recaptcha\/api\.js|grecaptcha/.test(html)) return 'recaptcha';
+  if (/hcaptcha\.com|h-captcha/i.test(html)) return 'hcaptcha';
+  if (/(sms|phone).*(verification|verify|code)/i.test(lc) && /enter.*code/i.test(lc)) return 'sms-phone';
+  if (/credit card|payment method|card number/i.test(lc)) return 'credit-card';
+  return null;
+}
+
+async function dumpState(page, tag) {
+  try {
+    const url = page.url();
+    const title = await page.title().catch(() => '');
+    const text = (await page.evaluate(() => document.body.innerText).catch(() => '')).slice(0, 600);
+    console.log(`[${tag}] url:    ${url}`);
+    console.log(`[${tag}] title:  ${title}`);
+    console.log(`[${tag}] text:   ${text.replace(/\s+/g, ' ').slice(0, 400)}`);
+  } catch (e) {
+    console.log(`[${tag}] dump failed: ${e.message}`);
+  }
+}
+
+async function tryFill(page, selectors, value) {
+  for (const sel of selectors) {
+    try {
+      await page.fill(sel, value, { timeout: 1500 });
+      return sel;
+    } catch {}
+  }
+  return null;
+}
+
+async function tryClick(page, selectors) {
+  for (const sel of selectors) {
+    try {
+      await page.click(sel, { timeout: 1500 });
+      return sel;
+    } catch {}
+  }
+  return null;
+}
+
+(async () => {
+  const bb = new Browserbase({ apiKey: API_KEY });
+  const session = await bb.sessions.create({ projectId: PROJECT_ID });
+  console.log(`[fsq-signup] browserbase session: ${session.id}`);
+  console.log(`[fsq-signup] live debugger:        ${session.debuggerUrl || session.connectUrl}`);
+
+  const browser = await chromium.connectOverCDP(session.connectUrl);
+  const ctx = browser.contexts()[0];
+  const page = ctx.pages()[0] || await ctx.newPage();
+  page.setDefaultTimeout(20_000);
+
+  try {
+    console.log('[fsq-signup] navigating to', SIGNUP_URL);
+    await page.goto(SIGNUP_URL, { waitUntil: 'networkidle', timeout: 30_000 });
+    await sleep(1500);
+    await dumpState(page, 'arrived');
+
+    // Hard-stop check on landing
+    let stop = await detectHardStop(page);
+    if (stop) {
+      console.log(`[HARD-STOP] ${stop} on signup landing — abort. Finish manually at ${page.url()}`);
+      console.log(`[fsq-signup] LIVE_DEBUG=${session.debuggerUrl}`);
+      await browser.close();
+      process.exit(2);
+    }
+
+    // The Auth0 universal-login flow has TWO steps: identifier (email) then password.
+    // OR it offers "Sign Up" as a separate tab. Try the "Sign Up" link first.
+    const signUpClicked = await tryClick(page, [
+      'a:has-text("Sign Up")',
+      'a:has-text("Sign up")',
+      'button:has-text("Sign Up")',
+      'button:has-text("Create account")',
+      'a[href*="signup"]',
+    ]);
+    if (signUpClicked) {
+      console.log(`[fsq-signup] clicked sign-up link: ${signUpClicked}`);
+      await sleep(2000);
+      await dumpState(page, 'post-signup-click');
+    }
+
+    // Fill email
+    const emailFilled = await tryFill(page, [
+      'input[name="email"]', 'input[type="email"]', 'input[autocomplete="email"]', '#email',
+    ], EMAIL);
+    if (!emailFilled) {
+      console.log('[fsq-signup] could not find email field — page may have changed.');
+      await dumpState(page, 'no-email-field');
+      console.log(`[fsq-signup] LIVE_DEBUG=${session.debuggerUrl}`);
+      await browser.close();
+      process.exit(3);
+    }
+    console.log(`[fsq-signup] email filled (${emailFilled})`);
+
+    // Fill password if a password field is present (some flows ask after email)
+    const pwFilled = await tryFill(page, [
+      'input[name="password"]', 'input[type="password"]', 'input[autocomplete="new-password"]',
+    ], PORTAL_PW);
+    if (pwFilled) console.log(`[fsq-signup] password filled (${pwFilled})`);
+
+    // Optional: name fields
+    await tryFill(page, ['input[name="firstName"]', 'input[name="given_name"]', '#firstName'], FIRST_NAME);
+    await tryFill(page, ['input[name="lastName"]', 'input[name="family_name"]', '#lastName'], LAST_NAME);
+
+    // Accept ToS checkbox if present (best-effort)
+    try { await page.check('input[type="checkbox"][name*="terms" i], input[type="checkbox"][name*="agree" i]', { timeout: 1500 }); } catch {}
+
+    // Submit
+    const submitClicked = await tryClick(page, [
+      'button[type="submit"]',
+      'button:has-text("Continue")',
+      'button:has-text("Sign Up")',
+      'button:has-text("Sign up")',
+      'button:has-text("Create account")',
+    ]);
+    if (!submitClicked) {
+      console.log('[fsq-signup] could not find submit — abort.');
+      await dumpState(page, 'no-submit');
+      console.log(`[fsq-signup] LIVE_DEBUG=${session.debuggerUrl}`);
+      await browser.close();
+      process.exit(4);
+    }
+    console.log(`[fsq-signup] submitted (${submitClicked})`);
+
+    // Wait for next state
+    await sleep(4000);
+    await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => {});
+    await dumpState(page, 'post-submit');
+
+    stop = await detectHardStop(page);
+    if (stop) {
+      console.log(`[HARD-STOP] ${stop} after submit — abort.`);
+      console.log(`[fsq-signup] LIVE_DEBUG=${session.debuggerUrl}`);
+      await browser.close();
+      process.exit(5);
+    }
+
+    // Detect email-verification wall
+    const text = (await page.evaluate(() => document.body.innerText).catch(() => '')).toLowerCase();
+    if (/check your email|verify your email|sent.*email|confirm.*email/i.test(text)) {
+      console.log('[fsq-signup] email-verification wall — Foursquare sent a confirmation to', EMAIL);
+      console.log('[fsq-signup] LIVE_DEBUG=' + session.debuggerUrl);
+      console.log('[fsq-signup] Steve: open that email, click the verify link, then re-run this script (the session will detect logged-in state and proceed).');
+      await browser.close();
+      process.exit(0);
+    }
+
+    console.log('[fsq-signup] signup appears to have completed without an obvious wall.');
+    console.log('[fsq-signup] page state above. LIVE_DEBUG=' + session.debuggerUrl);
+
+  } catch (e) {
+    console.error('[fsq-signup] error:', e.message);
+    console.log('[fsq-signup] LIVE_DEBUG=' + (session.debuggerUrl || ''));
+  } finally {
+    await browser.close().catch(() => {});
+  }
+})();
diff --git a/src/crawl/front_page.ts b/src/crawl/front_page.ts
new file mode 100644
index 0000000..613e20b
--- /dev/null
+++ b/src/crawl/front_page.ts
@@ -0,0 +1,287 @@
+/**
+ * Front-page crawler — Phase ii.
+ *
+ * For every business with a website on the Ventura corridor, visit the page
+ * with Playwright, capture metadata + screenshot + HTML, insert a row into
+ * front_page_audits.
+ *
+ * Polite: concurrency 4, 15s navigation timeout, ignore SSL errors.
+ *
+ * Run:
+ *   npm run crawl
+ *   tsx src/crawl/front_page.ts -- --limit=20   # smoke test
+ *   tsx src/crawl/front_page.ts -- --rescore    # only rows missing audits
+ */
+import 'dotenv/config';
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { chromium, type Browser } from 'playwright';
+import { pool, query } from '../../db/pool.ts';
+
+const ROOT = new URL('../..', import.meta.url).pathname;
+const SCREEN_DIR = join(ROOT, 'data', 'screenshots');
+const RAW_DIR = join(ROOT, 'data', 'raw');
+mkdirSync(SCREEN_DIR, { recursive: true });
+mkdirSync(RAW_DIR, { recursive: true });
+
+const CONCURRENCY = parseInt(process.env.CRAWL_CONCURRENCY || '4', 10);
+const TIMEOUT_MS = parseInt(process.env.CRAWL_TIMEOUT_MS || '15000', 10);
+
+interface BusinessRow {
+  id: number;
+  name: string;
+  website: string;
+}
+
+async function pickQueue(opts: { limit: number; rescoreOnly: boolean }): Promise<BusinessRow[]> {
+  const where = ['b.on_corridor', "b.website IS NOT NULL", "b.website ~ '^https?://'"];
+  if (opts.rescoreOnly) {
+    where.push(`NOT EXISTS (SELECT 1 FROM front_page_audits a WHERE a.business_id = b.id)`);
+  }
+  const r = await query<BusinessRow>(`
+    SELECT b.id, b.name, b.website
+    FROM businesses b
+    WHERE ${where.join(' AND ')}
+    ORDER BY b.id
+    LIMIT $1
+  `, [opts.limit]);
+  return r.rows;
+}
+
+interface AuditCapture {
+  fetchedUrl: string | null;
+  statusCode: number | null;
+  httpProtocol: string | null;
+  ttfbMs: number | null;
+  loadMs: number | null;
+  bytesHtml: number | null;
+  bytesTotal: number | null;
+  hasHttps: boolean;
+  hasViewport: boolean;
+  hasTitle: boolean;
+  titleText: string | null;
+  metaDesc: string | null;
+  hasH1: boolean;
+  h1Text: string | null;
+  schemaOrgCount: number;
+  schemaOrgTypes: string[];
+  ogImage: string | null;
+  altCoverage: number | null;
+  wordCount: number | null;
+  outboundLinks: number;
+  internalLinks: number;
+  errorMessage: string | null;
+  rawHtml: string | null;
+  screenshotPath: string | null;
+}
+
+function emptyCapture(err?: string): AuditCapture {
+  return {
+    fetchedUrl: null, statusCode: null, httpProtocol: null,
+    ttfbMs: null, loadMs: null, bytesHtml: null, bytesTotal: null,
+    hasHttps: false, hasViewport: false, hasTitle: false, titleText: null,
+    metaDesc: null, hasH1: false, h1Text: null,
+    schemaOrgCount: 0, schemaOrgTypes: [], ogImage: null,
+    altCoverage: null, wordCount: null, outboundLinks: 0, internalLinks: 0,
+    errorMessage: err || null, rawHtml: null, screenshotPath: null,
+  };
+}
+
+async function captureOne(browser: Browser, b: BusinessRow): Promise<AuditCapture> {
+  const out = emptyCapture();
+  const ctx = await browser.newContext({
+    ignoreHTTPSErrors: true,
+    userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 ventura-corridor/0.1 (audit; +local)',
+    viewport: { width: 1280, height: 900 },
+  });
+  const page = await ctx.newPage();
+  let totalBytes = 0;
+  page.on('response', (r) => {
+    const len = parseInt(r.headers()['content-length'] || '0', 10);
+    if (Number.isFinite(len) && len > 0) totalBytes += len;
+  });
+
+  const startedAt = Date.now();
+  try {
+    // 'load' is more permissive than 'domcontentloaded' for sites that lazy-render.
+    const resp = await page.goto(b.website, { waitUntil: 'load', timeout: TIMEOUT_MS });
+    out.loadMs = Date.now() - startedAt;
+    if (!resp) {
+      out.errorMessage = 'no response';
+      await ctx.close();
+      return out;
+    }
+    out.fetchedUrl = page.url();
+    out.statusCode = resp.status();
+    out.httpProtocol = out.fetchedUrl.startsWith('https:') ? 'https' : 'http';
+    out.hasHttps = out.httpProtocol === 'https';
+    const reqTime = (resp.timing && resp.timing().responseStart) || 0;
+    out.ttfbMs = reqTime > 0 ? Math.round(reqTime) : null;
+
+    const html = await page.content();
+    out.bytesHtml = Buffer.byteLength(html, 'utf8');
+    out.bytesTotal = totalBytes || out.bytesHtml;
+
+    // Pass as string to avoid tsx injecting __name() helper calls (which the
+    // browser doesn't have). Plain JS only.
+    const meta = await page.evaluate(`(function(){
+      function q(sel){ return document.querySelector(sel); }
+      function all(sel){ return Array.prototype.slice.call(document.querySelectorAll(sel)); }
+      var viewport = !!q('meta[name="viewport" i]');
+      var title = (document.title || '').trim();
+      var mdEl = q('meta[name="description" i]');
+      var metaDesc = (mdEl && mdEl.content) ? mdEl.content.trim() : '';
+      var h1 = q('h1');
+      var ogEl = q('meta[property="og:image" i]');
+      var ogImg = (ogEl && ogEl.content) ? ogEl.content.trim() : null;
+      var ldjson = all('script[type="application/ld+json"]').map(function(s){ return s.textContent || ''; }).filter(Boolean);
+      var types = [];
+      var ldCount = 0;
+      for (var i = 0; i < ldjson.length; i++) {
+        try {
+          var j = JSON.parse(ldjson[i]);
+          var items = Array.isArray(j) ? j : [j];
+          for (var k = 0; k < items.length; k++) {
+            var it = items[k];
+            ldCount++;
+            if (it && it['@type']) {
+              var ts = Array.isArray(it['@type']) ? it['@type'] : [it['@type']];
+              for (var m = 0; m < ts.length; m++) types.push(String(ts[m]));
+            }
+          }
+        } catch (e) {}
+      }
+      var imgs = all('img');
+      var totalImgs = imgs.length;
+      var withAlt = 0;
+      for (var ii = 0; ii < imgs.length; ii++) {
+        var a = imgs[ii].getAttribute('alt') || '';
+        if (a.trim().length > 0) withAlt++;
+      }
+      var text = (document.body && document.body.innerText ? document.body.innerText : '').replace(/\\s+/g, ' ').trim();
+      var words = text ? text.split(' ').length : 0;
+      var links = all('a[href]');
+      var host = location.hostname;
+      var inLinks = 0, outLinks = 0;
+      for (var li = 0; li < links.length; li++) {
+        try {
+          var u = new URL(links[li].href, location.href);
+          if (u.hostname === host) inLinks++; else outLinks++;
+        } catch (e) {}
+      }
+      var uniq = [];
+      for (var ti = 0; ti < types.length; ti++) if (uniq.indexOf(types[ti]) < 0) uniq.push(types[ti]);
+      return {
+        viewport: viewport, title: title, metaDesc: metaDesc,
+        h1Text: (h1 && h1.textContent) ? h1.textContent.trim() : null,
+        ogImg: ogImg, ldCount: ldCount, types: uniq,
+        altCoverage: totalImgs > 0 ? withAlt / totalImgs : null,
+        wordCount: words, inLinks: inLinks, outLinks: outLinks
+      };
+    })()`);
+
+    out.hasViewport = meta.viewport;
+    out.hasTitle = !!meta.title;
+    out.titleText = meta.title || null;
+    out.metaDesc = meta.metaDesc || null;
+    out.hasH1 = !!meta.h1Text;
+    out.h1Text = meta.h1Text;
+    out.schemaOrgCount = meta.ldCount;
+    out.schemaOrgTypes = meta.types;
+    out.ogImage = meta.ogImg;
+    out.altCoverage = meta.altCoverage;
+    out.wordCount = meta.wordCount;
+    out.outboundLinks = meta.outLinks;
+    out.internalLinks = meta.inLinks;
+
+    // Screenshot — top-of-viewport only, JPEG to keep size small.
+    const shotName = `firm-${b.id}.jpg`;
+    const shotPath = join(SCREEN_DIR, shotName);
+    await page.screenshot({ path: shotPath, type: 'jpeg', quality: 70, fullPage: false });
+    out.screenshotPath = `screenshots/${shotName}`;
+
+    // Raw HTML for offline re-scoring later.
+    const htmlName = `firm-${b.id}.html`;
+    writeFileSync(join(RAW_DIR, htmlName), html, 'utf8');
+    out.rawHtml = `raw/${htmlName}`;
+  } catch (e: any) {
+    out.errorMessage = (e?.message || String(e)).slice(0, 500);
+    out.loadMs = Date.now() - startedAt;
+  } finally {
+    await ctx.close().catch(() => {});
+  }
+  return out;
+}
+
+async function insertAudit(businessId: number, c: AuditCapture) {
+  await query(`
+    INSERT INTO front_page_audits (
+      business_id, fetched_url, status_code, http_protocol,
+      ttfb_ms, load_ms, bytes_html, bytes_total,
+      has_https, has_viewport, has_title, title_text, meta_desc,
+      has_h1, h1_text, schema_org_count, schema_org_types,
+      og_image, alt_coverage, word_count, outbound_links, internal_links,
+      screenshot_path, error_message, raw_html_path
+    ) VALUES (
+      $1, $2, $3, $4,
+      $5, $6, $7, $8,
+      $9, $10, $11, $12, $13,
+      $14, $15, $16, $17,
+      $18, $19, $20, $21, $22,
+      $23, $24, $25
+    )
+  `, [
+    businessId, c.fetchedUrl, c.statusCode, c.httpProtocol,
+    c.ttfbMs, c.loadMs, c.bytesHtml, c.bytesTotal,
+    c.hasHttps, c.hasViewport, c.hasTitle, c.titleText?.slice(0, 500), c.metaDesc?.slice(0, 1000),
+    c.hasH1, c.h1Text?.slice(0, 500), c.schemaOrgCount, c.schemaOrgTypes,
+    c.ogImage?.slice(0, 800), c.altCoverage, c.wordCount, c.outboundLinks, c.internalLinks,
+    c.screenshotPath, c.errorMessage, c.rawHtml,
+  ]);
+}
+
+async function main() {
+  const argLimit = process.argv.find((a) => a.startsWith('--limit='));
+  const rescoreOnly = process.argv.includes('--rescore');
+  const limit = argLimit ? parseInt(argLimit.split('=')[1], 10) : 1000;
+
+  const queue = await pickQueue({ limit, rescoreOnly });
+  console.log(`[crawl] ${queue.length} sites in queue · concurrency ${CONCURRENCY} · timeout ${TIMEOUT_MS}ms`);
+
+  const browser = await chromium.launch({ headless: true });
+  const startedAt = Date.now();
+  let done = 0, ok = 0, fail = 0;
+
+  // Simple parallel worker pool.
+  const queueCopy = queue.slice();
+  await Promise.all(Array.from({ length: CONCURRENCY }).map(async () => {
+    while (queueCopy.length) {
+      const b = queueCopy.shift()!;
+      const c = await captureOne(browser, b);
+      await insertAudit(b.id, c);
+      done++;
+      if (c.errorMessage) {
+        fail++;
+        if (done % 10 === 0 || done === queue.length) {
+          console.log(`[crawl] ${done}/${queue.length}  ok=${ok} fail=${fail}  · last fail: ${b.name} → ${c.errorMessage.slice(0, 80)}`);
+        }
+      } else {
+        ok++;
+        if (done % 10 === 0 || done === queue.length) {
+          console.log(`[crawl] ${done}/${queue.length}  ok=${ok} fail=${fail}  · last: ${b.name} (${c.statusCode}) ${c.loadMs}ms`);
+        }
+      }
+    }
+  }));
+
+  await browser.close();
+  const elapsed = Math.round((Date.now() - startedAt) / 1000);
+  console.log('');
+  console.log(`[crawl] done in ${elapsed}s · ${ok} ok · ${fail} failed`);
+  await pool.end();
+}
+
+main().catch((e) => {
+  console.error('[crawl]', e);
+  process.exit(1);
+});
diff --git a/src/enrich/classify_ownership.ts b/src/enrich/classify_ownership.ts
new file mode 100644
index 0000000..7042959
--- /dev/null
+++ b/src/enrich/classify_ownership.ts
@@ -0,0 +1,211 @@
+/**
+ * Ownership classifier — heuristic, no LLM, no API calls.
+ * Marks each business as corporate / franchise / chain / independent and
+ * suggests the best contact path.
+ *
+ * Run: tsx src/enrich/classify_ownership.ts
+ */
+import 'dotenv/config';
+import { pool, query } from '../../db/pool.ts';
+
+// Brands that operate via franchisees (local-franchisee path is real but
+// route corporate first for media/PR/policy questions).
+const FRANCHISE_BRANDS = new Set([
+  'mcdonald\'s', 'mcdonalds', 'subway', 'domino\'s', 'dominos', 'pizza hut', 'kfc', 'taco bell',
+  'chipotle', 'panera bread', 'panera', 'jersey mike\'s', 'jamba', 'jamba juice', "jersey mike's",
+  'starbucks', 'dunkin', 'dunkin\' donuts', 'wendy\'s', "wendy's",
+  'jack in the box', 'carl\'s jr', "carl's jr", 'chick-fil-a', 'in-n-out', 'in n out',
+  '7-eleven', 'circle k', 'baskin-robbins', 'baskin robbins',
+]);
+
+// Brands that are wholly corporate-owned (no franchise model, single owner).
+const CORPORATE_BRANDS = new Set([
+  'cvs pharmacy', 'cvs', 'walgreens', 'rite aid',
+  'whole foods market', 'whole foods', 'trader joe\'s', "trader joe's", 'ralphs', 'gelson\'s', 'gelsons',
+  'target', 'walmart', 'costco',
+  'chase', 'wells fargo', 'bank of america', 'citi', 'citibank',
+  'home depot', 'lowes',
+  'guitar center', 'best buy', 'apple store',
+  'dollar tree', 'dollar general',
+]);
+
+// Domains we'll classify as corporate locator subdomains.
+const LOCATOR_HOST_PATTERNS = [
+  /^locations?\./i,
+  /^locator\./i,
+  /^stores?\./i,
+  /^find\./i,
+  /^restaurants?\./i,
+  /^order\./i,
+  /\.locations\./i,
+  /\.stores?\./i,
+];
+
+// Third-party ordering / aggregator hosts (signals corporate or franchise restaurant).
+const AGGREGATOR_HOSTS = new Set([
+  'getreef.com', 'doordash.com', 'grubhub.com', 'ubereats.com', 'seamless.com',
+  'chownow.com', 'toasttab.com', 'olo.com', 'ezcater.com',
+]);
+
+interface Row {
+  id: number;
+  name: string;
+  category: string | null;
+  website: string | null;
+}
+
+function hostOf(url: string | null): string | null {
+  if (!url) return null;
+  try {
+    return new URL(/^https?:\/\//.test(url) ? url : 'https://' + url).hostname.replace(/^www\./, '');
+  } catch { return null; }
+}
+
+function normName(s: string): string {
+  return s.toLowerCase().replace(/[®™]/g, '').trim();
+}
+
+function classify(b: Row): {
+  ownership_class: string;
+  parent_brand: string | null;
+  is_franchise: boolean;
+  is_chain: boolean;
+  best_contact_path: string;
+  best_contact_note: string;
+  signals: Record<string, any>;
+} {
+  const nm = normName(b.name);
+  const host = hostOf(b.website);
+  const sig: Record<string, any> = { host, nm };
+
+  // 1. Franchise brand?
+  for (const brand of FRANCHISE_BRANDS) {
+    if (nm === brand || nm.startsWith(brand + ' ') || nm.endsWith(' ' + brand)) {
+      sig.brand_match = brand;
+      return {
+        ownership_class: 'franchise',
+        parent_brand: brand,
+        is_franchise: true,
+        is_chain: true,
+        best_contact_path: 'franchisee',
+        best_contact_note: 'Franchise model — local franchisee owns the storefront; corporate handles brand/PR. For local concerns contact the GM listed at this location; for media or policy go through corporate.',
+        signals: sig,
+      };
+    }
+  }
+
+  // 2. Corporate brand?
+  for (const brand of CORPORATE_BRANDS) {
+    if (nm === brand || nm.startsWith(brand + ' ') || nm.endsWith(' ' + brand)) {
+      sig.brand_match = brand;
+      return {
+        ownership_class: 'corporate',
+        parent_brand: brand,
+        is_franchise: false,
+        is_chain: true,
+        best_contact_path: 'corporate-pr',
+        best_contact_note: 'Wholly corporate-owned chain. Local store managers handle ops only; brand decisions, PR, partnerships go through corporate communications.',
+        signals: sig,
+      };
+    }
+  }
+
+  // 3. Locator-pattern host?
+  if (host) {
+    for (const pat of LOCATOR_HOST_PATTERNS) {
+      if (pat.test(host)) {
+        sig.locator_host = true;
+        return {
+          ownership_class: 'corporate',
+          parent_brand: host.split('.').slice(-2, -1)[0] || host,
+          is_franchise: false,
+          is_chain: true,
+          best_contact_path: 'corporate-pr',
+          best_contact_note: `Listed via a corporate locator subdomain (${host}). Local manager handles ops; corporate handles policy/media.`,
+          signals: sig,
+        };
+      }
+    }
+    // 4. Third-party aggregator?
+    if (AGGREGATOR_HOSTS.has(host)) {
+      sig.aggregator_host = true;
+      return {
+        ownership_class: 'unknown',
+        parent_brand: null,
+        is_franchise: false,
+        is_chain: false,
+        best_contact_path: 'unknown',
+        best_contact_note: `Site URL points to a third-party ordering platform (${host}); the storefront\'s own contact channel needs separate research.`,
+        signals: sig,
+      };
+    }
+  }
+
+  // 5. Default: independent. Best contact = owner-direct.
+  return {
+    ownership_class: 'independent',
+    parent_brand: null,
+    is_franchise: false,
+    is_chain: false,
+    best_contact_path: 'owner-direct',
+    best_contact_note: 'No corporate parent detected. The website host matches the business name pattern, suggesting local ownership. Contact the owner / operator directly via the published channels.',
+    signals: sig,
+  };
+}
+
+async function main() {
+  const r = await query<Row>(`
+    SELECT id, name, category, website FROM businesses
+    WHERE on_corridor
+    ORDER BY id
+  `);
+  console.log(`[classify] ${r.rowCount} businesses to classify`);
+
+  const tally: Record<string, number> = {};
+  let inserted = 0;
+
+  for (const b of r.rows) {
+    const c = classify(b);
+    tally[c.ownership_class] = (tally[c.ownership_class] || 0) + 1;
+
+    await query(`
+      INSERT INTO business_enrichment (
+        business_id, ownership_class, parent_brand,
+        is_franchise, is_chain, best_contact_path, best_contact_note,
+        enrichment_sources, confidence, raw_signals
+      ) VALUES ($1, $2, $3, $4, $5, $6, $7, ARRAY['classify-ownership']::TEXT[], $8, $9)
+      ON CONFLICT (business_id) DO UPDATE SET
+        enriched_at        = NOW(),
+        ownership_class    = EXCLUDED.ownership_class,
+        parent_brand       = EXCLUDED.parent_brand,
+        is_franchise       = EXCLUDED.is_franchise,
+        is_chain           = EXCLUDED.is_chain,
+        best_contact_path  = EXCLUDED.best_contact_path,
+        best_contact_note  = EXCLUDED.best_contact_note,
+        enrichment_sources = (
+          SELECT ARRAY(SELECT DISTINCT unnest(business_enrichment.enrichment_sources || ARRAY['classify-ownership']))
+        ),
+        confidence         = GREATEST(business_enrichment.confidence, EXCLUDED.confidence),
+        raw_signals        = COALESCE(business_enrichment.raw_signals, '{}'::jsonb) || EXCLUDED.raw_signals
+    `, [
+      b.id, c.ownership_class, c.parent_brand,
+      c.is_franchise, c.is_chain, c.best_contact_path, c.best_contact_note,
+      0.85, JSON.stringify(c.signals),
+    ]);
+    inserted++;
+  }
+
+  console.log('');
+  console.log(`[classify] inserted/updated ${inserted} enrichment rows`);
+  console.log('[classify] ownership distribution:');
+  for (const [k, v] of Object.entries(tally).sort((a, b) => b[1] - a[1])) {
+    console.log(`        ${k.padEnd(14)} ${v}`);
+  }
+
+  await pool.end();
+}
+
+main().catch((e) => {
+  console.error('[classify]', e);
+  process.exit(1);
+});
diff --git a/src/enrich/restaurant_owners.ts b/src/enrich/restaurant_owners.ts
new file mode 100644
index 0000000..04d8ca1
--- /dev/null
+++ b/src/enrich/restaurant_owners.ts
@@ -0,0 +1,302 @@
+/**
+ * Restaurant owner finder — DuckDuckGo HTML search → LinkedIn first hit.
+ *
+ * Free, no key. Uses https://html.duckduckgo.com/html which returns plain HTML
+ * we can parse server-side. Polite throttle: 2.5s/req.
+ *
+ * Strategy per restaurant:
+ *   1. Query: "{name}" {city} owner site:linkedin.com
+ *   2. Pull the first organic result. If it's a /in/{slug}/ profile, extract
+ *      person name + title from the snippet. If it's a /company/{slug} page,
+ *      record the LinkedIn company URL (already useful) and try again with
+ *      "owner OR founder OR chef" in the query.
+ *   3. UPSERT business_contacts row.
+ *
+ * Reality check: this is a coarse first pass. Some restaurants have no
+ * findable LinkedIn presence at all. Confidence stays moderate (0.55-0.75).
+ *
+ * Run:
+ *   npm run enrich:restaurant-owners
+ *   tsx src/enrich/restaurant_owners.ts -- --limit=10  # smoke test
+ */
+import 'dotenv/config';
+import { pool, query } from '../../db/pool.ts';
+
+const SLEEP_MS = 2500;
+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+
+interface Row {
+  id: number;
+  name: string;
+  city: string | null;
+  category: string | null;
+  ownership_class: string | null;
+}
+
+function decodeEntities(s: string): string {
+  return s
+    .replace(/&amp;/g, '&')
+    .replace(/&quot;/g, '"')
+    .replace(/&#39;/g, "'")
+    .replace(/&apos;/g, "'")
+    .replace(/&lt;/g, '<')
+    .replace(/&gt;/g, '>')
+    .replace(/&nbsp;/g, ' ')
+    .replace(/&#x27;/g, "'")
+    .replace(/&#x2F;/g, '/')
+    .replace(/&#?\w+;/g, '');
+}
+
+function stripTags(s: string): string {
+  return decodeEntities(s.replace(/<[^>]+>/g, '')).replace(/\s+/g, ' ').trim();
+}
+
+interface Hit {
+  url: string;
+  title: string;
+  snippet: string;
+}
+
+async function ddgSearch(q: string): Promise<Hit[]> {
+  const url = 'https://html.duckduckgo.com/html/?q=' + encodeURIComponent(q);
+  const r = await fetch(url, {
+    headers: {
+      'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36',
+      'Accept': 'text/html,application/xhtml+xml',
+      'Accept-Language': 'en-US,en;q=0.9',
+    },
+  });
+  if (!r.ok) throw new Error(`DDG ${r.status}`);
+  const html = await r.text();
+
+  // DDG HTML markup: each result is a div.result with
+  //   <a class="result__a" href="...">title</a>
+  //   <a class="result__url"...>display url</a>
+  //   <a class="result__snippet" ...>snippet</a>
+  // The href is wrapped through a redirect (uddg=...) — extract the real URL.
+  const hits: Hit[] = [];
+  const re = /<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<a[^>]*class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g;
+  let m: RegExpExecArray | null;
+  while ((m = re.exec(html)) !== null) {
+    let href = m[1];
+    // DDG wraps real URLs as /l/?uddg=ENCODED
+    const u = href.match(/uddg=([^&]+)/);
+    if (u) {
+      try { href = decodeURIComponent(u[1]); } catch {}
+    }
+    hits.push({
+      url: href,
+      title: stripTags(m[2]),
+      snippet: stripTags(m[3]),
+    });
+    if (hits.length >= 10) break;
+  }
+  return hits;
+}
+
+function classifyLinkedinUrl(url: string): 'profile' | 'company' | 'school' | 'jobs' | 'other' {
+  if (!/linkedin\.com/i.test(url)) return 'other';
+  if (/linkedin\.com\/in\//i.test(url)) return 'profile';
+  if (/linkedin\.com\/company\//i.test(url)) return 'company';
+  if (/linkedin\.com\/school\//i.test(url)) return 'school';
+  if (/linkedin\.com\/jobs\//i.test(url)) return 'jobs';
+  return 'other';
+}
+
+// Pull a person name + role from a LinkedIn search-result title/snippet.
+// LinkedIn format: "Person Name - Role at Company - LinkedIn"
+function parsePersonHit(h: Hit): { name: string | null; title: string | null; role: string } {
+  const t = h.title;
+  // Common pattern: "Jane Doe - Owner at Pasta House - LinkedIn"
+  let name: string | null = null;
+  let title: string | null = null;
+  const m1 = t.match(/^([^-–|]+?)\s*[-–|]\s*([^-–|]+?)(?:\s*[-–|]\s*LinkedIn)?$/i);
+  if (m1) {
+    name = m1[1].trim();
+    title = m1[2].trim();
+  } else {
+    name = t.replace(/\s*[-–|]\s*LinkedIn\s*$/i, '').trim();
+  }
+  // Best-effort role classification.
+  const role = inferRole(title || h.snippet);
+  return { name, title, role };
+}
+
+function inferRole(s: string | null): string {
+  if (!s) return 'unknown';
+  const lc = s.toLowerCase();
+  if (/\bco-?owner\b/.test(lc)) return 'co-owner';
+  if (/\bowner\b/.test(lc)) return 'owner';
+  if (/\bfounder\b/.test(lc)) return 'founder';
+  if (/\bgeneral manager\b|\bgm\b/.test(lc)) return 'gm';
+  if (/\bexecutive chef\b|\bhead chef\b/.test(lc)) return 'chef';
+  if (/\bchef\b/.test(lc)) return 'chef';
+  if (/\bmanager\b/.test(lc)) return 'manager';
+  if (/\bfranchisee\b/.test(lc)) return 'franchisee';
+  if (/\bdirector\b/.test(lc)) return 'director';
+  if (/\bpresident\b|\bceo\b/.test(lc)) return 'ceo';
+  return 'unknown';
+}
+
+interface Found {
+  source: 'linkedin';
+  source_url: string;
+  source_snippet: string;
+  role: string;
+  person_name: string | null;
+  person_title: string | null;
+  person_linkedin: string;
+  confidence: number;
+  company_page?: string;     // recorded separately if first hit was a company page
+}
+
+async function findOwnersFor(b: Row): Promise<Found[]> {
+  const queries = [
+    `"${b.name}" ${b.city || 'Los Angeles'} owner site:linkedin.com`,
+    `"${b.name}" ${b.city || 'Los Angeles'} chef OR founder site:linkedin.com`,
+  ];
+  const found: Found[] = [];
+  let companyPage: string | null = null;
+
+  for (const q of queries) {
+    let hits: Hit[] = [];
+    try { hits = await ddgSearch(q); }
+    catch (e: any) { console.log(`  [ddg] err on "${q.slice(0, 60)}": ${e.message}`); break; }
+
+    for (const h of hits.slice(0, 5)) {
+      const kind = classifyLinkedinUrl(h.url);
+      if (kind === 'company' && !companyPage) {
+        companyPage = h.url;
+        continue;
+      }
+      if (kind !== 'profile') continue;
+
+      const { name, title, role } = parsePersonHit(h);
+      if (!name) continue;
+
+      // Confidence: high if title clearly says owner/founder/chef, lower otherwise
+      const conf = (role === 'owner' || role === 'co-owner' || role === 'founder') ? 0.78
+                 : (role === 'chef' || role === 'gm' || role === 'manager' || role === 'ceo') ? 0.62
+                 : 0.45;
+
+      // Sanity check: snippet mentions the business name (case-insensitive).
+      const mentioned = (h.title + ' ' + h.snippet).toLowerCase().includes(b.name.toLowerCase().slice(0, Math.min(b.name.length, 18)));
+      if (!mentioned && conf < 0.6) continue;
+
+      found.push({
+        source: 'linkedin',
+        source_url: h.url,
+        source_snippet: (h.title + ' — ' + h.snippet).slice(0, 400),
+        role,
+        person_name: name,
+        person_title: title,
+        person_linkedin: h.url.split('?')[0],
+        confidence: mentioned ? conf : Math.max(0.3, conf - 0.15),
+      });
+      if (found.length >= 3) break;
+    }
+    if (found.length) break;     // first query gave us hits, don't burn the second
+    await sleep(SLEEP_MS);
+  }
+
+  // If we found nothing personal but did spot a company page, record that
+  // separately (still useful — Steve can DM the company).
+  if (!found.length && companyPage) {
+    found.push({
+      source: 'linkedin',
+      source_url: companyPage,
+      source_snippet: `LinkedIn company page (no individual owner profile found in top results)`,
+      role: 'corporate_local_rep',
+      person_name: null,
+      person_title: null,
+      person_linkedin: companyPage,
+      confidence: 0.5,
+      company_page: companyPage,
+    });
+  }
+  return found;
+}
+
+async function main() {
+  const argLimit = process.argv.find((a) => a.startsWith('--limit='));
+  const limit = argLimit ? parseInt(argLimit.split('=')[1], 10) : 1000;
+
+  // Restaurants only per Steve's directive — but we'll cast wide on category
+  // ('amenity: restaurant', 'amenity: cafe', 'amenity: bar', 'amenity: fast_food').
+  const r = await query<Row>(`
+    SELECT b.id, b.name, b.city, b.category, e.ownership_class
+    FROM businesses b
+    LEFT JOIN business_enrichment e ON e.business_id = b.id
+    WHERE b.on_corridor
+      AND b.category ILIKE 'amenity:%'
+      AND (b.category ILIKE '%restaurant%' OR b.category ILIKE '%cafe%' OR b.category ILIKE '%bar%' OR b.category ILIKE '%fast_food%' OR b.category ILIKE '%pub%')
+    ORDER BY (e.ownership_class = 'independent') DESC NULLS LAST, b.id
+    LIMIT $1
+  `, [limit]);
+
+  console.log(`[owners] ${r.rowCount} restaurants to look up · throttle ${SLEEP_MS}ms/req`);
+  console.log(`[owners] estimated time: ${Math.ceil(r.rowCount * SLEEP_MS / 1000 / 60)} min`);
+  console.log('');
+
+  let tot = 0, withHit = 0, contactRows = 0, errs = 0;
+  for (const b of r.rows) {
+    tot++;
+    process.stdout.write(`[${tot}/${r.rowCount}] ${b.name.slice(0, 36).padEnd(36)} ${b.city || ''}  → `);
+    try {
+      const found = await findOwnersFor(b);
+      if (found.length) {
+        withHit++;
+        for (const f of found) {
+          await query(`
+            INSERT INTO business_contacts (
+              business_id, role, person_name, person_title, person_linkedin,
+              source, source_url, source_snippet, confidence, last_verified_at
+            ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW())
+            ON CONFLICT (business_id, source, dedupe_key) DO UPDATE SET
+              role = EXCLUDED.role,
+              person_title = EXCLUDED.person_title,
+              source_snippet = EXCLUDED.source_snippet,
+              confidence = GREATEST(business_contacts.confidence, EXCLUDED.confidence),
+              last_verified_at = NOW()
+          `, [
+            b.id, f.role, f.person_name, f.person_title, f.person_linkedin,
+            f.source, f.source_url, f.source_snippet, f.confidence,
+          ]);
+          contactRows++;
+        }
+        const top = found[0];
+        console.log(`${top.role}: ${top.person_name || '(company page)'}  conf=${top.confidence.toFixed(2)}`);
+      } else {
+        console.log('no LinkedIn hit');
+      }
+    } catch (e: any) {
+      errs++;
+      console.log(`ERR ${e.message.slice(0, 60)}`);
+    }
+    await sleep(SLEEP_MS);
+  }
+
+  console.log('');
+  console.log(`[owners] done · ${tot} searched · ${withHit} with LinkedIn hits · ${contactRows} contact rows inserted · ${errs} errors`);
+
+  // Sample summary
+  const sample = await query<{ name: string; person_name: string; role: string; person_linkedin: string }>(`
+    SELECT b.name, c.person_name, c.role, c.person_linkedin
+    FROM business_contacts c JOIN businesses b ON b.id = c.business_id
+    WHERE c.last_verified_at > NOW() - INTERVAL '5 minutes'
+      AND c.person_name IS NOT NULL
+    ORDER BY c.confidence DESC LIMIT 8
+  `);
+  console.log('');
+  console.log('[owners] top hits:');
+  for (const s of sample.rows) {
+    console.log(`        ${s.name.slice(0, 30).padEnd(30)} ${(s.person_name || '').slice(0, 28).padEnd(28)} ${s.role.padEnd(10)} ${s.person_linkedin.slice(0, 60)}`);
+  }
+
+  await pool.end();
+}
+
+main().catch((e) => {
+  console.error('[owners]', e);
+  process.exit(1);
+});
diff --git a/src/ingest/foursquare.ts b/src/ingest/foursquare.ts
new file mode 100644
index 0000000..67f0ba9
--- /dev/null
+++ b/src/ingest/foursquare.ts
@@ -0,0 +1,243 @@
+/**
+ * Foursquare Places ingest — Ventura Blvd corridor.
+ *
+ * Free tier: 100,000 requests/month with sign-up. Best free option that
+ * returns the business website explicitly (HERE is OK; Yelp rarely returns it).
+ *
+ * Get a key at https://location.foursquare.com → developer dashboard.
+ * Add to ~/Projects/ventura-corridor/.env:  FOURSQUARE_API_KEY=fsq3...
+ *
+ * Strategy: same anchor walk as HERE (~27 points along Ventura Blvd, 350m
+ * radius). UPSERTs into businesses(source='foursquare'). Marks on_corridor=TRUE
+ * only when address.address matches /VENTURA BL?V?D/i.
+ *
+ * Endpoint: https://places-api.foursquare.com/places/search?ll=...&radius=...
+ *
+ * Run:
+ *   npm run ingest:foursquare
+ *   tsx src/ingest/foursquare.ts -- --limit-steps=3   # smoke test
+ */
+import 'dotenv/config';
+import { pool, query } from '../../db/pool.ts';
+
+const FSQ_KEY = process.env.FOURSQUARE_API_KEY;
+if (!FSQ_KEY) {
+  console.error('[fsq] missing FOURSQUARE_API_KEY in .env. Get one at https://location.foursquare.com.');
+  process.exit(1);
+}
+
+// Same anchor list as HERE — Ventura Blvd west→east, ~600m apart.
+const ANCHORS: Array<{ lat: number; lng: number; city: string }> = [
+  { lat: 34.1466, lng: -118.6086, city: 'Calabasas' },
+  { lat: 34.1505, lng: -118.6005, city: 'Calabasas' },
+  { lat: 34.1530, lng: -118.5915, city: 'Woodland Hills' },
+  { lat: 34.1545, lng: -118.5810, city: 'Woodland Hills' },
+  { lat: 34.1556, lng: -118.5705, city: 'Woodland Hills' },
+  { lat: 34.1563, lng: -118.5605, city: 'Woodland Hills' },
+  { lat: 34.1568, lng: -118.5500, city: 'Woodland Hills' },
+  { lat: 34.1572, lng: -118.5400, city: 'Woodland Hills' },
+  { lat: 34.1575, lng: -118.5305, city: 'Tarzana' },
+  { lat: 34.1577, lng: -118.5210, city: 'Tarzana' },
+  { lat: 34.1580, lng: -118.5115, city: 'Tarzana' },
+  { lat: 34.1583, lng: -118.5025, city: 'Encino' },
+  { lat: 34.1586, lng: -118.4930, city: 'Encino' },
+  { lat: 34.1590, lng: -118.4835, city: 'Encino' },
+  { lat: 34.1593, lng: -118.4740, city: 'Encino' },
+  { lat: 34.1597, lng: -118.4645, city: 'Encino' },
+  { lat: 34.1600, lng: -118.4550, city: 'Sherman Oaks' },
+  { lat: 34.1604, lng: -118.4455, city: 'Sherman Oaks' },
+  { lat: 34.1606, lng: -118.4360, city: 'Sherman Oaks' },
+  { lat: 34.1599, lng: -118.4270, city: 'Sherman Oaks' },
+  { lat: 34.1565, lng: -118.4200, city: 'Sherman Oaks' },
+  { lat: 34.1505, lng: -118.4140, city: 'Studio City' },
+  { lat: 34.1450, lng: -118.4070, city: 'Studio City' },
+  { lat: 34.1421, lng: -118.3985, city: 'Studio City' },
+  { lat: 34.1402, lng: -118.3895, city: 'Studio City' },
+  { lat: 34.1385, lng: -118.3820, city: 'Studio City' },
+  { lat: 34.1370, lng: -118.3760, city: 'North Hollywood' },
+];
+
+const RADIUS_M = 350;
+const PER_STEP_LIMIT = 50;        // Foursquare hard cap is 50 per request
+const SLEEP_MS = 200;
+
+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+
+interface FsqPlace {
+  fsq_place_id: string;
+  name: string;
+  categories?: Array<{ id: number; name: string; short_name?: string; plural_name?: string }>;
+  location?: {
+    address?: string;
+    locality?: string;
+    region?: string;
+    postcode?: string;
+    formatted_address?: string;
+  };
+  latitude?: number;
+  longitude?: number;
+  tel?: string;
+  website?: string;
+  social_media?: { facebook_id?: string; instagram?: string; twitter?: string };
+  email?: string;
+}
+
+function topCategory(p: FsqPlace): { name: string | null; raw: string | null } {
+  const cats = p.categories || [];
+  const c = cats[0];
+  return { name: c?.name || null, raw: c ? String(c.id) : null };
+}
+
+function isVenturaBlvd(s?: string): boolean {
+  if (!s) return false;
+  return /\bVENTURA\s+B(L)?V?D\b/i.test(s) || /\bVENTURA\s+BOULEVARD\b/i.test(s);
+}
+
+function normalizeStreet(s?: string): string | null {
+  if (!s) return null;
+  return s.toUpperCase().replace(/\s+/g, ' ').trim();
+}
+
+function parseHouseNumber(addr?: string): string | null {
+  if (!addr) return null;
+  const m = addr.match(/^\s*(\d+)\s/);
+  return m ? m[1] : null;
+}
+
+async function fetchStep(anchor: { lat: number; lng: number }): Promise<FsqPlace[]> {
+  const url = new URL('https://places-api.foursquare.com/places/search');
+  url.searchParams.set('ll', `${anchor.lat},${anchor.lng}`);
+  url.searchParams.set('radius', String(RADIUS_M));
+  url.searchParams.set('limit', String(PER_STEP_LIMIT));
+  url.searchParams.set('fields', 'fsq_place_id,name,categories,location,latitude,longitude,tel,website,social_media,email');
+  const r = await fetch(url, {
+    headers: {
+      'X-Places-Api-Version': '2025-06-17',
+      'Authorization': `Bearer ${FSQ_KEY}`,
+      'Accept': 'application/json',
+      'User-Agent': 'ventura-corridor/0.1 (+local)',
+    },
+  });
+  if (!r.ok) {
+    const txt = await r.text().catch(() => '');
+    throw new Error(`Foursquare ${r.status} ${r.statusText}: ${txt.slice(0, 200)}`);
+  }
+  const j = await r.json() as { results?: FsqPlace[] };
+  return j.results || [];
+}
+
+async function upsertPlace(p: FsqPlace, anchorCity: string): Promise<'new' | 'updated'> {
+  const cat = topCategory(p);
+  const addr = p.location?.address;
+  const street = normalizeStreet(addr);
+  const onCorridor = isVenturaBlvd(addr) || isVenturaBlvd(p.location?.formatted_address);
+  const houseNum = parseHouseNumber(addr);
+  const block = houseNum ? Math.floor(parseInt(houseNum, 10) / 100) * 100 || null : null;
+
+  const r = await query<{ id: number; first_seen_at: string }>(`
+    INSERT INTO businesses (
+      source, source_id, name, category, category_raw,
+      address, street_number, street_name, city, zip, lat, lng,
+      phone, website, on_corridor, corridor_block, raw, last_seen_at
+    ) VALUES (
+      'foursquare', $1, $2, $3, $4,
+      $5, $6, $7, $8, $9, $10, $11,
+      $12, $13, $14, $15, $16, NOW()
+    )
+    ON CONFLICT (source, source_id) DO UPDATE SET
+      name           = EXCLUDED.name,
+      category       = EXCLUDED.category,
+      category_raw   = EXCLUDED.category_raw,
+      address        = EXCLUDED.address,
+      street_number  = EXCLUDED.street_number,
+      street_name    = EXCLUDED.street_name,
+      city           = COALESCE(EXCLUDED.city, businesses.city),
+      zip            = COALESCE(EXCLUDED.zip, businesses.zip),
+      lat            = EXCLUDED.lat,
+      lng            = EXCLUDED.lng,
+      phone          = COALESCE(EXCLUDED.phone, businesses.phone),
+      website        = COALESCE(EXCLUDED.website, businesses.website),
+      on_corridor    = EXCLUDED.on_corridor OR businesses.on_corridor,
+      corridor_block = COALESCE(EXCLUDED.corridor_block, businesses.corridor_block),
+      raw            = EXCLUDED.raw,
+      last_seen_at   = NOW()
+    RETURNING id, first_seen_at
+  `, [
+    p.fsq_place_id,
+    p.name,
+    cat.name,
+    cat.raw,
+    p.location?.formatted_address || addr || null,
+    houseNum,
+    street,
+    p.location?.locality || anchorCity,
+    p.location?.postcode || null,
+    p.latitude ?? null,
+    p.longitude ?? null,
+    p.tel || null,
+    p.website || null,
+    onCorridor,
+    block,
+    JSON.stringify(p),
+  ]);
+  return Date.parse(r.rows[0].first_seen_at) > Date.now() - 5_000 ? 'new' : 'updated';
+}
+
+async function main() {
+  const argLimit = process.argv.find((a) => a.startsWith('--limit-steps='));
+  const stepLimit = argLimit ? parseInt(argLimit.split('=')[1], 10) : ANCHORS.length;
+
+  const run = await query<{ id: number }>(
+    `INSERT INTO ingest_runs (source, notes) VALUES ('foursquare', $1) RETURNING id`,
+    [`Ventura Blvd corridor — ${stepLimit}/${ANCHORS.length} anchors`],
+  );
+  const runId = run.rows[0].id;
+  console.log(`[fsq] ingest_run #${runId} starting · ${stepLimit} anchors · ${RADIUS_M}m radius`);
+
+  let inTotal = 0, newTotal = 0, updTotal = 0, errMessage: string | null = null;
+
+  for (let i = 0; i < stepLimit; i++) {
+    const a = ANCHORS[i];
+    process.stdout.write(`[step ${i + 1}/${stepLimit}] ${a.city.padEnd(16)} ${a.lat.toFixed(4)},${a.lng.toFixed(4)} · `);
+    try {
+      const places = await fetchStep(a);
+      let stepNew = 0, stepUpd = 0;
+      for (const p of places) {
+        const v = await upsertPlace(p, a.city);
+        if (v === 'new') stepNew++; else stepUpd++;
+      }
+      inTotal += places.length;
+      newTotal += stepNew;
+      updTotal += stepUpd;
+      console.log(`fetched ${places.length} · new ${stepNew} · upd ${stepUpd}`);
+    } catch (e: any) {
+      console.log(`ERROR: ${e.message}`);
+      errMessage = e.message;
+      break;
+    }
+    if (i < stepLimit - 1) await sleep(SLEEP_MS);
+  }
+
+  await query(
+    `UPDATE ingest_runs SET finished_at=NOW(), rows_in=$1, rows_new=$2, rows_updated=$3, error_message=$4 WHERE id=$5`,
+    [inTotal, newTotal, updTotal, errMessage, runId],
+  );
+
+  const corridor = await query<{ n: string }>(
+    `SELECT COUNT(*)::text AS n FROM businesses WHERE on_corridor`,
+  );
+  const withSite = await query<{ n: string }>(
+    `SELECT COUNT(*)::text AS n FROM businesses WHERE on_corridor AND website IS NOT NULL`,
+  );
+  console.log('');
+  console.log(`[fsq] done. fetched ${inTotal} · new ${newTotal} · updated ${updTotal}`);
+  console.log(`[fsq] businesses on Ventura Blvd: ${corridor.rows[0].n}`);
+  console.log(`[fsq] with websites: ${withSite.rows[0].n}`);
+
+  await pool.end();
+}
+
+main().catch((e) => {
+  console.error('[fsq]', e);
+  process.exit(1);
+});
diff --git a/src/ingest/here_discover.ts b/src/ingest/here_discover.ts
new file mode 100644
index 0000000..9127b38
--- /dev/null
+++ b/src/ingest/here_discover.ts
@@ -0,0 +1,232 @@
+/**
+ * HERE Discover ingest — Ventura Blvd corridor.
+ *
+ * Strategy:
+ *   1. Walk a coarse polyline of Ventura Blvd west→east in ~600m steps.
+ *   2. For each anchor point, call HERE /discover with q="businesses" and a small radius.
+ *   3. UPSERT each unique result into businesses (source='here', source_id=HERE id).
+ *   4. Mark on_corridor=TRUE only when address.street matches /VENTURA BLVD/i.
+ *   5. Log the run in ingest_runs.
+ *
+ * Free tier: 30,000 requests/month. Each step = 1 request. ~70 steps = ~70 req → trivial cost.
+ *
+ * Run: npm run ingest:here
+ *      or: tsx src/ingest/here_discover.ts -- --limit-steps=5  (smoke test)
+ */
+import 'dotenv/config';
+import { pool, query } from '../../db/pool.ts';
+
+const HERE_KEY = process.env.HERE_API_KEY;
+if (!HERE_KEY) {
+  console.error('[here] missing HERE_API_KEY in .env. Get one at https://platform.here.com or via /secrets.');
+  process.exit(1);
+}
+
+// ─── Ventura Blvd polyline anchor points (approx, west → east) ──────────
+// Pulled from OpenStreetMap centerline, sampled every ~600m. Covers
+// Calabasas (Mulholland) → Woodland Hills → Tarzana → Encino → Sherman Oaks
+// → Studio City → North Hollywood (Cahuenga). Total ~17 mi.
+const ANCHORS: Array<{ lat: number; lng: number; city: string }> = [
+  // Calabasas / west end
+  { lat: 34.1466, lng: -118.6086, city: 'Calabasas' },
+  { lat: 34.1505, lng: -118.6005, city: 'Calabasas' },
+  { lat: 34.1530, lng: -118.5915, city: 'Woodland Hills' },
+  { lat: 34.1545, lng: -118.5810, city: 'Woodland Hills' },
+  { lat: 34.1556, lng: -118.5705, city: 'Woodland Hills' },
+  { lat: 34.1563, lng: -118.5605, city: 'Woodland Hills' },
+  { lat: 34.1568, lng: -118.5500, city: 'Woodland Hills' },
+  { lat: 34.1572, lng: -118.5400, city: 'Woodland Hills' },
+  { lat: 34.1575, lng: -118.5305, city: 'Tarzana' },
+  { lat: 34.1577, lng: -118.5210, city: 'Tarzana' },
+  { lat: 34.1580, lng: -118.5115, city: 'Tarzana' },
+  { lat: 34.1583, lng: -118.5025, city: 'Encino' },
+  { lat: 34.1586, lng: -118.4930, city: 'Encino' },
+  { lat: 34.1590, lng: -118.4835, city: 'Encino' },
+  { lat: 34.1593, lng: -118.4740, city: 'Encino' },
+  { lat: 34.1597, lng: -118.4645, city: 'Encino' },
+  { lat: 34.1600, lng: -118.4550, city: 'Sherman Oaks' },
+  { lat: 34.1604, lng: -118.4455, city: 'Sherman Oaks' },
+  { lat: 34.1606, lng: -118.4360, city: 'Sherman Oaks' },
+  { lat: 34.1599, lng: -118.4270, city: 'Sherman Oaks' },
+  { lat: 34.1565, lng: -118.4200, city: 'Sherman Oaks' },
+  { lat: 34.1505, lng: -118.4140, city: 'Studio City' },
+  { lat: 34.1450, lng: -118.4070, city: 'Studio City' },
+  { lat: 34.1421, lng: -118.3985, city: 'Studio City' },
+  { lat: 34.1402, lng: -118.3895, city: 'Studio City' },
+  { lat: 34.1385, lng: -118.3820, city: 'Studio City' },
+  { lat: 34.1370, lng: -118.3760, city: 'North Hollywood' }, // Cahuenga junction (east end)
+];
+
+const RADIUS_M = 350;            // overlap so we don't miss between anchors
+const PER_STEP_LIMIT = 100;      // HERE max items per call
+const SLEEP_MS = 250;            // polite throttle
+
+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+
+interface HereItem {
+  id: string;
+  title: string;
+  resultType?: string;
+  address?: {
+    label?: string;
+    countryCode?: string;
+    state?: string;
+    city?: string;
+    district?: string;
+    street?: string;
+    houseNumber?: string;
+    postalCode?: string;
+  };
+  position?: { lat: number; lng: number };
+  contacts?: Array<{ phone?: Array<{ value: string }>; www?: Array<{ value: string }> }>;
+  categories?: Array<{ id: string; name: string; primary?: boolean }>;
+}
+
+function topCategory(item: HereItem): { name: string | null; raw: string | null } {
+  const cats = item.categories || [];
+  const primary = cats.find((c) => c.primary) || cats[0];
+  return { name: primary?.name || null, raw: primary?.id || null };
+}
+
+function normalizeStreet(s?: string): string | null {
+  if (!s) return null;
+  return s.toUpperCase().replace(/\s+/g, ' ').trim();
+}
+
+function isVenturaBlvd(s?: string): boolean {
+  if (!s) return false;
+  return /\bVENTURA\s+B(L)?V?D\b/i.test(s) || /\bVENTURA\s+BOULEVARD\b/i.test(s);
+}
+
+async function fetchStep(anchor: { lat: number; lng: number }): Promise<HereItem[]> {
+  const url = new URL('https://discover.search.hereapi.com/v1/discover');
+  url.searchParams.set('apiKey', HERE_KEY!);
+  url.searchParams.set('at', `${anchor.lat},${anchor.lng}`);
+  url.searchParams.set('q', 'business');
+  url.searchParams.set('limit', String(PER_STEP_LIMIT));
+  url.searchParams.set('in', `circle:${anchor.lat},${anchor.lng};r=${RADIUS_M}`);
+  const r = await fetch(url, { headers: { 'User-Agent': 'ventura-corridor/0.1 (+local)' } });
+  if (!r.ok) {
+    const txt = await r.text().catch(() => '');
+    throw new Error(`HERE ${r.status} ${r.statusText}: ${txt.slice(0, 200)}`);
+  }
+  const j = await r.json() as { items?: HereItem[] };
+  return j.items || [];
+}
+
+async function upsertBusiness(item: HereItem, anchorCity: string): Promise<'new' | 'updated'> {
+  const cat = topCategory(item);
+  const street = normalizeStreet(item.address?.street);
+  const onCorridor = isVenturaBlvd(item.address?.street) ||
+                     isVenturaBlvd(item.address?.label);
+  const block = item.address?.houseNumber
+    ? Math.floor(parseInt(item.address.houseNumber, 10) / 100) * 100 || null
+    : null;
+  const phone = item.contacts?.[0]?.phone?.[0]?.value || null;
+  const website = item.contacts?.[0]?.www?.[0]?.value || null;
+
+  const r = await query<{ id: number; first_seen_at: string }>(`
+    INSERT INTO businesses (
+      source, source_id, name, category, category_raw,
+      address, street_number, street_name, city, zip, lat, lng,
+      phone, website, on_corridor, corridor_block, raw, last_seen_at
+    ) VALUES (
+      'here', $1, $2, $3, $4,
+      $5, $6, $7, $8, $9, $10, $11,
+      $12, $13, $14, $15, $16, NOW()
+    )
+    ON CONFLICT (source, source_id) DO UPDATE SET
+      name           = EXCLUDED.name,
+      category       = EXCLUDED.category,
+      category_raw   = EXCLUDED.category_raw,
+      address        = EXCLUDED.address,
+      street_number  = EXCLUDED.street_number,
+      street_name    = EXCLUDED.street_name,
+      city           = COALESCE(EXCLUDED.city, businesses.city),
+      zip            = COALESCE(EXCLUDED.zip, businesses.zip),
+      lat            = EXCLUDED.lat,
+      lng            = EXCLUDED.lng,
+      phone          = COALESCE(EXCLUDED.phone, businesses.phone),
+      website        = COALESCE(EXCLUDED.website, businesses.website),
+      on_corridor    = EXCLUDED.on_corridor OR businesses.on_corridor,
+      corridor_block = COALESCE(EXCLUDED.corridor_block, businesses.corridor_block),
+      raw            = EXCLUDED.raw,
+      last_seen_at   = NOW()
+    RETURNING id, first_seen_at
+  `, [
+    item.id,
+    item.title,
+    cat.name,
+    cat.raw,
+    item.address?.label || null,
+    item.address?.houseNumber || null,
+    street,
+    item.address?.city || anchorCity,
+    item.address?.postalCode || null,
+    item.position?.lat ?? null,
+    item.position?.lng ?? null,
+    phone,
+    website,
+    onCorridor,
+    block,
+    JSON.stringify(item),
+  ]);
+  // Same-second first_seen_at means this row was just inserted.
+  return Date.parse(r.rows[0].first_seen_at) > Date.now() - 5_000 ? 'new' : 'updated';
+}
+
+async function main() {
+  const argLimit = process.argv.find((a) => a.startsWith('--limit-steps='));
+  const stepLimit = argLimit ? parseInt(argLimit.split('=')[1], 10) : ANCHORS.length;
+
+  const run = await query<{ id: number }>(
+    `INSERT INTO ingest_runs (source, notes) VALUES ('here', $1) RETURNING id`,
+    [`Ventura Blvd corridor — ${stepLimit}/${ANCHORS.length} anchors`],
+  );
+  const runId = run.rows[0].id;
+  console.log(`[here] ingest_run #${runId} starting · ${stepLimit} anchors · ${RADIUS_M}m radius`);
+
+  let inTotal = 0, newTotal = 0, updTotal = 0, errMessage: string | null = null;
+
+  for (let i = 0; i < stepLimit; i++) {
+    const a = ANCHORS[i];
+    process.stdout.write(`[step ${i + 1}/${stepLimit}] ${a.city.padEnd(16)} ${a.lat.toFixed(4)},${a.lng.toFixed(4)} · `);
+    try {
+      const items = await fetchStep(a);
+      let stepNew = 0, stepUpd = 0;
+      for (const it of items) {
+        const v = await upsertBusiness(it, a.city);
+        if (v === 'new') stepNew++; else stepUpd++;
+      }
+      inTotal += items.length;
+      newTotal += stepNew;
+      updTotal += stepUpd;
+      console.log(`fetched ${items.length} · new ${stepNew} · upd ${stepUpd}`);
+    } catch (e: any) {
+      console.log(`ERROR: ${e.message}`);
+      errMessage = e.message;
+      break;
+    }
+    if (i < stepLimit - 1) await sleep(SLEEP_MS);
+  }
+
+  await query(
+    `UPDATE ingest_runs SET finished_at=NOW(), rows_in=$1, rows_new=$2, rows_updated=$3, error_message=$4 WHERE id=$5`,
+    [inTotal, newTotal, updTotal, errMessage, runId],
+  );
+
+  // Summary
+  const corridor = await query<{ n: string }>(
+    `SELECT COUNT(*)::text AS n FROM businesses WHERE on_corridor`,
+  );
+  console.log('');
+  console.log(`[here] done. fetched ${inTotal} · new ${newTotal} · updated ${updTotal}`);
+  console.log(`[here] businesses on Ventura Blvd: ${corridor.rows[0].n}`);
+
+  await pool.end();
+}
+
+main().catch((e) => {
+  console.error('[here]', e);
+  process.exit(1);
+});
diff --git a/src/ingest/osm_overpass.ts b/src/ingest/osm_overpass.ts
new file mode 100644
index 0000000..75fb7b5
--- /dev/null
+++ b/src/ingest/osm_overpass.ts
@@ -0,0 +1,212 @@
+/**
+ * OpenStreetMap Overpass ingest — Ventura Blvd corridor.
+ *
+ * Free, no API key. Hits the public Overpass API at overpass-api.de.
+ * Be polite: one big query rather than 27 small ones.
+ *
+ * Strategy:
+ *   1. Bbox the entire 17-mi corridor (~34.130..34.165 lat × -118.620..-118.370 lng).
+ *   2. Pull every node + way that has an `addr:street` tag matching `Ventura B*`
+ *      AND at least one business-y tag (shop, amenity, office, craft, tourism, healthcare, leisure).
+ *   3. UPSERT into businesses(source='osm', source_id=node-/way-/relation-id).
+ *
+ * Coverage: OSM has ~40-60% of Ventura Blvd storefronts depending on which
+ * stretch (Studio City and Sherman Oaks better-mapped than Calabasas).
+ * We layer this with HERE/Foursquare later — UPSERT keys are (source, source_id)
+ * so each provider has its own row; dedupe across providers is a separate pass.
+ *
+ * Run: npm run ingest:overpass
+ */
+import 'dotenv/config';
+import { pool, query } from '../../db/pool.ts';
+
+const OVERPASS_URL = 'https://overpass-api.de/api/interpreter';
+const BBOX = {
+  south: parseFloat(process.env.VENTURA_BBOX_SOUTH || '34.130'),
+  west:  parseFloat(process.env.VENTURA_BBOX_WEST  || '-118.620'),
+  north: parseFloat(process.env.VENTURA_BBOX_NORTH || '34.165'),
+  east:  parseFloat(process.env.VENTURA_BBOX_EAST  || '-118.370'),
+};
+
+// Overpass QL — businesses with a Ventura Blvd address.
+// We require BOTH: (a) some kind of business tag, (b) addr:street matches.
+const QUERY = `
+[out:json][timeout:90];
+(
+  node["addr:street"~"Ventura B(l)?(v)?d|Ventura Boulevard",i]
+      [~"^(shop|amenity|office|craft|tourism|healthcare|leisure)$"~".",i]
+      (${BBOX.south},${BBOX.west},${BBOX.north},${BBOX.east});
+  way["addr:street"~"Ventura B(l)?(v)?d|Ventura Boulevard",i]
+      [~"^(shop|amenity|office|craft|tourism|healthcare|leisure)$"~".",i]
+      (${BBOX.south},${BBOX.west},${BBOX.north},${BBOX.east});
+);
+out tags center;
+`.trim();
+
+interface OsmElement {
+  type: 'node' | 'way' | 'relation';
+  id: number;
+  lat?: number;
+  lon?: number;
+  center?: { lat: number; lon: number };
+  tags?: Record<string, string>;
+}
+
+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+
+function topCategory(tags: Record<string, string>): { name: string | null; raw: string | null } {
+  // Order matters — pick the most specific business tag first.
+  for (const k of ['shop', 'amenity', 'craft', 'office', 'healthcare', 'tourism', 'leisure']) {
+    if (tags[k]) {
+      const friendly = `${k}: ${tags[k].replace(/_/g, ' ')}`;
+      return { name: friendly, raw: `${k}=${tags[k]}` };
+    }
+  }
+  return { name: null, raw: null };
+}
+
+function isVenturaBlvd(s?: string): boolean {
+  if (!s) return false;
+  return /\bventura\s+(b(l)?(v)?d|boulevard)\b/i.test(s);
+}
+
+function normalizeStreet(s?: string): string | null {
+  if (!s) return null;
+  return s.toUpperCase().replace(/\s+/g, ' ').trim();
+}
+
+async function upsertElement(el: OsmElement): Promise<'new' | 'updated' | 'skipped'> {
+  const t = el.tags || {};
+  if (!t.name) return 'skipped';        // anonymous nodes are noise
+  const cat = topCategory(t);
+  if (!cat.name) return 'skipped';      // no business tag — shouldn't happen given our query
+
+  const lat = el.lat ?? el.center?.lat ?? null;
+  const lng = el.lon ?? el.center?.lon ?? null;
+  const street = t['addr:street'] || null;
+  const houseNum = t['addr:housenumber'] || null;
+  const block = houseNum ? Math.floor(parseInt(houseNum, 10) / 100) * 100 || null : null;
+  const onCorridor = isVenturaBlvd(street);
+
+  const r = await query<{ id: number; first_seen_at: string }>(`
+    INSERT INTO businesses (
+      source, source_id, name, category, category_raw,
+      address, street_number, street_name, city, zip, lat, lng,
+      phone, website, on_corridor, corridor_block, raw, last_seen_at
+    ) VALUES (
+      'osm', $1, $2, $3, $4,
+      $5, $6, $7, $8, $9, $10, $11,
+      $12, $13, $14, $15, $16, NOW()
+    )
+    ON CONFLICT (source, source_id) DO UPDATE SET
+      name           = EXCLUDED.name,
+      category       = EXCLUDED.category,
+      category_raw   = EXCLUDED.category_raw,
+      address        = EXCLUDED.address,
+      street_number  = EXCLUDED.street_number,
+      street_name    = EXCLUDED.street_name,
+      city           = COALESCE(EXCLUDED.city, businesses.city),
+      zip            = COALESCE(EXCLUDED.zip, businesses.zip),
+      lat            = EXCLUDED.lat,
+      lng            = EXCLUDED.lng,
+      phone          = COALESCE(EXCLUDED.phone, businesses.phone),
+      website        = COALESCE(EXCLUDED.website, businesses.website),
+      on_corridor    = EXCLUDED.on_corridor OR businesses.on_corridor,
+      corridor_block = COALESCE(EXCLUDED.corridor_block, businesses.corridor_block),
+      raw            = EXCLUDED.raw,
+      last_seen_at   = NOW()
+    RETURNING id, first_seen_at
+  `, [
+    `${el.type}/${el.id}`,
+    t.name,
+    cat.name,
+    cat.raw,
+    [houseNum, street, t['addr:city'], t['addr:postcode']].filter(Boolean).join(' '),
+    houseNum,
+    normalizeStreet(street),
+    t['addr:city'] || null,
+    t['addr:postcode'] || null,
+    lat,
+    lng,
+    t['phone'] || t['contact:phone'] || null,
+    t['website'] || t['contact:website'] || t['url'] || null,
+    onCorridor,
+    block,
+    JSON.stringify(el),
+  ]);
+  return Date.parse(r.rows[0].first_seen_at) > Date.now() - 5_000 ? 'new' : 'updated';
+}
+
+async function main() {
+  const run = await query<{ id: number }>(
+    `INSERT INTO ingest_runs (source, notes) VALUES ('osm', $1) RETURNING id`,
+    [`Overpass — bbox ${BBOX.south},${BBOX.west} → ${BBOX.north},${BBOX.east} · addr:street ~ Ventura B*`],
+  );
+  const runId = run.rows[0].id;
+  console.log(`[osm] ingest_run #${runId} starting · single Overpass query`);
+  console.log(`[osm] bbox ${BBOX.south},${BBOX.west} → ${BBOX.north},${BBOX.east}`);
+
+  let inTotal = 0, newTotal = 0, updTotal = 0, skippedTotal = 0, errMessage: string | null = null;
+
+  try {
+    process.stdout.write('[osm] firing Overpass query (up to 90s timeout) ... ');
+    const r = await fetch(OVERPASS_URL, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/x-www-form-urlencoded',
+        'User-Agent': 'ventura-corridor/0.1 (local research; contact via project repo)',
+      },
+      body: 'data=' + encodeURIComponent(QUERY),
+    });
+    if (!r.ok) {
+      const txt = await r.text().catch(() => '');
+      throw new Error(`Overpass ${r.status} ${r.statusText}: ${txt.slice(0, 300)}`);
+    }
+    const j = await r.json() as { elements?: OsmElement[] };
+    const elements = j.elements || [];
+    console.log(`got ${elements.length} elements`);
+    inTotal = elements.length;
+
+    let i = 0;
+    for (const el of elements) {
+      i++;
+      if (i % 25 === 0) process.stdout.write(`  · processed ${i}/${elements.length}\n`);
+      const v = await upsertElement(el);
+      if (v === 'new') newTotal++;
+      else if (v === 'updated') updTotal++;
+      else skippedTotal++;
+    }
+  } catch (e: any) {
+    console.log(`ERROR: ${e.message}`);
+    errMessage = e.message;
+  }
+
+  await query(
+    `UPDATE ingest_runs SET finished_at=NOW(), rows_in=$1, rows_new=$2, rows_updated=$3, error_message=$4 WHERE id=$5`,
+    [inTotal, newTotal, updTotal, errMessage, runId],
+  );
+
+  // Summary
+  const corridor = await query<{ n: string }>(`SELECT COUNT(*)::text AS n FROM businesses WHERE on_corridor`);
+  const withSite = await query<{ n: string }>(`SELECT COUNT(*)::text AS n FROM businesses WHERE on_corridor AND website IS NOT NULL`);
+  const cities = await query<{ city: string; n: string }>(`
+    SELECT COALESCE(city, '(none)') AS city, COUNT(*)::text AS n
+    FROM businesses WHERE on_corridor
+    GROUP BY city ORDER BY COUNT(*) DESC LIMIT 10
+  `);
+
+  console.log('');
+  console.log(`[osm] done. fetched ${inTotal} · new ${newTotal} · updated ${updTotal} · skipped ${skippedTotal}`);
+  console.log(`[osm] businesses on Ventura Blvd: ${corridor.rows[0].n}  (with website: ${withSite.rows[0].n})`);
+  if (cities.rows.length) {
+    console.log('[osm] by city:');
+    for (const c of cities.rows) console.log(`        ${c.city.padEnd(20)} ${c.n}`);
+  }
+
+  await pool.end();
+}
+
+main().catch((e) => {
+  console.error('[osm]', e);
+  process.exit(1);
+});
diff --git a/src/score/seo_score.ts b/src/score/seo_score.ts
new file mode 100644
index 0000000..073b841
--- /dev/null
+++ b/src/score/seo_score.ts
@@ -0,0 +1,218 @@
+/**
+ * SEO scorer — Phase iii.
+ *
+ * Reads the latest front_page_audits row per business, computes a 12-signal
+ * weighted score (0-100), assigns a tier (A/B/C/D), and ranks each business
+ * within its top-level category. Inserts one row per scoring run.
+ *
+ * Mirrors the lawyer/doctor methodology v1.0 weights, adapted for storefront
+ * directory businesses (no §6155 layer; weights bias toward conversion-relevant
+ * signals: HTTPS, mobile, schema, title/meta, alt-text, performance bands).
+ *
+ * Run: npm run score
+ */
+import 'dotenv/config';
+import { pool, query } from '../../db/pool.ts';
+
+interface LatestAudit {
+  audit_id: number;
+  business_id: number;
+  category: string | null;
+  has_https: boolean | null;
+  has_viewport: boolean | null;
+  has_title: boolean | null;
+  title_text: string | null;
+  meta_desc: string | null;
+  has_h1: boolean | null;
+  h1_text: string | null;
+  schema_org_count: number | null;
+  schema_org_types: string[] | null;
+  og_image: string | null;
+  alt_coverage: number | null;
+  word_count: number | null;
+  outbound_links: number | null;
+  internal_links: number | null;
+  bytes_html: number | null;
+  bytes_total: number | null;
+  ttfb_ms: number | null;
+  load_ms: number | null;
+  status_code: number | null;
+  error_message: string | null;
+}
+
+// 12 signals × weights = 100. Each signal returns its earned points.
+const SIGNAL_WEIGHTS = {
+  has_https:       10,
+  has_viewport:    10,
+  has_title:        8,
+  title_quality:    6,   // length 30-65
+  meta_desc:        8,   // present + length 70-160
+  has_h1:           8,
+  schema_org:      10,   // count >= 1, +bonus for LocalBusiness/Organization
+  og_image:         5,
+  alt_coverage:    10,   // 0..1 fraction
+  word_count:       8,   // 80-2000 sweet spot
+  ttfb_band:        9,   // <300ms = full
+  load_band:        8,   // <2500ms = full
+};
+
+function scoreSignals(a: LatestAudit): { signals: Record<string, number>; total: number } {
+  const s: Record<string, number> = {};
+
+  s.has_https = a.has_https ? SIGNAL_WEIGHTS.has_https : 0;
+  s.has_viewport = a.has_viewport ? SIGNAL_WEIGHTS.has_viewport : 0;
+  s.has_title = a.has_title ? SIGNAL_WEIGHTS.has_title : 0;
+
+  // Title quality: 30-65 chars is the sweet spot for SERP truncation.
+  const tl = a.title_text?.length || 0;
+  s.title_quality = (tl >= 30 && tl <= 65) ? SIGNAL_WEIGHTS.title_quality
+                  : (tl > 0)              ? Math.round(SIGNAL_WEIGHTS.title_quality * 0.5)
+                  : 0;
+
+  // Meta description: present + roughly within 70-160 chars
+  const ml = a.meta_desc?.length || 0;
+  s.meta_desc = (ml >= 70 && ml <= 200) ? SIGNAL_WEIGHTS.meta_desc
+              : (ml > 0)                ? Math.round(SIGNAL_WEIGHTS.meta_desc * 0.5)
+              : 0;
+
+  s.has_h1 = a.has_h1 ? SIGNAL_WEIGHTS.has_h1 : 0;
+
+  // Schema.org: any LD-JSON earns half; LocalBusiness/Organization gets full.
+  const types = (a.schema_org_types || []).map((t) => t.toLowerCase());
+  const hasLocal = types.some((t) => /localbusiness|restaurant|store|organization|professionalservice/.test(t));
+  s.schema_org = (a.schema_org_count || 0) > 0
+               ? (hasLocal ? SIGNAL_WEIGHTS.schema_org : Math.round(SIGNAL_WEIGHTS.schema_org * 0.5))
+               : 0;
+
+  s.og_image = a.og_image ? SIGNAL_WEIGHTS.og_image : 0;
+
+  // Alt coverage: linear from 0..1
+  const ac = a.alt_coverage ?? 0;
+  s.alt_coverage = Math.round(SIGNAL_WEIGHTS.alt_coverage * Math.max(0, Math.min(1, ac)));
+
+  // Word count: 80-2000 sweet spot; <80 anemic, >2000 likely text-spam (decays).
+  const wc = a.word_count || 0;
+  s.word_count = (wc >= 80 && wc <= 2000) ? SIGNAL_WEIGHTS.word_count
+              : (wc > 0 && wc < 80)        ? Math.round(SIGNAL_WEIGHTS.word_count * (wc / 80))
+              : (wc > 2000)                ? Math.round(SIGNAL_WEIGHTS.word_count * 0.6)
+              : 0;
+
+  // TTFB: <300 full, <800 half, else 0
+  const ttfb = a.ttfb_ms ?? 9999;
+  s.ttfb_band = ttfb <= 300 ? SIGNAL_WEIGHTS.ttfb_band
+              : ttfb <= 800 ? Math.round(SIGNAL_WEIGHTS.ttfb_band * 0.5)
+              : 0;
+
+  // Load: <2500 full, <5000 half, else 0
+  const load = a.load_ms ?? 99999;
+  s.load_band = load <= 2500 ? SIGNAL_WEIGHTS.load_band
+              : load <= 5000 ? Math.round(SIGNAL_WEIGHTS.load_band * 0.5)
+              : 0;
+
+  // If the page errored or returned non-2xx, force the score to a hard floor:
+  // technical attributes don't matter if the front page is broken.
+  if (a.error_message || (a.status_code && (a.status_code < 200 || a.status_code >= 400))) {
+    for (const k of Object.keys(s)) s[k] = 0;
+  }
+
+  const total = Object.values(s).reduce((acc, v) => acc + v, 0);
+  return { signals: s, total };
+}
+
+function scoreToTier(total: number): 'A' | 'B' | 'C' | 'D' {
+  if (total >= 80) return 'A';
+  if (total >= 60) return 'B';
+  if (total >= 40) return 'C';
+  return 'D';
+}
+
+async function main() {
+  console.log('[score] reading latest audit per business…');
+  const r = await query<LatestAudit>(`
+    WITH latest AS (
+      SELECT DISTINCT ON (business_id) *
+      FROM front_page_audits
+      ORDER BY business_id, audited_at DESC
+    )
+    SELECT l.id AS audit_id, b.id AS business_id, b.category,
+           l.has_https, l.has_viewport, l.has_title, l.title_text, l.meta_desc,
+           l.has_h1, l.h1_text, l.schema_org_count, l.schema_org_types,
+           l.og_image, l.alt_coverage, l.word_count,
+           l.outbound_links, l.internal_links, l.bytes_html, l.bytes_total,
+           l.ttfb_ms, l.load_ms, l.status_code, l.error_message
+    FROM latest l
+    JOIN businesses b ON b.id = l.business_id
+    WHERE b.on_corridor
+  `);
+
+  if (!r.rowCount) {
+    console.log('[score] no audits to score yet — run the crawler first.');
+    await pool.end();
+    return;
+  }
+
+  // Score in memory first, then rank within category, then bulk-insert.
+  const scored = r.rows.map((a) => {
+    const { signals, total } = scoreSignals(a);
+    return { audit_id: a.audit_id, business_id: a.business_id, category: a.category, signals, total };
+  });
+
+  // Rank within category (top-level bucket — split on ':' to collapse e.g.
+  // "amenity: restaurant" and "amenity: cafe" into the same competitor cohort).
+  const byBucket = new Map<string, typeof scored>();
+  for (const x of scored) {
+    const bucket = (x.category || 'uncategorized').split(':')[0].trim().toLowerCase();
+    if (!byBucket.has(bucket)) byBucket.set(bucket, []);
+    byBucket.get(bucket)!.push(x);
+  }
+  for (const arr of byBucket.values()) {
+    arr.sort((a, b) => b.total - a.total);
+    arr.forEach((x, i) => {
+      (x as any).category_rank = i + 1;
+      (x as any).category_n = arr.length;
+    });
+  }
+
+  console.log(`[score] scoring ${scored.length} businesses across ${byBucket.size} category buckets`);
+
+  let inserted = 0;
+  for (const x of scored) {
+    await query(`
+      INSERT INTO seo_scores (audit_id, business_id, total, signals, tier, category_rank, category_n)
+      VALUES ($1, $2, $3, $4, $5, $6, $7)
+    `, [x.audit_id, x.business_id, x.total, JSON.stringify(x.signals), scoreToTier(x.total),
+        (x as any).category_rank, (x as any).category_n]);
+    inserted++;
+  }
+
+  // Tier histogram + top 10 + bottom 10
+  const tier = await query<{ tier: string; n: string; avg: string }>(`
+    SELECT tier, COUNT(*)::text AS n, ROUND(AVG(total))::text AS avg
+    FROM seo_scores s
+    WHERE scored_at > NOW() - INTERVAL '5 minutes'
+    GROUP BY tier ORDER BY tier
+  `);
+  console.log('');
+  console.log(`[score] inserted ${inserted} score rows`);
+  console.log('[score] tier distribution (this run):');
+  for (const t of tier.rows) console.log(`        ${t.tier}  ${t.n.padStart(4)}  · avg ${t.avg}`);
+
+  const top = await query<{ name: string; total: number; tier: string; category: string }>(`
+    SELECT b.name, s.total, s.tier, b.category
+    FROM seo_scores s JOIN businesses b ON b.id = s.business_id
+    WHERE s.scored_at > NOW() - INTERVAL '5 minutes'
+    ORDER BY s.total DESC, b.name LIMIT 10
+  `);
+  console.log('');
+  console.log('[score] top 10 by score:');
+  for (const r of top.rows) {
+    console.log(`        ${String(r.total).padStart(3)}  ${r.tier}  ${r.name.padEnd(34)}  ${r.category || ''}`);
+  }
+
+  await pool.end();
+}
+
+main().catch((e) => {
+  console.error('[score]', e);
+  process.exit(1);
+});
diff --git a/src/server/index.ts b/src/server/index.ts
new file mode 100644
index 0000000..4bad1fe
--- /dev/null
+++ b/src/server/index.ts
@@ -0,0 +1,172 @@
+/**
+ * Ventura Corridor — read API + Leaflet mind-map viewer.
+ * Local-only. Loopback bind. Default :9780.
+ */
+import 'dotenv/config';
+import express from 'express';
+import helmet from 'helmet';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { pool, query } from '../../db/pool.ts';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const PORT = parseInt(process.env.PORT || '9780', 10);
+
+const app = express();
+// Security headers via helmet (added 2026-05-04 overnight YOLO loop)
+app.use(helmet({ contentSecurityPolicy: false }));
+app.use(express.json());
+
+// ─── Health ─────────────────────────────────────────────────────────────
+app.get('/api/health', (_req, res) => res.json({ ok: true, port: PORT, ts: new Date().toISOString() }));
+
+// ─── Stats — high-level counts for the landing ─────────────────────────
+app.get('/api/stats', async (_req, res) => {
+  try {
+    const r = await query<{ key: string; value: string }>(`
+      SELECT 'businesses_total' AS key, COUNT(*)::text AS value FROM businesses
+      UNION ALL SELECT 'businesses_corridor', COUNT(*)::text FROM businesses WHERE on_corridor
+      UNION ALL SELECT 'businesses_with_site', COUNT(*)::text FROM businesses WHERE on_corridor AND website IS NOT NULL
+      UNION ALL SELECT 'audits_total', COUNT(*)::text FROM front_page_audits
+      UNION ALL SELECT 'scored_total', COUNT(DISTINCT business_id)::text FROM seo_scores
+      UNION ALL SELECT 'segments_total', COUNT(*)::text FROM corridor_segments
+    `);
+    const out: Record<string, number> = {};
+    for (const row of r.rows) out[row.key] = parseInt(row.value, 10);
+    res.json(out);
+  } catch (e: any) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
+// ─── Businesses listing — for the map ──────────────────────────────────
+app.get('/api/businesses', async (req, res) => {
+  try {
+    const limit = Math.min(parseInt(String(req.query.limit ?? '5000'), 10), 10000);
+    const r = await query(`
+      SELECT b.id, b.name, b.category, b.address, b.city, b.zip,
+             b.lat, b.lng, b.phone, b.website, b.on_corridor, b.corridor_block,
+             s.total AS seo_score, s.tier AS seo_tier, s.category_rank, s.category_n,
+             e.ownership_class, e.parent_brand, e.is_franchise, e.is_chain,
+             a.screenshot_path
+      FROM businesses b
+      LEFT JOIN LATERAL (
+        SELECT total, tier, category_rank, category_n
+        FROM seo_scores
+        WHERE business_id = b.id
+        ORDER BY scored_at DESC
+        LIMIT 1
+      ) s ON TRUE
+      LEFT JOIN business_enrichment e ON e.business_id = b.id
+      LEFT JOIN LATERAL (
+        SELECT screenshot_path FROM front_page_audits
+        WHERE business_id = b.id AND screenshot_path IS NOT NULL AND error_message IS NULL
+        ORDER BY audited_at DESC LIMIT 1
+      ) a ON TRUE
+      WHERE b.on_corridor AND b.lat IS NOT NULL
+      ORDER BY b.corridor_block ASC NULLS LAST, b.name ASC
+      LIMIT $1
+    `, [limit]);
+    res.json({ count: r.rowCount, rows: r.rows });
+  } catch (e: any) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
+// ─── Build status — live progress strip in viewer ─────────────────────
+app.get('/api/build-status', async (_req, res) => {
+  try {
+    const r = await query<{ key: string; value: string }>(`
+      SELECT 'businesses' AS key, COUNT(*)::text AS value FROM businesses WHERE on_corridor
+      UNION ALL SELECT 'with_site', COUNT(*)::text FROM businesses WHERE on_corridor AND website IS NOT NULL
+      UNION ALL SELECT 'audited',  COUNT(DISTINCT business_id)::text FROM front_page_audits a JOIN businesses b ON b.id=a.business_id WHERE a.error_message IS NULL AND b.on_corridor
+      UNION ALL SELECT 'scored',   COUNT(DISTINCT business_id)::text FROM seo_scores s JOIN businesses b ON b.id=s.business_id WHERE b.on_corridor
+      UNION ALL SELECT 'enriched', COUNT(*)::text FROM business_enrichment e JOIN businesses b ON b.id=e.business_id WHERE b.on_corridor
+      UNION ALL SELECT 'with_owner_contact', COUNT(DISTINCT business_id)::text FROM business_contacts WHERE person_name IS NOT NULL
+      UNION ALL SELECT 'tier_a', COUNT(*)::text FROM seo_scores WHERE tier='A'
+      UNION ALL SELECT 'tier_b', COUNT(*)::text FROM seo_scores WHERE tier='B'
+      UNION ALL SELECT 'tier_c', COUNT(*)::text FROM seo_scores WHERE tier='C'
+      UNION ALL SELECT 'tier_d', COUNT(*)::text FROM seo_scores WHERE tier='D'
+      UNION ALL SELECT 'class_independent', COUNT(*)::text FROM business_enrichment WHERE ownership_class='independent'
+      UNION ALL SELECT 'class_corporate', COUNT(*)::text FROM business_enrichment WHERE ownership_class='corporate'
+      UNION ALL SELECT 'class_franchise', COUNT(*)::text FROM business_enrichment WHERE ownership_class='franchise'
+    `);
+    const out: Record<string, number> = {};
+    for (const row of r.rows) out[row.key] = parseInt(row.value, 10);
+    // Last 5 ingest_runs for the activity tail
+    const runs = await query(`
+      SELECT id, source, started_at, finished_at, rows_in, rows_new, error_message, notes
+      FROM ingest_runs ORDER BY id DESC LIMIT 5
+    `);
+    res.json({ counts: out, recent_runs: runs.rows, ts: new Date().toISOString() });
+  } catch (e: any) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
+// ─── Corridor segments — for the polyline overlay ─────────────────────
+app.get('/api/segments', async (_req, res) => {
+  try {
+    const r = await query(`
+      SELECT ord, city, west_lat, west_lng, east_lat, east_lng
+      FROM corridor_segments ORDER BY ord ASC
+    `);
+    res.json({ count: r.rowCount, rows: r.rows });
+  } catch (e: any) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
+// ─── Single business detail ────────────────────────────────────────────
+app.get('/api/businesses/:id', async (req, res) => {
+  try {
+    const id = parseInt(req.params.id, 10);
+    const b = await query(`SELECT * FROM businesses WHERE id = $1`, [id]);
+    if (!b.rowCount) return res.status(404).json({ error: 'not found' });
+    const a = await query(
+      `SELECT * FROM front_page_audits WHERE business_id = $1 ORDER BY audited_at DESC LIMIT 1`,
+      [id],
+    );
+    const s = await query(
+      `SELECT * FROM seo_scores WHERE business_id = $1 ORDER BY scored_at DESC LIMIT 1`,
+      [id],
+    );
+    const e = await query(
+      `SELECT * FROM business_enrichment WHERE business_id = $1`, [id],
+    );
+    const c = await query(
+      `SELECT id, role, person_name, person_title, person_linkedin, person_email, person_phone,
+              source, source_url, confidence, last_verified_at, notes
+       FROM business_contacts WHERE business_id = $1
+       ORDER BY confidence DESC NULLS LAST, role ASC`, [id],
+    );
+    res.json({
+      business: b.rows[0],
+      latest_audit: a.rows[0] || null,
+      latest_score: s.rows[0] || null,
+      enrichment: e.rows[0] || null,
+      contacts: c.rows,
+    });
+  } catch (e: any) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
+// ─── Polyline (Ventura Blvd centerline) — for the 3D viewer ───────────
+app.get('/api/polyline', (_req, res) => {
+  res.sendFile(path.resolve(__dirname, '../../data/ventura-blvd-polyline.json'));
+});
+
+// ─── Screenshots — captured front-page JPEGs from the crawler ────────
+app.use('/screenshots', express.static(path.resolve(__dirname, '../../data/screenshots'), {
+  maxAge: '1h',
+  immutable: false,
+}));
+
+// ─── Static viewer ─────────────────────────────────────────────────────
+app.use('/', express.static(path.resolve(__dirname, '../../public')));
+
+// 127.0.0.1 only — directory data stays local per the lawyer/doctor precedent.
+app.listen(PORT, '127.0.0.1', () => {
+  console.log(`[ventura-corridor] listening on http://127.0.0.1:${PORT}`);
+});
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..bc416f5
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,14 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "ESNext",
+    "moduleResolution": "Bundler",
+    "allowImportingTsExtensions": true,
+    "noEmit": true,
+    "strict": true,
+    "esModuleInterop": true,
+    "skipLibCheck": true,
+    "resolveJsonModule": true
+  },
+  "include": ["src/**/*", "db/**/*"]
+}

(oldest)  ·  back to Ventura Corridor  ·  Ship v1 public · UI-agent fixes + screenshot billboards + wa b340c7a →