← 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
A .env.bakA .env.exampleA .gitignoreA README.mdA data/3d-cam-fix.pngA data/3d-snapshot.pngA data/3d-with-screenshots.pngA data/after-review-2d.pngA data/after-review-3d-post-overlay.pngA data/after-review-3d.pngA data/after-review-wall.pngA data/ventura-blvd-polyline.jsonA data/wall-snapshot.pngA db/migrate.tsA db/migrations/001_init.sqlA db/migrations/002_enrichment.sqlA db/pool.tsA db/seed_segments.sqlA ecosystem.config.cjsA package-lock.jsonA package.jsonA public/3d.htmlA public/index.htmlA public/wall.htmlA scripts/foursquare_signup.cjsA src/crawl/front_page.tsA src/enrich/classify_ownership.tsA src/enrich/restaurant_owners.tsA src/ingest/foursquare.tsA src/ingest/here_discover.tsA src/ingest/osm_overpass.tsA src/score/seo_score.tsA src/server/index.tsA tsconfig.json
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 · <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: '© 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/'/g, "'")
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/ /g, ' ')
+ .replace(/'/g, "'")
+ .replace(///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 →