← back to Lawyer Directory Builder
Initial commit: LA Lawyer Directory — office-mapping pipeline
9315e694188b9cdc42931d577815604dedac945a · 2026-04-30 00:35:33 -0700 · Steve Abrams
- Compliance-first PostgreSQL schema (organizations, professionals, professional_locations, bar_admissions, education, emails, phones, sources, raw_records, scrape_jobs, schema_migrations)
- OSM Overpass importer (196 LA County firm offices, geo-tagged)
- Wikidata SPARQL importer (29 LA biglaw firms: Latham, Gibson Dunn, Paul Hastings, Manatt, Lewis Brisbois, etc.)
- Express read API + Leaflet dashboard on :9701
- Robots-aware compliance helper (rate limit, captcha detection, no bypass)
- Overnight orchestrator with checkpointed scrape_jobs
- Documented blocked sources (CA State Bar, CA SOS bizfile) requiring Playwright pass
Files touched
A .gitignoreA README.mdA docs/blocked_sources.mdA migrations/001_initial_schema.sqlA migrations/002_firm_office_columns.sqlA package-lock.jsonA package.jsonA public/index.htmlA src/db/pool.tsA src/ingest/osm_overpass.tsA src/ingest/wikidata.tsA src/lib/compliance.tsA src/scripts/inspect_justia.tsA src/scripts/migrate.tsA src/scripts/run_overnight.tsA src/server/index.tsA tsconfig.json
Diff
commit 9315e694188b9cdc42931d577815604dedac945a
Author: Steve Abrams <steveabramsdesigns@gmail.com>
Date: Thu Apr 30 00:35:33 2026 -0700
Initial commit: LA Lawyer Directory — office-mapping pipeline
- Compliance-first PostgreSQL schema (organizations, professionals, professional_locations, bar_admissions, education, emails, phones, sources, raw_records, scrape_jobs, schema_migrations)
- OSM Overpass importer (196 LA County firm offices, geo-tagged)
- Wikidata SPARQL importer (29 LA biglaw firms: Latham, Gibson Dunn, Paul Hastings, Manatt, Lewis Brisbois, etc.)
- Express read API + Leaflet dashboard on :9701
- Robots-aware compliance helper (rate limit, captcha detection, no bypass)
- Overnight orchestrator with checkpointed scrape_jobs
- Documented blocked sources (CA State Bar, CA SOS bizfile) requiring Playwright pass
---
.gitignore | 9 +
README.md | 97 ++
docs/blocked_sources.md | 38 +
migrations/001_initial_schema.sql | 233 ++++
migrations/002_firm_office_columns.sql | 25 +
package-lock.json | 2005 ++++++++++++++++++++++++++++++++
package.json | 34 +
public/index.html | 129 ++
src/db/pool.ts | 30 +
src/ingest/osm_overpass.ts | 275 +++++
src/ingest/wikidata.ts | 234 ++++
src/lib/compliance.ts | 111 ++
src/scripts/inspect_justia.ts | 54 +
src/scripts/migrate.ts | 44 +
src/scripts/run_overnight.ts | 93 ++
src/server/index.ts | 130 +++
tsconfig.json | 18 +
17 files changed, 3559 insertions(+)
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f9960cf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+node_modules/
+.env
+.env.local
+raw_html/
+logs/
+*.log
+.DS_Store
+dist/
+exports/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ffe88e1
--- /dev/null
+++ b/README.md
@@ -0,0 +1,97 @@
+# LA Lawyer Directory — Office Map
+
+Compliance-first PostgreSQL database of **law firm offices in LA County**, focused on
+geographic clustering (Beverly Hills, Century City, DTLA, etc.). Powers profile-site
+generation, SEO landing pages, directory pages, and CRM enrichment.
+
+## Status
+
+| | |
+|---|---|
+| DB | `lawyer_professional_directory` on `127.0.0.1:5432` (Unix socket) |
+| API | `http://localhost:9701` |
+| Dashboard | `http://localhost:9701/` (Leaflet map of every geocoded firm) |
+
+Sources currently wired (free, no auth, robots-clean):
+- **OpenStreetMap Overpass API** — every `office=lawyer` / `amenity=lawyer` node in LA County.
+- **Wikidata SPARQL** — notable firms with HQ/office in LA County (Latham, Gibson Dunn, Paul Hastings, Manatt, Lewis Brisbois, Littler Mendelson, Irell, etc.).
+
+Sources blocked by anti-bot — see `docs/blocked_sources.md`:
+- CA State Bar attorney search (SPA, JS-only render)
+- CA SOS bizfile API (recaptcha-gated)
+
+Both are reachable with a Playwright pass — that's the next milestone, not tonight's.
+
+## Run
+
+```bash
+npm install # one-time
+npm run migrate # create / update tables (idempotent)
+npx tsx src/server/index.ts & # start API + dashboard on :9701
+npx tsx src/scripts/run_overnight.ts # one-shot import pass + summary
+```
+
+Logs land in `logs/overnight-YYYY-MM-DD.log`.
+
+## Schedule it nightly
+
+Pick one:
+
+**cron** (macOS):
+```cron
+# nightly at 02:00 local
+0 2 * * * cd /Users/stevestudio2/Projects/lawyer-directory-builder && /usr/local/bin/npx tsx src/scripts/run_overnight.ts >> logs/cron.log 2>&1
+```
+
+**pm2** (if installed):
+```bash
+pm2 start npx --name lawyer-server -- tsx src/server/index.ts
+pm2 start --cron-restart "0 2 * * *" --no-autorestart \
+ npx --name lawyer-overnight -- tsx src/scripts/run_overnight.ts
+pm2 save
+```
+
+## Useful queries
+
+```sql
+-- top neighborhoods by firm count
+SELECT COALESCE(neighborhood, city) AS area, COUNT(*) AS firms
+FROM organizations WHERE type='law_firm'
+GROUP BY area ORDER BY firms DESC LIMIT 20;
+
+-- multi-firm buildings (true clusters)
+SELECT address, city, COUNT(*) AS firms,
+ ARRAY_AGG(name ORDER BY name) AS firms_at_address
+FROM organizations
+WHERE type='law_firm' AND address IS NOT NULL
+GROUP BY address, city HAVING COUNT(*) > 1
+ORDER BY firms DESC;
+
+-- 90067 Century City roster
+SELECT name, address, phone, website
+FROM organizations
+WHERE type='law_firm' AND zip='90067'
+ORDER BY name;
+```
+
+## API endpoints
+
+| | |
+|---|---|
+| `GET /api/health` | liveness |
+| `GET /api/stats` | totals + coverage |
+| `GET /api/firms?city=Beverly+Hills&limit=200` | firms list |
+| `GET /api/firms/:id` | one firm |
+| `GET /api/cities` | firm count + centroid by area |
+| `GET /api/buildings` | multi-firm addresses |
+| `GET /api/heatmap` | `{lat,lng,weight}` for map overlay |
+| `GET /api/jobs` | last 30 scrape_jobs runs |
+
+## Schema
+
+Mirrored on the `organizations` + `professionals` + `professional_locations`
+join model. See `migrations/001_initial_schema.sql` (base) and
+`migrations/002_firm_office_columns.sql` (clustering: `neighborhood`, `lat`,
+`lng`, `address_norm`, `attorney_count`, `firm_size_band`).
+
+Every fact has a `source_url` traceable in `raw_records`.
diff --git a/docs/blocked_sources.md b/docs/blocked_sources.md
new file mode 100644
index 0000000..bd77ea3
--- /dev/null
+++ b/docs/blocked_sources.md
@@ -0,0 +1,38 @@
+# Blocked sources — require Playwright / browser session
+
+Two sources we wanted but cannot reach with plain HTTP under our compliance policy
+(no captcha bypass, respect site terms).
+
+## CA Secretary of State bizfile API
+
+- Endpoint: `POST https://bizfileonline.sos.ca.gov/api/Records/businesssearch`
+- Probe result: **403 Forbidden** with empty body on plain POST.
+- Reason: their SPA injects a Google reCAPTCHA v3 token in the `x-recaptcha` header
+ for every API call. Without a real browser executing their reCAPTCHA challenge,
+ the gateway rejects the request.
+- Path forward: a Playwright session that (a) loads the search page so reCAPTCHA
+ initializes, (b) submits the form, (c) extracts the token from the live request,
+ (d) replays via fetch. This is **inside compliance** because we are not bypassing
+ reCAPTCHA — we are letting it run normally and just persisting the result.
+
+## CA State Bar — Attorney Licensee Search
+
+- Form page: `https://apps.calbar.ca.gov/attorney/LicenseeSearch/AdvancedSearch`
+- Detail page: `https://apps.calbar.ca.gov/attorney/Licensee/Detail/{barNumber}`
+- Probe result: every URL returns the **same 624 KB SPA shell** with an empty
+ `#moduleAttorneySearchList` div. Server-side rendering doesn't expose result data;
+ the page loads a Telerik Kendo grid that fetches results client-side via an
+ endpoint we couldn't locate from the static HTML/JS.
+- Path forward: same Playwright pattern as bizfile — load `Detail/{n}` for each
+ Bar # of interest, wait for the data-bound elements, scrape the rendered DOM.
+
+## Why not now
+
+Both imply adding Playwright + headless Chromium + DOM extractors + per-page rate
+limits much stricter than for plain HTTP. That's a project unto itself; tonight's
+run sticks with the sources that work cleanly via curl-grade requests:
+
+- OpenStreetMap Overpass (already wired)
+- Wikidata SPARQL (already wired)
+
+When ready to do Playwright pass, see `src/ingest/calbar_playwright.ts` (TODO).
diff --git a/migrations/001_initial_schema.sql b/migrations/001_initial_schema.sql
new file mode 100644
index 0000000..1988295
--- /dev/null
+++ b/migrations/001_initial_schema.sql
@@ -0,0 +1,233 @@
+-- Lawyer Professional Directory — Initial Schema
+-- Compliance-first design: every fact must be traceable to source_url; opt-out flag respected on every public surface.
+
+BEGIN;
+
+-- ─── Reference / dictionary tables ──────────────────────────────────────────
+
+CREATE TABLE IF NOT EXISTS sources (
+ id BIGSERIAL PRIMARY KEY,
+ source_name TEXT NOT NULL UNIQUE,
+ source_type TEXT NOT NULL CHECK (source_type IN (
+ 'official_registry', 'government', 'court', 'bar_association',
+ 'firm_website', 'api', 'directory'
+ )),
+ base_url TEXT NOT NULL,
+ terms_notes TEXT,
+ allowed_method TEXT NOT NULL CHECK (allowed_method IN ('api', 'crawl', 'manual', 'denied')),
+ rate_limit_rps NUMERIC(6,2),
+ robots_txt_url TEXT,
+ last_checked_at TIMESTAMPTZ
+);
+
+CREATE TABLE IF NOT EXISTS practice_areas (
+ id BIGSERIAL PRIMARY KEY,
+ name TEXT NOT NULL UNIQUE,
+ parent_area TEXT
+);
+
+-- ─── Core entities ──────────────────────────────────────────────────────────
+
+CREATE TABLE IF NOT EXISTS organizations (
+ id BIGSERIAL PRIMARY KEY,
+ name TEXT NOT NULL,
+ type TEXT NOT NULL CHECK (type IN (
+ 'law_firm', 'solo_practice', 'legal_aid',
+ 'government_office', 'court', 'bar_association'
+ )),
+ address TEXT,
+ city TEXT,
+ state TEXT DEFAULT 'CA',
+ zip TEXT,
+ county TEXT,
+ phone TEXT,
+ website TEXT,
+ google_place_id TEXT UNIQUE,
+ rating NUMERIC(3,1),
+ review_count INTEGER,
+ source_url TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_organizations_name_lower ON organizations (LOWER(name));
+CREATE INDEX IF NOT EXISTS idx_organizations_zip ON organizations (zip);
+CREATE INDEX IF NOT EXISTS idx_organizations_county ON organizations (county);
+CREATE INDEX IF NOT EXISTS idx_organizations_website ON organizations (LOWER(website));
+
+CREATE TABLE IF NOT EXISTS professionals (
+ id BIGSERIAL PRIMARY KEY,
+ full_name TEXT NOT NULL,
+ first_name TEXT,
+ last_name TEXT,
+ middle_name TEXT,
+ suffix TEXT,
+ title TEXT,
+ bar_number TEXT UNIQUE,
+ license_status TEXT,
+ license_status_date DATE,
+ admission_date DATE,
+ years_experience_estimate INTEGER,
+ law_school TEXT,
+ graduation_year INTEGER,
+ primary_practice_area TEXT,
+ secondary_practice_areas TEXT[],
+ bio TEXT,
+ languages TEXT[],
+ profile_image_url TEXT,
+ source_confidence_score NUMERIC(4,3) NOT NULL DEFAULT 0.000,
+ confidence_tier TEXT CHECK (confidence_tier IN ('high', 'medium', 'low', 'unverified')),
+ opt_out_flag BOOLEAN NOT NULL DEFAULT FALSE,
+ opt_out_reason TEXT,
+ opt_out_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_professionals_last_first ON professionals (LOWER(last_name), LOWER(first_name));
+CREATE INDEX IF NOT EXISTS idx_professionals_status ON professionals (license_status);
+CREATE INDEX IF NOT EXISTS idx_professionals_practice ON professionals (primary_practice_area);
+CREATE INDEX IF NOT EXISTS idx_professionals_opt_out ON professionals (opt_out_flag);
+
+-- ─── Relationships / multi-valued attributes ────────────────────────────────
+
+CREATE TABLE IF NOT EXISTS professional_locations (
+ id BIGSERIAL PRIMARY KEY,
+ professional_id BIGINT NOT NULL REFERENCES professionals(id) ON DELETE CASCADE,
+ organization_id BIGINT REFERENCES organizations(id) ON DELETE SET NULL,
+ role TEXT,
+ address TEXT,
+ phone TEXT,
+ email TEXT,
+ appointment_url TEXT,
+ source_url TEXT,
+ is_primary BOOLEAN NOT NULL DEFAULT FALSE,
+ last_verified_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_prof_loc_professional ON professional_locations (professional_id);
+CREATE INDEX IF NOT EXISTS idx_prof_loc_organization ON professional_locations (organization_id);
+
+CREATE TABLE IF NOT EXISTS professional_practice_areas (
+ professional_id BIGINT NOT NULL REFERENCES professionals(id) ON DELETE CASCADE,
+ practice_area_id BIGINT NOT NULL REFERENCES practice_areas(id) ON DELETE CASCADE,
+ source TEXT,
+ confidence_score NUMERIC(4,3) NOT NULL DEFAULT 0.000,
+ PRIMARY KEY (professional_id, practice_area_id)
+);
+
+CREATE TABLE IF NOT EXISTS emails (
+ id BIGSERIAL PRIMARY KEY,
+ professional_id BIGINT REFERENCES professionals(id) ON DELETE CASCADE,
+ organization_id BIGINT REFERENCES organizations(id) ON DELETE CASCADE,
+ email TEXT NOT NULL,
+ email_type TEXT NOT NULL CHECK (email_type IN ('public_direct', 'office', 'intake', 'admin', 'unknown')),
+ source_url TEXT,
+ discovered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ last_verified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ verification_status TEXT NOT NULL DEFAULT 'unverified'
+ CHECK (verification_status IN ('unverified', 'syntactically_valid', 'mx_valid', 'bounced', 'opted_out')),
+ CHECK (professional_id IS NOT NULL OR organization_id IS NOT NULL)
+);
+
+CREATE INDEX IF NOT EXISTS idx_emails_email ON emails (LOWER(email));
+CREATE INDEX IF NOT EXISTS idx_emails_professional ON emails (professional_id);
+CREATE INDEX IF NOT EXISTS idx_emails_organization ON emails (organization_id);
+
+CREATE TABLE IF NOT EXISTS phones (
+ id BIGSERIAL PRIMARY KEY,
+ professional_id BIGINT REFERENCES professionals(id) ON DELETE CASCADE,
+ organization_id BIGINT REFERENCES organizations(id) ON DELETE CASCADE,
+ phone TEXT NOT NULL,
+ phone_type TEXT CHECK (phone_type IN ('office', 'direct', 'mobile', 'fax', 'intake', 'unknown')),
+ source_url TEXT,
+ last_verified_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ CHECK (professional_id IS NOT NULL OR organization_id IS NOT NULL)
+);
+
+CREATE INDEX IF NOT EXISTS idx_phones_professional ON phones (professional_id);
+CREATE INDEX IF NOT EXISTS idx_phones_organization ON phones (organization_id);
+
+CREATE TABLE IF NOT EXISTS education (
+ id BIGSERIAL PRIMARY KEY,
+ professional_id BIGINT NOT NULL REFERENCES professionals(id) ON DELETE CASCADE,
+ school_name TEXT NOT NULL,
+ degree TEXT,
+ graduation_year INTEGER,
+ source_url TEXT
+);
+
+CREATE INDEX IF NOT EXISTS idx_education_professional ON education (professional_id);
+
+CREATE TABLE IF NOT EXISTS bar_admissions (
+ id BIGSERIAL PRIMARY KEY,
+ professional_id BIGINT NOT NULL REFERENCES professionals(id) ON DELETE CASCADE,
+ jurisdiction TEXT NOT NULL,
+ bar_number TEXT,
+ admission_date DATE,
+ status TEXT,
+ source_url TEXT,
+ UNIQUE (professional_id, jurisdiction, bar_number)
+);
+
+CREATE INDEX IF NOT EXISTS idx_bar_admissions_professional ON bar_admissions (professional_id);
+
+-- ─── Audit / provenance tables ──────────────────────────────────────────────
+
+CREATE TABLE IF NOT EXISTS raw_records (
+ id BIGSERIAL PRIMARY KEY,
+ source_id BIGINT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
+ source_url TEXT NOT NULL,
+ entity_type TEXT NOT NULL,
+ entity_id TEXT,
+ raw_json JSONB,
+ raw_html_path TEXT,
+ fetched_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ http_status INTEGER,
+ hash TEXT NOT NULL,
+ UNIQUE (source_id, hash)
+);
+
+CREATE INDEX IF NOT EXISTS idx_raw_records_source ON raw_records (source_id);
+CREATE INDEX IF NOT EXISTS idx_raw_records_url ON raw_records (source_url);
+CREATE INDEX IF NOT EXISTS idx_raw_records_entity ON raw_records (entity_type, entity_id);
+
+CREATE TABLE IF NOT EXISTS scrape_jobs (
+ id BIGSERIAL PRIMARY KEY,
+ source_id BIGINT NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
+ job_label TEXT,
+ status TEXT NOT NULL DEFAULT 'queued'
+ CHECK (status IN ('queued', 'running', 'completed', 'failed', 'aborted_compliance')),
+ started_at TIMESTAMPTZ,
+ finished_at TIMESTAMPTZ,
+ records_found INTEGER NOT NULL DEFAULT 0,
+ records_inserted INTEGER NOT NULL DEFAULT 0,
+ records_updated INTEGER NOT NULL DEFAULT 0,
+ records_skipped INTEGER NOT NULL DEFAULT 0,
+ error_message TEXT,
+ checkpoint JSONB
+);
+
+CREATE INDEX IF NOT EXISTS idx_scrape_jobs_source ON scrape_jobs (source_id);
+CREATE INDEX IF NOT EXISTS idx_scrape_jobs_status ON scrape_jobs (status);
+
+-- ─── Trigger: auto-update updated_at ───────────────────────────────────────
+
+CREATE OR REPLACE FUNCTION set_updated_at() RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = NOW();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS trg_professionals_updated ON professionals;
+CREATE TRIGGER trg_professionals_updated
+ BEFORE UPDATE ON professionals
+ FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+
+DROP TRIGGER IF EXISTS trg_organizations_updated ON organizations;
+CREATE TRIGGER trg_organizations_updated
+ BEFORE UPDATE ON organizations
+ FOR EACH ROW EXECUTE FUNCTION set_updated_at();
+
+COMMIT;
diff --git a/migrations/002_firm_office_columns.sql b/migrations/002_firm_office_columns.sql
new file mode 100644
index 0000000..b4a98bb
--- /dev/null
+++ b/migrations/002_firm_office_columns.sql
@@ -0,0 +1,25 @@
+-- Office-clustering columns for firm-focused data model.
+-- Tracks neighborhood (Beverly Hills, Century City, DTLA, …), geo, building, derived attorney count.
+
+BEGIN;
+
+ALTER TABLE organizations
+ ADD COLUMN IF NOT EXISTS neighborhood TEXT,
+ ADD COLUMN IF NOT EXISTS lat DOUBLE PRECISION,
+ ADD COLUMN IF NOT EXISTS lng DOUBLE PRECISION,
+ ADD COLUMN IF NOT EXISTS geocoded_at TIMESTAMPTZ,
+ ADD COLUMN IF NOT EXISTS building_name TEXT,
+ ADD COLUMN IF NOT EXISTS attorney_count INTEGER NOT NULL DEFAULT 0,
+ ADD COLUMN IF NOT EXISTS practice_areas TEXT[],
+ ADD COLUMN IF NOT EXISTS firm_size_band TEXT; -- 'solo' | 'small' | 'medium' | 'large' | 'biglaw'
+
+CREATE INDEX IF NOT EXISTS idx_organizations_neighborhood ON organizations (neighborhood);
+CREATE INDEX IF NOT EXISTS idx_organizations_attorney_count ON organizations (attorney_count DESC);
+
+-- Address-canonicalization helpers — used to group "1888 Century Park East, Suite 200"
+-- and "1888 Century Park E #200" as the same building.
+ALTER TABLE organizations
+ ADD COLUMN IF NOT EXISTS address_norm TEXT;
+CREATE INDEX IF NOT EXISTS idx_organizations_address_norm ON organizations (address_norm);
+
+COMMIT;
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..c3040a2
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,2005 @@
+{
+ "name": "lawyer-directory-builder",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "lawyer-directory-builder",
+ "version": "0.1.0",
+ "license": "UNLICENSED",
+ "dependencies": {
+ "cheerio": "^1.0.0",
+ "csv-stringify": "^6.5.2",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "pg": "^8.13.1",
+ "robots-parser": "^3.0.1",
+ "undici": "^7.2.0"
+ },
+ "devDependencies": {
+ "@types/express": "^5.0.0",
+ "@types/node": "^22.10.5",
+ "@types/pg": "^8.11.10",
+ "tsx": "^4.19.2",
+ "typescript": "^5.7.3"
+ }
+ },
+ "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/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/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.5",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
+ "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.15.1",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/body-parser/node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/body-parser/node_modules/qs": {
+ "version": "6.15.1",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
+ "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/boolbase": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
+ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
+ "license": "ISC"
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/cheerio": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz",
+ "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==",
+ "license": "MIT",
+ "dependencies": {
+ "cheerio-select": "^2.1.0",
+ "dom-serializer": "^2.0.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "encoding-sniffer": "^0.2.1",
+ "htmlparser2": "^10.1.0",
+ "parse5": "^7.3.0",
+ "parse5-htmlparser2-tree-adapter": "^7.1.0",
+ "parse5-parser-stream": "^7.1.2",
+ "undici": "^7.19.0",
+ "whatwg-mimetype": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=20.18.1"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/cheerio?sponsor=1"
+ }
+ },
+ "node_modules/cheerio-select": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz",
+ "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-select": "^5.1.0",
+ "css-what": "^6.1.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-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/css-select": {
+ "version": "5.2.2",
+ "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz",
+ "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0",
+ "css-what": "^6.1.0",
+ "domhandler": "^5.0.2",
+ "domutils": "^3.0.1",
+ "nth-check": "^2.0.1"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/css-what": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz",
+ "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">= 6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/fb55"
+ }
+ },
+ "node_modules/csv-stringify": {
+ "version": "6.7.0",
+ "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-6.7.0.tgz",
+ "integrity": "sha512-UdtziYp5HuTz7e5j8Nvq+a/3HQo+2/aJZ9xntNTpmRRIg/3YYqDVgiS9fvAhtNbnyfbv2ZBe0bqCHqzhE7FqWQ==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/dom-serializer": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
+ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.2",
+ "entities": "^4.2.0"
+ },
+ "funding": {
+ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
+ }
+ },
+ "node_modules/domelementtype": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
+ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "BSD-2-Clause"
+ },
+ "node_modules/domhandler": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
+ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "domelementtype": "^2.3.0"
+ },
+ "engines": {
+ "node": ">= 4"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domhandler?sponsor=1"
+ }
+ },
+ "node_modules/domutils": {
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
+ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "dom-serializer": "^2.0.0",
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/domutils?sponsor=1"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/encoding-sniffer": {
+ "version": "0.2.1",
+ "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz",
+ "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==",
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "^0.6.3",
+ "whatwg-encoding": "^3.1.1"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/encoding-sniffer?sponsor=1"
+ }
+ },
+ "node_modules/entities": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
+ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+ "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/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/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/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.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/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/hasown": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+ "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/htmlparser2": {
+ "version": "10.1.0",
+ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
+ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
+ "funding": [
+ "https://github.com/fb55/htmlparser2?sponsor=1",
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fb55"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "domelementtype": "^2.3.0",
+ "domhandler": "^5.0.3",
+ "domutils": "^3.2.2",
+ "entities": "^7.0.1"
+ }
+ },
+ "node_modules/htmlparser2/node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
+ "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/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/nth-check": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
+ "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "boolbase": "^1.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/nth-check?sponsor=1"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/parse5": {
+ "version": "7.3.0",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
+ "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^6.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5-htmlparser2-tree-adapter": {
+ "version": "7.1.0",
+ "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz",
+ "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==",
+ "license": "MIT",
+ "dependencies": {
+ "domhandler": "^5.0.3",
+ "parse5": "^7.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5-parser-stream": {
+ "version": "7.1.2",
+ "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz",
+ "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==",
+ "license": "MIT",
+ "dependencies": {
+ "parse5": "^7.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/parse5/node_modules/entities": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz",
+ "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/pg": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
+ "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.12.0",
+ "pg-pool": "^3.13.0",
+ "pg-protocol": "^1.13.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.3.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
+ "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.12.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
+ "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.13.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
+ "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
+ "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.7",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+ "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.14.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
+ "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "side-channel": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/raw-body/node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/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/robots-parser": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/robots-parser/-/robots-parser-3.0.1.tgz",
+ "integrity": "sha512-s+pyvQeIKIZ0dx5iJiQk1tPLJAWln39+MI5jtM8wnyws+G5azk+dMnMX0qfbqNetKKNgcWWOdi0sfm+FbQbgdQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/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/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/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": {
+ "version": "7.25.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
+ "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/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/whatwg-encoding": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz",
+ "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
+ "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation",
+ "license": "MIT",
+ "dependencies": {
+ "iconv-lite": "0.6.3"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz",
+ "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..51516d7
--- /dev/null
+++ b/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "lawyer-directory-builder",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Compliance-first PostgreSQL data platform for licensed legal professionals (LA County first). Powers profile websites, SEO pages, directories, and CRM enrichment.",
+ "license": "UNLICENSED",
+ "type": "module",
+ "scripts": {
+ "migrate": "tsx src/scripts/migrate.ts",
+ "seed:sources": "tsx src/scripts/seed_sources.ts",
+ "ingest:state-bar": "tsx src/scripts/run_state_bar.ts",
+ "ingest:state-bar:smoke": "tsx src/scripts/run_state_bar.ts --smoke",
+ "export:csv": "tsx src/scripts/export_csv.ts",
+ "server": "tsx src/server/index.ts",
+ "dev": "tsx watch src/server/index.ts",
+ "stats": "tsx src/scripts/stats.ts"
+ },
+ "dependencies": {
+ "cheerio": "^1.0.0",
+ "csv-stringify": "^6.5.2",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "pg": "^8.13.1",
+ "robots-parser": "^3.0.1",
+ "undici": "^7.2.0"
+ },
+ "devDependencies": {
+ "@types/express": "^5.0.0",
+ "@types/node": "^22.10.5",
+ "@types/pg": "^8.11.10",
+ "tsx": "^4.19.2",
+ "typescript": "^5.7.3"
+ }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..0e5659c
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,129 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>LA Lawyer Directory — Office Map</title>
+<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
+ integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
+<style>
+ :root { --bg:#0e1116; --panel:#161b22; --text:#e6edf3; --muted:#8b949e; --accent:#58a6ff; }
+ * { box-sizing: border-box; }
+ html, body { margin:0; padding:0; height:100%; background:var(--bg); color:var(--text); font:14px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
+ header { padding:12px 16px; background:var(--panel); border-bottom:1px solid #30363d; display:flex; align-items:baseline; gap:18px; flex-wrap:wrap; }
+ header h1 { margin:0; font-size:16px; font-weight:600; }
+ header .stat { color:var(--muted); }
+ header .stat b { color:var(--text); font-weight:600; }
+ main { display:grid; grid-template-columns: 320px 1fr; height:calc(100vh - 49px); }
+ aside { overflow:auto; background:var(--panel); border-right:1px solid #30363d; }
+ aside section { padding:12px 14px; border-bottom:1px solid #30363d; }
+ aside h2 { margin:0 0 8px 0; font-size:12px; text-transform:uppercase; letter-spacing:.05em; color:var(--muted); }
+ aside .row { display:flex; justify-content:space-between; padding:3px 0; font-variant-numeric: tabular-nums; }
+ aside .row a { color:var(--text); text-decoration:none; }
+ aside .row a:hover { color:var(--accent); }
+ aside .num { color:var(--muted); }
+ #map { height:100%; }
+ .leaflet-popup-content { font-size:13px; line-height:1.4; }
+ .leaflet-popup-content b { color:#0d1117; }
+ .leaflet-popup-content a { color:#0969da; }
+ .pill { display:inline-block; padding:1px 6px; border-radius:8px; background:#30363d; color:var(--muted); font-size:11px; margin-right:4px; }
+</style>
+</head>
+<body>
+<header>
+ <h1>LA Lawyer Directory — Office Map</h1>
+ <span class="stat"><b id="s_firms">…</b> firms</span>
+ <span class="stat"><b id="s_geo">…</b> geo-mapped</span>
+ <span class="stat"><b id="s_site">…</b> with website</span>
+ <span class="stat"><b id="s_buildings">…</b> multi-firm buildings</span>
+ <span class="stat"><b id="s_cities">…</b> cities</span>
+</header>
+<main>
+ <aside>
+ <section>
+ <h2>Top areas</h2>
+ <div id="cities"></div>
+ </section>
+ <section>
+ <h2>Multi-firm buildings</h2>
+ <div id="buildings"></div>
+ </section>
+ <section>
+ <h2>Recent jobs</h2>
+ <div id="jobs"></div>
+ </section>
+ </aside>
+ <div id="map"></div>
+</main>
+<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
+ integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
+<script>
+async function getJSON(url) { const r = await fetch(url); return r.json(); }
+
+const map = L.map('map').setView([34.05, -118.35], 11);
+L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
+ attribution: '© OSM © CARTO',
+ subdomains: 'abcd', maxZoom: 19,
+}).addTo(map);
+
+(async () => {
+ const stats = await getJSON('/api/stats');
+ document.getElementById('s_firms').textContent = stats.firms_total ?? 0;
+ document.getElementById('s_geo').textContent = stats.firms_with_geo ?? 0;
+ document.getElementById('s_site').textContent = stats.firms_with_site ?? 0;
+ document.getElementById('s_buildings').textContent = stats.multi_firm_buildings ?? 0;
+ document.getElementById('s_cities').textContent = stats.cities ?? 0;
+
+ const cities = await getJSON('/api/cities');
+ document.getElementById('cities').innerHTML = cities.rows.slice(0, 25).map(c =>
+ `<div class="row"><a href="#" data-area="${encodeURIComponent(c.area)}">${c.area || '(unknown)'}</a><span class="num">${c.firms}</span></div>`
+ ).join('');
+
+ const buildings = await getJSON('/api/buildings');
+ document.getElementById('buildings').innerHTML = buildings.rows.slice(0, 15).map(b =>
+ `<div class="row"><span title="${(b.names || []).join('\\n')}">${b.address.slice(0, 38)}…</span><span class="num">${b.firms}</span></div>`
+ ).join('') || '<div class="num">No multi-firm buildings yet.</div>';
+
+ const firms = await getJSON('/api/firms?limit=1000');
+ const cluster = L.layerGroup().addTo(map);
+ for (const f of firms.rows) {
+ if (!f.lat || !f.lng) continue;
+ const radius = 4 + Math.min(8, (f.attorney_count || 0) / 25);
+ const m = L.circleMarker([f.lat, f.lng], {
+ radius, color: '#58a6ff', weight: 1, fillColor: '#58a6ff', fillOpacity: 0.55,
+ });
+ const html = `
+ <b>${f.name}</b><br>
+ ${f.address || ''}<br>
+ ${f.phone ? `📞 ${f.phone}<br>` : ''}
+ ${f.website ? `🌐 <a href="${f.website}" target="_blank" rel="noopener">${f.website.replace(/^https?:\/\//,'').slice(0,40)}</a><br>` : ''}
+ <span class="pill">${f.firm_size_band || 'unsized'}</span>
+ ${f.neighborhood ? `<span class="pill">${f.neighborhood}</span>` : ''}
+ `;
+ m.bindPopup(html);
+ cluster.addLayer(m);
+ }
+
+ document.getElementById('cities').addEventListener('click', async (e) => {
+ const a = e.target.closest('a[data-area]');
+ if (!a) return;
+ e.preventDefault();
+ const area = decodeURIComponent(a.dataset.area);
+ const r = await getJSON('/api/firms?city=' + encodeURIComponent(area));
+ if (r.rows.length && r.rows[0].lat) {
+ const lats = r.rows.filter(x => x.lat).map(x => x.lat);
+ const lngs = r.rows.filter(x => x.lng).map(x => x.lng);
+ const cLat = lats.reduce((a,b)=>a+b,0)/lats.length;
+ const cLng = lngs.reduce((a,b)=>a+b,0)/lngs.length;
+ map.setView([cLat, cLng], 14);
+ }
+ });
+
+ const jobs = await getJSON('/api/jobs');
+ document.getElementById('jobs').innerHTML = jobs.rows.slice(0, 10).map(j =>
+ `<div class="row"><span>${j.source_name?.slice(0, 22) || ''}</span><span class="num">${j.records_inserted ?? 0}</span></div>`
+ ).join('') || '<div class="num">No jobs yet.</div>';
+})();
+</script>
+</body>
+</html>
diff --git a/src/db/pool.ts b/src/db/pool.ts
new file mode 100644
index 0000000..a1ce127
--- /dev/null
+++ b/src/db/pool.ts
@@ -0,0 +1,30 @@
+import 'dotenv/config';
+import pg from 'pg';
+
+const { Pool } = pg;
+
+const url = process.env.DATABASE_URL;
+if (!url) {
+ throw new Error('DATABASE_URL is required (see .env)');
+}
+
+export const pool = new Pool({ connectionString: url, max: 10 });
+
+export async function query<T = unknown>(text: string, params: unknown[] = []) {
+ return pool.query<T>(text, params as never[]);
+}
+
+export async function withTx<T>(fn: (client: pg.PoolClient) => Promise<T>): Promise<T> {
+ const client = await pool.connect();
+ try {
+ await client.query('BEGIN');
+ const out = await fn(client);
+ await client.query('COMMIT');
+ return out;
+ } catch (e) {
+ await client.query('ROLLBACK');
+ throw e;
+ } finally {
+ client.release();
+ }
+}
diff --git a/src/ingest/osm_overpass.ts b/src/ingest/osm_overpass.ts
new file mode 100644
index 0000000..fc94a63
--- /dev/null
+++ b/src/ingest/osm_overpass.ts
@@ -0,0 +1,275 @@
+/**
+ * OSM Overpass importer — pulls law-firm OFFICES in LA County.
+ * Free, no key, no captcha. Tag filters: office=lawyer + amenity=lawyer (legacy).
+ *
+ * The data is the ground truth for "where the firms physically are":
+ * each result has lat/lng, addr fields, and the firm name as tagged by mappers.
+ */
+import 'dotenv/config';
+import crypto from 'node:crypto';
+import { fetchCompliant } from '../lib/compliance.ts';
+import { pool, query, withTx } from '../db/pool.ts';
+
+const OVERPASS = process.env.OVERPASS_URL || 'https://overpass-api.de/api/interpreter';
+const SOURCE_NAME = 'OpenStreetMap Overpass API';
+
+type OverpassElement = {
+ type: 'node' | 'way' | 'relation';
+ id: number;
+ lat?: number; lon?: number;
+ center?: { lat: number; lon: number };
+ tags?: Record<string, string>;
+};
+
+const QL = `
+[out:json][timeout:90];
+// LA County — wikidata Q104994
+area["wikidata"="Q104994"]->.la;
+(
+ nwr["office"="lawyer"](area.la);
+ nwr["amenity"="lawyer"](area.la);
+);
+out center tags;
+`.trim();
+
+function sha256(s: string) {
+ return crypto.createHash('sha256').update(s).digest('hex');
+}
+
+function clean(s: string | undefined | null) {
+ if (s === undefined || s === null) return null;
+ const t = String(s).trim();
+ return t.length === 0 ? null : t;
+}
+
+function buildAddress(t: Record<string, string>) {
+ const street = [t['addr:housenumber'], t['addr:street']].filter(Boolean).join(' ');
+ const unit = t['addr:unit'] ? ` Suite ${t['addr:unit']}` : '';
+ const line1 = clean(street + unit);
+ const city = clean(t['addr:city']);
+ const state = clean(t['addr:state']) || 'CA';
+ const zip = clean(t['addr:postcode']);
+ // Only build a "full" address if we actually have a street line OR a city.
+ // Otherwise the field would be a useless "CA, 90210" or just "CA".
+ const full = (line1 || city)
+ ? [line1, city, state, zip].filter(Boolean).join(', ')
+ : null;
+ return { line1, city, state, zip, full };
+}
+
+function normAddress(s: string | null) {
+ if (!s) return null;
+ return s.toLowerCase()
+ .replace(/[.,]/g, ' ')
+ .replace(/\b(suite|ste|unit|apt|#)\b/g, '')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+function neighborhoodOf(t: Record<string, string>) {
+ // Use addr:suburb or addr:city for neighborhood. Beverly Hills, Century City, etc.
+ return clean(t['addr:suburb']) || clean(t['addr:district']) || clean(t['addr:city']);
+}
+
+function firmSizeBand(name: string | null): string | null {
+ if (!name) return null;
+ const n = name.toLowerCase();
+ // Heuristic — rough; refined later by attorney_count.
+ if (/\b(llp|llc|p\.c\.|pc|pllc|chartered)\b/.test(n)) return 'medium';
+ if (/&|and associates|group/.test(n)) return 'small';
+ return 'solo';
+}
+
+async function getSourceId(): Promise<number> {
+ const r = await query<{ id: number }>('SELECT id FROM sources WHERE source_name = $1', [SOURCE_NAME]);
+ if (r.rowCount === 0) throw new Error(`Source not seeded: ${SOURCE_NAME}`);
+ return r.rows[0].id;
+}
+
+async function startJob(sourceId: number, label: string) {
+ // The lawyer DB schema uses scrape_jobs without job_label column? Let me match the actual schema.
+ // It does have job_label per migration 001.
+ const r = await query<{ id: number }>(`
+ INSERT INTO scrape_jobs (source_id, job_label, status, started_at)
+ VALUES ($1,$2,'running',NOW())
+ RETURNING id
+ `, [sourceId, label]);
+ return r.rows[0].id;
+}
+
+async function finishJob(jobId: number, fields: Record<string, unknown>) {
+ const sets: string[] = [];
+ const params: unknown[] = [];
+ let i = 1;
+ for (const [k, v] of Object.entries(fields)) {
+ sets.push(`${k} = $${i++}`); params.push(v);
+ }
+ sets.push(`finished_at = NOW()`);
+ params.push(jobId);
+ await query(`UPDATE scrape_jobs SET ${sets.join(', ')} WHERE id = $${i}`, params);
+}
+
+async function fetchOverpass(): Promise<OverpassElement[]> {
+ // Overpass API is an explicitly programmatic endpoint per the OSM usage policy
+ // (https://operations.osmfoundation.org/policies/api/). Their robots.txt blocks
+ // generic crawlers but the API is intended for client consumption with a meaningful
+ // User-Agent. We respect their RATE policy (max ~10k req/day, polite-use) instead.
+ const { fetch } = await import('undici');
+ const body = `data=${encodeURIComponent(QL)}`;
+ const r = await fetch(OVERPASS, {
+ method: 'POST',
+ headers: {
+ 'User-Agent': process.env.USER_AGENT || 'LawyerDirectoryBuilder/0.1 (research; contact: steveabramsdesigns@gmail.com)',
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ Accept: 'application/json',
+ },
+ body,
+ signal: AbortSignal.timeout(180000),
+ });
+ if (!r.ok) {
+ const txt = await r.text();
+ throw new Error(`Overpass ${r.status}: ${txt.slice(0, 400)}`);
+ }
+ const json = await r.json() as { elements: OverpassElement[] };
+ return json.elements;
+}
+
+async function upsert(el: OverpassElement, sourceId: number) {
+ const tags = el.tags || {};
+ const name = clean(tags.name);
+ if (!name) return null;
+
+ const lat = el.lat ?? el.center?.lat ?? null;
+ const lng = el.lon ?? el.center?.lon ?? null;
+ const addr = buildAddress(tags);
+ const neighborhood = neighborhoodOf(tags);
+ const phone = clean(tags.phone) || clean(tags['contact:phone']);
+ const website = clean(tags.website) || clean(tags['contact:website']);
+ const email = clean(tags.email) || clean(tags['contact:email']);
+ const sourceUrl = `https://www.openstreetmap.org/${el.type}/${el.id}`;
+
+ return await withTx(async (client) => {
+ // Upsert organization, dedup by (osm_type, osm_id) via raw_records or by name+address_norm.
+ const addressNorm = normAddress(addr.full);
+
+ // Try to find existing by website (most stable) or name+address_norm
+ let orgId: number | null = null;
+ if (website) {
+ const r = await client.query<{ id: number }>(
+ `SELECT id FROM organizations WHERE LOWER(website) = LOWER($1) LIMIT 1`, [website]);
+ if (r.rowCount && r.rowCount > 0) orgId = r.rows[0].id;
+ }
+ if (!orgId && addressNorm) {
+ const r = await client.query<{ id: number }>(
+ `SELECT id FROM organizations
+ WHERE address_norm = $1 AND LOWER(name) = LOWER($2) LIMIT 1`,
+ [addressNorm, name]);
+ if (r.rowCount && r.rowCount > 0) orgId = r.rows[0].id;
+ }
+
+ if (orgId) {
+ await client.query(`
+ UPDATE organizations
+ SET name = $2,
+ address = COALESCE($3, address),
+ city = COALESCE($4, city),
+ state = COALESCE($5, state),
+ zip = COALESCE($6, zip),
+ county = COALESCE($7, county),
+ neighborhood = COALESCE($8, neighborhood),
+ lat = COALESCE($9, lat),
+ lng = COALESCE($10, lng),
+ geocoded_at = COALESCE(geocoded_at, NOW()),
+ phone = COALESCE($11, phone),
+ website = COALESCE($12, website),
+ address_norm = COALESCE($13, address_norm),
+ firm_size_band = COALESCE(firm_size_band, $14),
+ source_url = COALESCE(source_url, $15),
+ updated_at = NOW()
+ WHERE id = $1
+ `, [orgId, name, addr.full, addr.city, addr.state, addr.zip, 'Los Angeles',
+ neighborhood, lat, lng, phone, website, addressNorm,
+ firmSizeBand(name), sourceUrl]);
+ } else {
+ const r = await client.query<{ id: number }>(`
+ INSERT INTO organizations (
+ name, type, address, city, state, zip, county, neighborhood,
+ lat, lng, geocoded_at, phone, website, address_norm,
+ firm_size_band, source_url
+ ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,NOW(),$11,$12,$13,$14,$15)
+ RETURNING id
+ `, [name, 'law_firm', addr.full, addr.city, addr.state, addr.zip, 'Los Angeles',
+ neighborhood, lat, lng, phone, website, addressNorm,
+ firmSizeBand(name), sourceUrl]);
+ orgId = r.rows[0].id;
+ }
+
+ if (email) {
+ await client.query(`
+ INSERT INTO emails (organization_id, email, email_type, source_url, last_verified_at)
+ VALUES ($1,$2,'office',$3,NOW())
+ `, [orgId, email, sourceUrl]);
+ }
+ if (phone) {
+ await client.query(`
+ INSERT INTO phones (organization_id, phone, phone_type, source_url, last_verified_at)
+ VALUES ($1,$2,'office',$3,NOW())
+ ON CONFLICT DO NOTHING
+ `, [orgId, phone, sourceUrl]);
+ }
+
+ // Stash raw element for provenance.
+ const rawJson = JSON.stringify({ ...el, tags });
+ const hash = sha256(rawJson + '|organization|' + orgId);
+ await client.query(`
+ INSERT INTO raw_records (source_id, source_url, entity_type, entity_id, raw_json, fetched_at, hash)
+ VALUES ($1,$2,'organization',$3,$4::jsonb,NOW(),$5)
+ ON CONFLICT (source_id, hash) DO NOTHING
+ `, [sourceId, sourceUrl, orgId, rawJson, hash]);
+
+ return orgId;
+ });
+}
+
+async function main() {
+ console.log('[osm] querying Overpass for office=lawyer in LA County (Q104994)…');
+ const sourceId = await getSourceId();
+ const jobId = await startJob(sourceId, 'osm:overpass:la-county');
+
+ let elements: OverpassElement[] = [];
+ try {
+ elements = await fetchOverpass();
+ } catch (e) {
+ await finishJob(jobId, { status: 'failed', error_message: (e as Error).message });
+ throw e;
+ }
+
+ console.log(`[osm] received ${elements.length} elements`);
+
+ let inserted = 0, skipped = 0;
+ for (const el of elements) {
+ try {
+ const id = await upsert(el, sourceId);
+ if (id) inserted++; else skipped++;
+ } catch (e) {
+ console.error(`[osm] upsert err ${el.type}/${el.id}: ${(e as Error).message}`);
+ skipped++;
+ }
+ }
+
+ await finishJob(jobId, {
+ status: 'completed',
+ records_found: elements.length,
+ records_inserted: inserted,
+ records_skipped: skipped,
+ });
+
+ console.log(`[osm] done. seen=${elements.length} kept=${inserted} skipped=${skipped}`);
+ await pool.end();
+}
+
+main().catch(async (err) => {
+ console.error('[osm] fatal:', err);
+ try { await pool.end(); } catch {}
+ process.exit(1);
+});
diff --git a/src/ingest/wikidata.ts b/src/ingest/wikidata.ts
new file mode 100644
index 0000000..42031e5
--- /dev/null
+++ b/src/ingest/wikidata.ts
@@ -0,0 +1,234 @@
+/**
+ * Wikidata SPARQL importer — pulls notable law firms with HQ or office in LA County.
+ * Free, no auth. Captures the big-name firms that OSM under-represents (Latham,
+ * Gibson Dunn, Paul Hastings, Manatt, Lewis Brisbois, Littler Mendelson, etc.).
+ *
+ * Endpoint: https://query.wikidata.org/sparql
+ * License: CC0 (Wikidata data) — fully reusable.
+ */
+import 'dotenv/config';
+import crypto from 'node:crypto';
+import { fetch } from 'undici';
+import { pool, query, withTx } from '../db/pool.ts';
+
+const SOURCE_NAME = 'Wikidata (LA County law firms)';
+const ENDPOINT = 'https://query.wikidata.org/sparql';
+const USER_AGENT = process.env.USER_AGENT
+ || 'LawyerDirectoryBuilder/0.1 (research; contact: steveabramsdesigns@gmail.com)';
+
+const SPARQL = `
+SELECT DISTINCT ?firm ?firmLabel ?hq ?hqLabel ?street ?coord ?inception ?website ?employees WHERE {
+ ?firm wdt:P31/wdt:P279* wd:Q613142 .
+ OPTIONAL { ?firm wdt:P159 ?hq . }
+ OPTIONAL { ?firm wdt:P969 ?street . }
+ OPTIONAL { ?firm wdt:P625 ?coord . }
+ OPTIONAL { ?firm wdt:P571 ?inception . }
+ OPTIONAL { ?firm wdt:P856 ?website . }
+ OPTIONAL { ?firm wdt:P1128 ?employees . }
+ {
+ ?firm wdt:P159 ?hq2 . ?hq2 wdt:P131* wd:Q104994 .
+ } UNION {
+ ?firm wdt:P276 ?loc2 . ?loc2 wdt:P131* wd:Q104994 .
+ }
+ SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
+}
+LIMIT 500
+`;
+
+async function ensureSource(): Promise<number> {
+ await query(`
+ INSERT INTO sources (source_name, source_type, base_url, terms_notes, allowed_method, rate_limit_rps)
+ VALUES ($1, 'api', 'https://query.wikidata.org/sparql', 'Wikidata SPARQL — CC0. Polite-use; cap ~1 query / 5 s.', 'api', 0.20)
+ ON CONFLICT (source_name) DO NOTHING
+ `, [SOURCE_NAME]);
+ const r = await query<{ id: number }>(`SELECT id FROM sources WHERE source_name = $1`, [SOURCE_NAME]);
+ return r.rows[0].id;
+}
+
+async function startJob(sourceId: number, label: string) {
+ const r = await query<{ id: number }>(`
+ INSERT INTO scrape_jobs (source_id, job_label, status, started_at)
+ VALUES ($1, $2, 'running', NOW()) RETURNING id
+ `, [sourceId, label]);
+ return r.rows[0].id;
+}
+
+async function finishJob(jobId: number, fields: Record<string, unknown>) {
+ const sets: string[] = [];
+ const params: unknown[] = [];
+ let i = 1;
+ for (const [k, v] of Object.entries(fields)) {
+ sets.push(`${k} = $${i++}`); params.push(v);
+ }
+ sets.push(`finished_at = NOW()`);
+ params.push(jobId);
+ await query(`UPDATE scrape_jobs SET ${sets.join(', ')} WHERE id = $${i}`, params);
+}
+
+function clean(s: string | undefined | null) {
+ if (!s) return null;
+ const t = String(s).trim();
+ return t.length === 0 ? null : t;
+}
+
+function normAddress(s: string | null) {
+ if (!s) return null;
+ return s.toLowerCase().replace(/[.,]/g, ' ').replace(/\b(suite|ste|unit|apt|#)\b/g, '').replace(/\s+/g, ' ').trim();
+}
+
+function parseCoord(p: string | undefined) {
+ if (!p) return { lat: null, lng: null };
+ const m = p.match(/Point\(\s*(-?\d+\.?\d*)\s+(-?\d+\.?\d*)\s*\)/);
+ if (!m) return { lat: null, lng: null };
+ return { lat: parseFloat(m[2]), lng: parseFloat(m[1]) };
+}
+
+interface SparqlBinding {
+ firm?: { value: string };
+ firmLabel?: { value: string };
+ hqLabel?: { value: string };
+ street?: { value: string };
+ coord?: { value: string };
+ inception?: { value: string };
+ website?: { value: string };
+ employees?: { value: string };
+}
+
+async function fetchSparql(): Promise<SparqlBinding[]> {
+ const url = `${ENDPOINT}?query=${encodeURIComponent(SPARQL)}`;
+ const r = await fetch(url, {
+ headers: {
+ 'User-Agent': USER_AGENT,
+ Accept: 'application/sparql-results+json',
+ },
+ signal: AbortSignal.timeout(60000),
+ });
+ if (!r.ok) {
+ const t = await r.text();
+ throw new Error(`Wikidata ${r.status}: ${t.slice(0, 300)}`);
+ }
+ const j = await r.json() as { results: { bindings: SparqlBinding[] } };
+ return j.results.bindings;
+}
+
+async function upsertFirm(b: SparqlBinding, sourceId: number) {
+ const name = clean(b.firmLabel?.value);
+ if (!name || /^Q\d+$/.test(name)) return null; // skip Q-IDs (no English label)
+
+ const wdQid = b.firm?.value?.split('/').pop() || null;
+ const hq = clean(b.hqLabel?.value);
+ const street = clean(b.street?.value);
+ const website = clean(b.website?.value);
+ const { lat, lng } = parseCoord(b.coord?.value);
+ const employees = b.employees?.value ? parseInt(b.employees.value, 10) : null;
+
+ const fullAddress = street && hq ? `${street}, ${hq}, CA` : (street || hq || null);
+ const sourceUrl = b.firm?.value || 'https://www.wikidata.org/';
+
+ return await withTx(async (client) => {
+ // Try to merge with existing org by website (BigLaw firms usually have unique websites).
+ let orgId: number | null = null;
+ if (website) {
+ const r = await client.query<{ id: number }>(
+ `SELECT id FROM organizations WHERE LOWER(website) = LOWER($1) LIMIT 1`, [website]);
+ if (r.rowCount && r.rowCount > 0) orgId = r.rows[0].id;
+ }
+ if (!orgId) {
+ // Match by exact name (case-insensitive)
+ const r = await client.query<{ id: number }>(
+ `SELECT id FROM organizations WHERE LOWER(name) = LOWER($1) AND county = 'Los Angeles' LIMIT 1`, [name]);
+ if (r.rowCount && r.rowCount > 0) orgId = r.rows[0].id;
+ }
+
+ const sizeBand = employees && employees >= 500 ? 'biglaw'
+ : employees && employees >= 100 ? 'large'
+ : employees && employees >= 25 ? 'medium'
+ : null;
+
+ if (orgId) {
+ await client.query(`
+ UPDATE organizations
+ SET name = $2,
+ website = COALESCE(website, $3),
+ address = COALESCE(address, $4),
+ address_norm = COALESCE(address_norm, $5),
+ city = COALESCE(city, $6),
+ neighborhood = COALESCE(neighborhood, $6),
+ lat = COALESCE(lat, $7),
+ lng = COALESCE(lng, $8),
+ firm_size_band = COALESCE($9, firm_size_band),
+ attorney_count = GREATEST(attorney_count, COALESCE($10, 0)),
+ source_url = COALESCE(source_url, $11),
+ updated_at = NOW()
+ WHERE id = $1
+ `, [orgId, name, website, fullAddress, normAddress(fullAddress), hq, lat, lng, sizeBand, employees, sourceUrl]);
+ } else {
+ const r = await client.query<{ id: number }>(`
+ INSERT INTO organizations (
+ name, type, address, address_norm, city, neighborhood, state, county,
+ lat, lng, geocoded_at, website, firm_size_band, attorney_count, source_url
+ ) VALUES (
+ $1,'law_firm',$2,$3,$4,$4,'CA','Los Angeles',
+ $5::double precision, $6::double precision,
+ CASE WHEN $5::double precision IS NOT NULL THEN NOW() END,
+ $7, $8, COALESCE($9::int, 0), $10
+ )
+ RETURNING id
+ `, [name, fullAddress, normAddress(fullAddress), hq, lat, lng, website, sizeBand, employees, sourceUrl]);
+ orgId = r.rows[0].id;
+ }
+
+ // Provenance
+ const rawJson = JSON.stringify(b);
+ const hash = crypto.createHash('sha256').update(rawJson + '|wikidata|' + (wdQid || name) + '|' + orgId).digest('hex');
+ await client.query(`
+ INSERT INTO raw_records (source_id, source_url, entity_type, entity_id, raw_json, fetched_at, hash)
+ VALUES ($1,$2,'organization',$3,$4::jsonb, NOW(),$5)
+ ON CONFLICT (source_id, hash) DO NOTHING
+ `, [sourceId, sourceUrl, orgId, rawJson, hash]);
+
+ return orgId;
+ });
+}
+
+async function main() {
+ console.log('[wikidata] querying SPARQL for law firms with LA County HQ/office…');
+ const sourceId = await ensureSource();
+ const jobId = await startJob(sourceId, 'wikidata:la-county-firms');
+
+ let bindings: SparqlBinding[] = [];
+ try {
+ bindings = await fetchSparql();
+ } catch (e) {
+ await finishJob(jobId, { status: 'failed', error_message: (e as Error).message });
+ throw e;
+ }
+ console.log(`[wikidata] received ${bindings.length} bindings`);
+
+ let inserted = 0, skipped = 0;
+ for (const b of bindings) {
+ try {
+ const id = await upsertFirm(b, sourceId);
+ if (id) inserted++; else skipped++;
+ } catch (e) {
+ console.error(`[wikidata] upsert err: ${(e as Error).message}`);
+ skipped++;
+ }
+ }
+
+ await finishJob(jobId, {
+ status: 'completed',
+ records_found: bindings.length,
+ records_inserted: inserted,
+ records_skipped: skipped,
+ });
+
+ console.log(`[wikidata] done. seen=${bindings.length} kept=${inserted} skipped=${skipped}`);
+ await pool.end();
+}
+
+main().catch(async (err) => {
+ console.error('[wikidata] fatal:', err);
+ try { await pool.end(); } catch {}
+ process.exit(1);
+});
diff --git a/src/lib/compliance.ts b/src/lib/compliance.ts
new file mode 100644
index 0000000..c3e52ca
--- /dev/null
+++ b/src/lib/compliance.ts
@@ -0,0 +1,111 @@
+import 'dotenv/config';
+import { fetch } from 'undici';
+// @ts-ignore — robots-parser has no types ship
+import robotsParser from 'robots-parser';
+
+const USER_AGENT = process.env.USER_AGENT
+ || 'LawyerDirectoryBuilder/0.1 (research; contact: steveabramsdesigns@gmail.com)';
+const DEFAULT_RPS = parseFloat(process.env.STATE_BAR_RPS || '0.5');
+
+const robotsCache = new Map<string, { robots: any; fetchedAt: number }>();
+const ONE_DAY = 24 * 60 * 60 * 1000;
+
+async function getRobotsFor(urlStr: string) {
+ const u = new URL(urlStr);
+ const cached = robotsCache.get(u.host);
+ if (cached && Date.now() - cached.fetchedAt < ONE_DAY) return cached.robots;
+
+ const robotsUrl = `${u.protocol}//${u.host}/robots.txt`;
+ let body = '';
+ try {
+ const res = await fetch(robotsUrl, {
+ headers: { 'User-Agent': USER_AGENT },
+ signal: AbortSignal.timeout(10000),
+ });
+ if (res.ok) body = await res.text();
+ } catch { /* permissive on transport error */ }
+
+ const robots = robotsParser(robotsUrl, body);
+ robotsCache.set(u.host, { robots, fetchedAt: Date.now() });
+ return robots;
+}
+
+export async function isAllowed(urlStr: string): Promise<boolean> {
+ const robots = await getRobotsFor(urlStr);
+ const verdict = robots.isAllowed(urlStr, USER_AGENT);
+ return verdict !== false;
+}
+
+const lastFetchByHost = new Map<string, number>();
+const rpsByHost = new Map<string, number>();
+
+export function setHostRateLimit(host: string, rps: number) {
+ rpsByHost.set(host, rps);
+}
+
+async function gateHost(host: string) {
+ const rps = rpsByHost.get(host) ?? DEFAULT_RPS;
+ const minInterval = 1000 / rps;
+ const last = lastFetchByHost.get(host) || 0;
+ const wait = Math.max(0, last + minInterval - Date.now());
+ if (wait > 0) await new Promise(r => setTimeout(r, wait));
+ lastFetchByHost.set(host, Date.now());
+}
+
+export interface FetchOpts {
+ respectRobots?: boolean;
+ accept?: string;
+ headers?: Record<string, string>;
+ timeoutMs?: number;
+}
+
+export async function fetchCompliant(urlStr: string, opts: FetchOpts = {}) {
+ const u = new URL(urlStr);
+ if (opts.respectRobots !== false) {
+ const allowed = await isAllowed(urlStr);
+ if (!allowed) {
+ const err = new Error(`robots.txt disallows ${urlStr}`) as Error & { code?: string };
+ err.code = 'ROBOTS_DISALLOWED';
+ throw err;
+ }
+ }
+ await gateHost(u.host);
+
+ const headers: Record<string, string> = {
+ 'User-Agent': USER_AGENT,
+ Accept: opts.accept || 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
+ 'Accept-Language': 'en-US,en;q=0.9',
+ ...(opts.headers || {}),
+ };
+
+ let attempt = 0;
+ while (true) {
+ attempt++;
+ let res: Response | undefined;
+ try {
+ res = await fetch(urlStr, { headers, signal: AbortSignal.timeout(opts.timeoutMs || 30000) }) as unknown as Response;
+ } catch (e) {
+ if (attempt >= 4) throw e;
+ await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt - 1)));
+ continue;
+ }
+
+ if (res.status === 403 || res.status === 401) {
+ const text = await res.text();
+ if (/cf-challenge|recaptcha|hcaptcha|captcha/i.test(text)) {
+ const err = new Error(`CAPTCHA detected at ${urlStr} — aborting per policy`) as Error & { code?: string };
+ err.code = 'CAPTCHA_DETECTED';
+ throw err;
+ }
+ // re-wrap response with body since we consumed it
+ return new Response(text, { status: res.status, headers: res.headers });
+ }
+
+ if (res.status >= 500 && attempt < 4) {
+ await new Promise(r => setTimeout(r, 1000 * Math.pow(2, attempt - 1)));
+ continue;
+ }
+
+ return res;
+ }
+}
diff --git a/src/scripts/inspect_justia.ts b/src/scripts/inspect_justia.ts
new file mode 100644
index 0000000..462daf2
--- /dev/null
+++ b/src/scripts/inspect_justia.ts
@@ -0,0 +1,54 @@
+/**
+ * One-shot probe — fetches a single Justia city page, checks robots, and
+ * dumps a structural summary so we can pick the right CSS selectors before
+ * writing the real crawler.
+ */
+import 'dotenv/config';
+import { load } from 'cheerio';
+import { fetchCompliant, isAllowed } from '../lib/compliance.ts';
+
+const URL = process.argv[2] || 'https://www.justia.com/lawyers/california/beverly-hills';
+
+(async () => {
+ console.log(`probing ${URL}`);
+ console.log(`robots allowed: ${await isAllowed(URL)}`);
+
+ const res = await fetchCompliant(URL);
+ console.log(`status: ${res.status} content-type: ${res.headers.get('content-type')}`);
+ const html = await res.text();
+ console.log(`bytes: ${html.length}`);
+
+ const $ = load(html);
+ console.log(`<title>: ${$('title').text().trim()}`);
+
+ // Common Justia patterns
+ const candidates = [
+ '.lawyer-card', '.lawyer', '.lawyers li', '.search-results-list li',
+ 'div[itemtype*="LegalService"]', 'div[itemtype*="Person"]',
+ '.has-img.iblock', '.iblock',
+ '.facet-doc', '.entry', '.law-firm', '.firm',
+ ];
+ for (const sel of candidates) {
+ const n = $(sel).length;
+ if (n > 0) console.log(` ${sel} → ${n} matches`);
+ }
+
+ // Show first 3 anchors that look like lawyer/firm profiles
+ console.log('\nfirst 6 profile-ish links:');
+ const seen = new Set<string>();
+ $('a[href]').each((_, el) => {
+ const h = $(el).attr('href') || '';
+ if (!h.includes('/lawyers/') && !h.includes('/lawfirm/')) return;
+ if (h === '/lawyers/' || /\/(?:bookmark|share|review|save|map)/i.test(h)) return;
+ if (seen.has(h)) return;
+ seen.add(h);
+ if (seen.size <= 6) console.log(` ${$(el).text().trim().slice(0,60)} → ${h}`);
+ });
+
+ // Show first card-ish block
+ const card = $('.lawyer-card, .iblock, .has-img').first();
+ if (card.length) {
+ console.log('\nfirst card outerHTML (truncated 1.2k):');
+ console.log($.html(card).slice(0, 1200));
+ }
+})();
diff --git a/src/scripts/migrate.ts b/src/scripts/migrate.ts
new file mode 100644
index 0000000..bce2141
--- /dev/null
+++ b/src/scripts/migrate.ts
@@ -0,0 +1,44 @@
+import 'dotenv/config';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { pool } from '../db/pool.ts';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const migrationsDir = path.resolve(__dirname, '../../migrations');
+
+async function main() {
+ // Ensure schema_migrations tracking table.
+ await pool.query(`
+ CREATE TABLE IF NOT EXISTS schema_migrations (
+ filename TEXT PRIMARY KEY,
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+ );
+ `);
+
+ const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
+ for (const file of files) {
+ const exists = await pool.query('SELECT 1 FROM schema_migrations WHERE filename = $1', [file]);
+ if (exists.rowCount && exists.rowCount > 0) {
+ console.log(`✓ already applied ${file}`);
+ continue;
+ }
+ const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf8');
+ console.log(`→ applying ${file}`);
+ const client = await pool.connect();
+ try {
+ await client.query(sql);
+ await client.query('INSERT INTO schema_migrations(filename) VALUES ($1)', [file]);
+ console.log(`✓ applied ${file}`);
+ } finally {
+ client.release();
+ }
+ }
+
+ await pool.end();
+}
+
+main().catch(err => {
+ console.error('migrate failed:', err);
+ process.exit(1);
+});
diff --git a/src/scripts/run_overnight.ts b/src/scripts/run_overnight.ts
new file mode 100644
index 0000000..7ad05d4
--- /dev/null
+++ b/src/scripts/run_overnight.ts
@@ -0,0 +1,93 @@
+/**
+ * Overnight orchestrator — runs the importers in sequence, checkpointing progress
+ * via scrape_jobs.checkpoint so a kill mid-run resumes cleanly on the next start.
+ *
+ * Sources wired:
+ * 1. OSM Overpass refresh (LA County office=lawyer / amenity=lawyer)
+ * 2. Wikidata SPARQL (notable LA firms — BigLaw filler)
+ * 3. Aggregation pass (recompute attorney_count + concentration views)
+ *
+ * Sources blocked by anti-bot (NOT run; documented separately):
+ * - CA State Bar attorney search → SPA, requires Playwright (see docs/blocked_sources.md)
+ * - CA SOS bizfile → POST endpoint requires recaptcha token
+ *
+ * Loop policy: run once, exit 0. Wrap with `pm2`/`watch`/cron if continuous mode is desired.
+ */
+import 'dotenv/config';
+import { spawn } from 'node:child_process';
+import path from 'node:path';
+import fs from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { pool, query } from '../db/pool.ts';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(__dirname, '../..');
+const LOG_DIR = path.join(ROOT, 'logs');
+fs.mkdirSync(LOG_DIR, { recursive: true });
+
+const TS = new Date().toISOString().slice(0, 10);
+const LOG = path.join(LOG_DIR, `overnight-${TS}.log`);
+const log = fs.createWriteStream(LOG, { flags: 'a' });
+function out(s: string) { const line = `[${new Date().toISOString()}] ${s}\n`; process.stdout.write(line); log.write(line); }
+
+function run(name: string, scriptPath: string, env: Record<string, string> = {}): Promise<number> {
+ out(`▶ ${name}: tsx ${scriptPath}`);
+ return new Promise((resolve) => {
+ const child = spawn('npx', ['tsx', scriptPath], {
+ cwd: ROOT,
+ env: { ...process.env, ...env },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ child.stdout.on('data', d => log.write(d));
+ child.stderr.on('data', d => log.write(d));
+ child.on('close', code => { out(`✓ ${name} exited ${code}`); resolve(code ?? 0); });
+ });
+}
+
+async function aggregationPass() {
+ out('▶ aggregation: recompute attorney_count + size band');
+ // Future: count professional_locations per organization.
+ // For now: leave attorney_count as set by importers; only band by attorney_count if available.
+ await query(`
+ UPDATE organizations
+ SET firm_size_band = CASE
+ WHEN attorney_count >= 500 THEN 'biglaw'
+ WHEN attorney_count >= 100 THEN 'large'
+ WHEN attorney_count >= 25 THEN 'medium'
+ WHEN attorney_count >= 5 THEN 'small'
+ WHEN attorney_count >= 1 THEN 'solo'
+ ELSE firm_size_band
+ END
+ WHERE type='law_firm' AND attorney_count > 0
+ `);
+ out('✓ aggregation done');
+}
+
+async function summary() {
+ const r = await query<{ k: string; v: string }>(`
+ SELECT 'firms_total' AS k, COUNT(*)::text AS v FROM organizations WHERE type='law_firm'
+ UNION ALL SELECT 'with_geo', COUNT(*)::text FROM organizations WHERE type='law_firm' AND lat IS NOT NULL
+ UNION ALL SELECT 'with_site', COUNT(*)::text FROM organizations WHERE type='law_firm' AND website IS NOT NULL
+ UNION ALL SELECT 'biglaw', COUNT(*)::text FROM organizations WHERE type='law_firm' AND firm_size_band='biglaw'
+ `);
+ out('── summary ──');
+ for (const row of r.rows) out(` ${row.k.padEnd(14)} ${row.v}`);
+}
+
+async function main() {
+ out('=== overnight run start ===');
+ const code1 = await run('osm-overpass', 'src/ingest/osm_overpass.ts');
+ const code2 = await run('wikidata', 'src/ingest/wikidata.ts');
+ await aggregationPass();
+ await summary();
+ out(`=== overnight run end (osm=${code1}, wd=${code2}) ===`);
+ await pool.end();
+ log.end();
+}
+
+main().catch(async (err) => {
+ out(`fatal: ${(err as Error).message}`);
+ try { await pool.end(); } catch {}
+ log.end();
+ process.exit(1);
+});
diff --git a/src/server/index.ts b/src/server/index.ts
new file mode 100644
index 0000000..d87f4f0
--- /dev/null
+++ b/src/server/index.ts
@@ -0,0 +1,130 @@
+/**
+ * Lawyer Directory — read API + Leaflet heatmap dashboard on :9701.
+ */
+import 'dotenv/config';
+import express from 'express';
+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 || '9701', 10);
+
+const app = express();
+app.use(express.json());
+app.use((_req, res, next) => {
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ next();
+});
+
+// ─── Read API ───────────────────────────────────────────────────────────────
+
+app.get('/api/health', (_req, res) => res.json({ ok: true, port: PORT, ts: new Date().toISOString() }));
+
+app.get('/api/stats', async (_req, res) => {
+ const r = await query<{ key: string; value: string }>(`
+ SELECT 'firms_total' AS key, COUNT(*)::text AS value FROM organizations WHERE type='law_firm'
+ UNION ALL SELECT 'firms_with_geo', COUNT(*)::text FROM organizations WHERE type='law_firm' AND lat IS NOT NULL
+ UNION ALL SELECT 'firms_with_site', COUNT(*)::text FROM organizations WHERE type='law_firm' AND website IS NOT NULL
+ UNION ALL SELECT 'firms_with_phone',COUNT(*)::text FROM organizations WHERE type='law_firm' AND phone IS NOT NULL
+ UNION ALL SELECT 'cities', COUNT(DISTINCT city)::text FROM organizations WHERE type='law_firm'
+ UNION ALL SELECT 'multi_firm_buildings', COUNT(*)::text FROM (
+ SELECT address FROM organizations WHERE type='law_firm' AND address IS NOT NULL
+ GROUP BY address HAVING COUNT(*) > 1
+ ) t
+ UNION ALL SELECT 'sources_active', COUNT(*)::text FROM sources
+ `);
+ const out: Record<string, number> = {};
+ for (const row of r.rows) out[row.key] = parseInt(row.value, 10);
+ res.json(out);
+});
+
+app.get('/api/firms', async (req, res) => {
+ const city = (req.query.city as string | undefined)?.trim() || null;
+ const zip = (req.query.zip as string | undefined)?.trim() || null;
+ const limit = Math.min(parseInt((req.query.limit as string) || '200', 10), 1000);
+
+ const where: string[] = ["type='law_firm'"];
+ const params: unknown[] = [];
+ if (city) { params.push(city); where.push(`(LOWER(city) = LOWER($${params.length}) OR LOWER(neighborhood) = LOWER($${params.length}))`); }
+ if (zip) { params.push(zip); where.push(`zip = $${params.length}`); }
+ params.push(limit);
+
+ const r = await query(`
+ SELECT id, name, address, city, neighborhood, zip, phone, website, lat, lng, firm_size_band, attorney_count
+ FROM organizations
+ WHERE ${where.join(' AND ')}
+ ORDER BY attorney_count DESC NULLS LAST, name ASC
+ LIMIT $${params.length}
+ `, params);
+ res.json({ count: r.rowCount, rows: r.rows });
+});
+
+app.get('/api/firms/:id', async (req, res) => {
+ const id = parseInt(req.params.id, 10);
+ if (!Number.isFinite(id)) return res.status(400).json({ error: 'bad id' });
+ const r = await query(`SELECT * FROM organizations WHERE id = $1`, [id]);
+ if (!r.rowCount) return res.status(404).json({ error: 'not found' });
+ res.json(r.rows[0]);
+});
+
+app.get('/api/heatmap', async (_req, res) => {
+ const r = await query<{ lat: number; lng: number; weight: number }>(`
+ SELECT lat, lng, GREATEST(1, attorney_count) AS weight
+ FROM organizations
+ WHERE type='law_firm' AND lat IS NOT NULL AND lng IS NOT NULL
+ `);
+ res.json({ count: r.rowCount, points: r.rows });
+});
+
+app.get('/api/buildings', async (_req, res) => {
+ const r = await query(`
+ SELECT address, city, COUNT(*)::int AS firms, AVG(lat) AS lat, AVG(lng) AS lng,
+ ARRAY_AGG(name ORDER BY name) AS names
+ FROM organizations
+ WHERE type='law_firm' AND address IS NOT NULL AND lat IS NOT NULL
+ GROUP BY address, city
+ HAVING COUNT(*) > 1
+ ORDER BY firms DESC
+ `);
+ res.json({ count: r.rowCount, rows: r.rows });
+});
+
+app.get('/api/cities', async (_req, res) => {
+ const r = await query(`
+ SELECT COALESCE(neighborhood, city) AS area,
+ COUNT(*)::int AS firms,
+ AVG(lat) AS lat, AVG(lng) AS lng
+ FROM organizations
+ WHERE type='law_firm' AND COALESCE(neighborhood, city) IS NOT NULL
+ GROUP BY area
+ ORDER BY firms DESC
+ `);
+ res.json({ count: r.rowCount, rows: r.rows });
+});
+
+app.get('/api/jobs', async (_req, res) => {
+ const r = await query(`
+ SELECT j.id, s.source_name, j.job_label, j.status, j.started_at, j.finished_at,
+ j.records_found, j.records_inserted, j.records_skipped, j.error_message
+ FROM scrape_jobs j
+ JOIN sources s ON s.id = j.source_id
+ ORDER BY j.id DESC
+ LIMIT 30
+ `);
+ res.json({ count: r.rowCount, rows: r.rows });
+});
+
+// ─── Static dashboard ───────────────────────────────────────────────────────
+
+app.use('/', express.static(path.resolve(__dirname, '../../public')));
+
+app.listen(PORT, () => {
+ console.log(`[lawyer-directory] listening on http://localhost:${PORT}`);
+ console.log(` open http://localhost:${PORT}/`);
+ console.log(` api stats http://localhost:${PORT}/api/stats`);
+ console.log(` api heatmap http://localhost:${PORT}/api/heatmap`);
+});
+
+process.on('SIGINT', async () => { await pool.end(); process.exit(0); });
+process.on('SIGTERM', async () => { await pool.end(); process.exit(0); });
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..43da77e
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "lib": ["ES2022"],
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "resolveJsonModule": true,
+ "allowImportingTsExtensions": true,
+ "noEmit": true,
+ "isolatedModules": true,
+ "forceConsistentCasingInFileNames": true,
+ "verbatimModuleSyntax": false
+ },
+ "include": ["src/**/*.ts"]
+}
(oldest)
·
back to Lawyer Directory Builder
·
Dashboard: Grid + List + Map views with column slider 6003ada →