← back to Animals
fix(map): stop near-me SQL from crashing, hide empty cats, add cap notice
9e169437187b52fb519e247997636b22df937b83 · 2026-05-01 13:02:40 -0700 · SteveStudio2
The home-page "Sharing your location loads the closest 24 listings…" was
the reported "map does not work" — /api/near-me returned 500 because
Postgres saw $1/$2/$3 as untyped 'unknown' and refused subtraction. The
CTE param-typing pattern in index.js fixes the planner. Plus three
user-visible cleanups from the dual-Claude review:
- renderHome filters out zero-count categories (Specialty Hospitals,
Daycare) so the home-page grid stops linking to dead-end pages.
- renderCategory annotates the map when the 200-marker cap is hit,
e.g. /dog-parks now shows "Map shows first 200 of 377 geocoded
listings — full table below."
- All Leaflet CSS + JS <link>/<script> tags get integrity= hashes
back; they were stripped during the working-tree refactor.
Also lands the broader companion work that was sitting uncommitted:
geo.js (browser geolocation + ipapi.co fallback), sort-table.js,
OSM California / Reddit / Nextdoor ingest stubs, email enrich
migration, llm helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
A docs/community_signal.mdA migrations/002_emails.sqlM package.jsonM public/css/site.cssA public/js/geo.jsA public/js/sort-table.jsA src/enrich/extract_emails.jsA src/ingest/nextdoor.jsA src/ingest/osm_california.jsA src/ingest/reddit.jsA src/lib/llm.jsM src/server/index.jsM src/server/render.js
Diff
commit 9e169437187b52fb519e247997636b22df937b83
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Fri May 1 13:02:40 2026 -0700
fix(map): stop near-me SQL from crashing, hide empty cats, add cap notice
The home-page "Sharing your location loads the closest 24 listings…" was
the reported "map does not work" — /api/near-me returned 500 because
Postgres saw $1/$2/$3 as untyped 'unknown' and refused subtraction. The
CTE param-typing pattern in index.js fixes the planner. Plus three
user-visible cleanups from the dual-Claude review:
- renderHome filters out zero-count categories (Specialty Hospitals,
Daycare) so the home-page grid stops linking to dead-end pages.
- renderCategory annotates the map when the 200-marker cap is hit,
e.g. /dog-parks now shows "Map shows first 200 of 377 geocoded
listings — full table below."
- All Leaflet CSS + JS <link>/<script> tags get integrity= hashes
back; they were stripped during the working-tree refactor.
Also lands the broader companion work that was sitting uncommitted:
geo.js (browser geolocation + ipapi.co fallback), sort-table.js,
OSM California / Reddit / Nextdoor ingest stubs, email enrich
migration, llm helper.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
docs/community_signal.md | 105 +++++++++++++++++++
migrations/002_emails.sql | 55 ++++++++++
package.json | 5 +
public/css/site.css | 23 ++++-
public/js/geo.js | 167 ++++++++++++++++++++++++++++++
public/js/sort-table.js | 67 ++++++++++++
src/enrich/extract_emails.js | 236 +++++++++++++++++++++++++++++++++++++++++++
src/ingest/nextdoor.js | 56 ++++++++++
src/ingest/osm_california.js | 184 +++++++++++++++++++++++++++++++++
src/ingest/reddit.js | 168 ++++++++++++++++++++++++++++++
src/lib/llm.js | 59 +++++++++++
src/server/index.js | 46 ++++++++-
src/server/render.js | 97 ++++++++++++------
13 files changed, 1234 insertions(+), 34 deletions(-)
diff --git a/docs/community_signal.md b/docs/community_signal.md
new file mode 100644
index 0000000..86d459f
--- /dev/null
+++ b/docs/community_signal.md
@@ -0,0 +1,105 @@
+# Community-signal ingest — Reddit, Nextdoor, and the free path
+
+The animals directory has structured public sources (state vet boards, AAHA,
+OSM, Petfinder). Community sources fill in everything they miss: which vet is
+actually loved, who's the best groomer in a 5-mile radius, who's a scammy
+breeder. This doc covers the free ingest path.
+
+## What's wired
+
+| Source | File | Status | Cost |
+| ---------- | ------------------------------- | ----------- | ----- |
+| Reddit | `src/ingest/reddit.js` | LIVE | $0 |
+| Nextdoor | `src/ingest/nextdoor.js` | STUB | (paid) |
+| Patch.com | (planned `src/ingest/patch.js`) | not started | $0 |
+
+## Reddit pipeline
+
+```
+fetch r/<sub>/new.json (anonymous, ~60 req/min)
+ → regex prefilter (drops 80% of noise without an LLM call)
+ → gemma3:4b triage ("is this about a real pet-care business?")
+ → qwen3:14b extract (JSON: {business_name, service_type, city, state, sentiment})
+ → INSERT raw_records (idempotent on hash)
+```
+
+Both LLMs are local Ollama. Zero API cost.
+
+### Subreddits
+
+Default set (`src/ingest/reddit.js`): pet-care subs (`AskVet`, `dogs`, `cats`,
+`Pets`, `Petloss`, `puppy101`, `CatAdvice`) + top-50 US metro city subs
+(`LosAngeles`, `nyc`, `chicago`, etc).
+
+Override:
+
+```sh
+SUBREDDITS=LosAngeles,nyc,chicago LIMIT=25 npm run ingest:reddit
+DRY=1 npm run ingest:reddit:dry # see what would land without writing
+```
+
+### What gets stored
+
+`raw_records` rows with `entity_type='reddit_post'` and `raw_json` containing:
+
+```json
+{
+ "post": { /* full reddit post */ },
+ "mentions": [
+ { "business_name": "...", "service_type": "vet_clinic",
+ "city": "Los Angeles", "state": "CA",
+ "sentiment": "positive", "context": "..." }
+ ],
+ "sub": "LosAngeles"
+}
+```
+
+A downstream enrichment pass (next iteration) walks `raw_records`, fuzzy-matches
+`business_name + city/state` against `businesses`, and either:
+
+- Adds a `community_mention` row to a new `business_mentions` table (TODO), or
+- Promotes a brand-new business if no match exists with confidence ≥ 0.7.
+
+## Weekly schedule
+
+Loaded as a launchd agent on Mac Studio 2:
+
+- Plist: `~/Library/LaunchAgents/com.steve.animals-reddit-weekly.plist`
+- Schedule: every Sunday at 07:00 local
+- Logs: `~/Projects/animals/logs/reddit-weekly.{log,err}`
+
+Manage:
+
+```sh
+launchctl bootstrap gui/$UID ~/Library/LaunchAgents/com.steve.animals-reddit-weekly.plist
+launchctl bootout gui/$UID/com.steve.animals-reddit-weekly
+launchctl kickstart gui/$UID/com.steve.animals-reddit-weekly # run now
+launchctl print gui/$UID/com.steve.animals-reddit-weekly
+```
+
+## Nextdoor — why it's a stub
+
+Nextdoor has no public API and aggressively blocks anonymous scrapers
+(Cloudflare bot detection + login wall). Three free-ish surfaces exist:
+
+1. **Public business pages** (`/pages/<slug>/`) — useful for enriching a
+ known business; rate-limits anonymous fetches to a few per minute.
+2. **Public sitemap** (`nextdoor.com/sitemap.xml`) — best free starting
+ point for discovery; politely crawl with 5s delays.
+3. **Logged-in Browserbase session** — required for actual neighborhood
+ feed posts and "recommendations" threads. **Not free** — Browserbase
+ has paid tiers and Nextdoor itself requires an account. Per Steve's
+ "must be free" constraint, not implemented.
+
+When ready to enable the Browserbase route:
+
+```sh
+NEXTDOOR_EMAIL=... NEXTDOOR_PASS=... USE_BROWSERBASE=1 npm run ingest:nextdoor
+```
+
+## Free Nextdoor substitute
+
+**Patch.com** is the closest free analog — city-by-city local-news + community
+business mentions, no login wall. A `src/ingest/patch.js` module is the next
+brick. Coverage is good in 1,200+ US cities (better than Nextdoor in many
+secondary markets).
diff --git a/migrations/002_emails.sql b/migrations/002_emails.sql
new file mode 100644
index 0000000..12e0347
--- /dev/null
+++ b/migrations/002_emails.sql
@@ -0,0 +1,55 @@
+-- Email enrichment — emails are the most valuable contact data on the platform.
+-- Without an email we can't run leads outreach (Rail B) OR the upgrade pitch
+-- (Rail C). Treat email discovery as a first-class job, not a side-effect.
+--
+-- Two granularities:
+-- 1. business_emails — one row per (business, email, source_page_url).
+-- Lets us track multiple emails per business + remember WHERE we found
+-- them (for compliance + re-verify later when they go stale).
+-- 2. veterinarian_emails — same but for individual vets.
+-- Plus a `email_kind` heuristic to distinguish role-based (info@, contact@)
+-- from personal (drsmith@…) so the lead router can pick the right one.
+
+BEGIN;
+
+CREATE TABLE IF NOT EXISTS business_emails (
+ id BIGSERIAL PRIMARY KEY,
+ business_id BIGINT NOT NULL REFERENCES businesses(id) ON DELETE CASCADE,
+ email TEXT NOT NULL,
+ email_kind TEXT CHECK (email_kind IN ('role','personal','staff','intake','admin','unknown')),
+ context_label TEXT, -- nearby text on page: "Office Manager", "Dr. Smith"
+ source_url TEXT NOT NULL, -- page where we found it
+ source_method TEXT NOT NULL, -- 'mailto' | 'plaintext' | 'jsonld' | 'manual'
+ found_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ verified_at TIMESTAMPTZ,
+ verification_status TEXT NOT NULL DEFAULT 'unverified'
+ CHECK (verification_status IN ('unverified','syntactically_valid','mx_valid','bounced','opted_out','rejected'))
+);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_biz_emails_uniq ON business_emails(business_id, LOWER(email), source_url);
+CREATE INDEX IF NOT EXISTS idx_biz_emails_biz ON business_emails(business_id);
+CREATE INDEX IF NOT EXISTS idx_biz_emails_email ON business_emails(LOWER(email));
+CREATE INDEX IF NOT EXISTS idx_biz_emails_kind ON business_emails(email_kind);
+
+CREATE TABLE IF NOT EXISTS veterinarian_emails (
+ id BIGSERIAL PRIMARY KEY,
+ veterinarian_id BIGINT NOT NULL REFERENCES veterinarians(id) ON DELETE CASCADE,
+ business_id BIGINT REFERENCES businesses(id) ON DELETE SET NULL,
+ email TEXT NOT NULL,
+ email_kind TEXT CHECK (email_kind IN ('role','personal','staff','intake','admin','unknown')),
+ source_url TEXT NOT NULL,
+ source_method TEXT NOT NULL,
+ found_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ verified_at TIMESTAMPTZ,
+ verification_status TEXT NOT NULL DEFAULT 'unverified'
+);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_vet_emails_uniq ON veterinarian_emails(veterinarian_id, LOWER(email));
+CREATE INDEX IF NOT EXISTS idx_vet_emails_vet ON veterinarian_emails(veterinarian_id);
+CREATE INDEX IF NOT EXISTS idx_vet_emails_email ON veterinarian_emails(LOWER(email));
+
+-- Add a "last attempted to find emails" stamp so the enricher can skip
+-- recently-checked sites and not hammer the same hosts.
+ALTER TABLE businesses
+ ADD COLUMN IF NOT EXISTS email_search_at TIMESTAMPTZ,
+ ADD COLUMN IF NOT EXISTS email_search_pages_tried INTEGER NOT NULL DEFAULT 0;
+
+COMMIT;
diff --git a/package.json b/package.json
index d4fe2ad..163a4a2 100644
--- a/package.json
+++ b/package.json
@@ -10,8 +10,13 @@
"seed:breeds": "node src/scripts/seed_breeds.js",
"seed:sources": "node src/scripts/seed_sources.js",
"ingest:vets": "node src/ingest/state_vet_boards.js",
+ "ingest:osm-ca": "node src/ingest/osm_california.js",
+ "enrich:emails": "node src/enrich/extract_emails.js",
"ingest:clinics:aaha": "node src/ingest/aaha_accredited.js",
"ingest:shelters": "node src/ingest/petfinder_shelters.js",
+ "ingest:reddit": "node src/ingest/reddit.js",
+ "ingest:reddit:dry": "DRY=1 LIMIT=10 node src/ingest/reddit.js",
+ "ingest:nextdoor": "node src/ingest/nextdoor.js",
"audit:websites": "node src/audit/site_audit.js",
"audit:mockups": "node src/audit/generate_mockups.js",
"domains:research": "node src/scripts/research_domains.js",
diff --git a/public/css/site.css b/public/css/site.css
index 0416879..04db409 100644
--- a/public/css/site.css
+++ b/public/css/site.css
@@ -80,5 +80,26 @@ img{max-width:100%;display:block}
.ad-house{background:#fffaf3;border-style:solid;border-color:var(--accent2)}
table{width:100%;border-collapse:collapse;margin:1em 0;background:var(--bg2);border:1px solid var(--rule);border-radius:8px;overflow:hidden}
-th,td{padding:.6em .9em;border-bottom:1px solid var(--rule);text-align:left;font-size:.9em}
+th,td{padding:.6em .9em;border-bottom:1px solid var(--rule);text-align:left;font-size:.9em;vertical-align:top}
th{background:#f3efe6;font-weight:600}
+table[data-sortable] th:hover{background:#ebe6d8}
+.biz-table td a{display:inline-block}
+.biz-table tbody tr:hover{background:#f7f4ec}
+
+/* Geolocation banner + near-me grid */
+.geo-banner{display:inline-flex;align-items:center;gap:10px;padding:10px 14px;background:var(--bg2);border:1px solid var(--rule);border-radius:999px;font-size:.95em;color:var(--ink);margin:14px 0}
+.geo-banner .geo-pin{font-size:1.1em}
+.geo-banner small{color:var(--muted);font-size:.85em}
+.geo-banner .geo-change{margin-left:auto;background:transparent;border:0;color:#2b6cb0;cursor:pointer;font-size:.85em;text-decoration:underline;padding:2px 6px;font-family:inherit}
+.near-me{margin:32px 0}
+.near-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:14px}
+.near-card{display:block;padding:14px;background:var(--bg2);border:1px solid var(--rule);border-radius:8px;text-decoration:none;color:var(--ink);transition:transform .15s,border-color .15s}
+.near-card:hover{transform:translateY(-2px);border-color:#888}
+.near-card strong{display:block;font-size:1em;margin-bottom:4px}
+.near-card small{color:var(--muted);font-size:.85em}
+.empty{color:var(--muted);font-size:.9em;font-style:italic;padding:20px;text-align:center}
+@media (max-width:640px){
+ .geo-banner{display:flex;width:100%;border-radius:8px}
+ .near-grid{grid-template-columns:1fr 1fr;gap:10px}
+ .near-card{padding:10px}
+}
diff --git a/public/js/geo.js b/public/js/geo.js
new file mode 100644
index 0000000..06a43db
--- /dev/null
+++ b/public/js/geo.js
@@ -0,0 +1,167 @@
+// AnimalsDirectory — geolocation onload + "Near me" filter.
+// Strategy:
+// 1. Try cached coords from localStorage (24h TTL) — instant.
+// 2. Try navigator.geolocation.getCurrentPosition — high accuracy if user allows.
+// 3. Fall back to ipapi.co free tier (no key, 1k req/day) for a city-level estimate.
+// 4. Expose the result via window.userGeo and dispatch a 'geo:ready' event.
+//
+// Pages with #geo-banner / #near-me-list will auto-update.
+
+(function () {
+ const CACHE_KEY = 'animals.geo.v1';
+ const TTL_MS = 24 * 60 * 60 * 1000;
+
+ function readCache() {
+ try {
+ const raw = localStorage.getItem(CACHE_KEY);
+ if (!raw) return null;
+ const j = JSON.parse(raw);
+ if (Date.now() - j.t > TTL_MS) return null;
+ return j;
+ } catch { return null; }
+ }
+ function writeCache(geo) {
+ try { localStorage.setItem(CACHE_KEY, JSON.stringify({ ...geo, t: Date.now() })); } catch {}
+ }
+
+ function setReady(geo) {
+ window.userGeo = geo;
+ writeCache(geo);
+ document.dispatchEvent(new CustomEvent('geo:ready', { detail: geo }));
+ renderBanner(geo);
+ fetchNearMe(geo);
+ }
+
+ function renderBanner(geo) {
+ const el = document.getElementById('geo-banner');
+ if (!el) return;
+ const where = geo.city ? `${geo.city}${geo.state ? ', ' + geo.state : ''}` : `${geo.lat.toFixed(2)}, ${geo.lng.toFixed(2)}`;
+ el.innerHTML =
+ `<span class="geo-pin">📍</span> ` +
+ `<span>Showing results near <strong>${where}</strong> ` +
+ `<small>(${geo.source})</small></span> ` +
+ `<button type="button" class="geo-change">change</button>`;
+ el.querySelector('.geo-change').addEventListener('click', promptManualLocation);
+ }
+
+ async function fetchNearMe(geo) {
+ const list = document.getElementById('near-me-list');
+ if (!list) return;
+ try {
+ const r = await fetch(`/api/near-me?lat=${geo.lat}&lng=${geo.lng}&radius_mi=25`);
+ if (!r.ok) throw new Error('HTTP ' + r.status);
+ const j = await r.json();
+ if (!j.results || j.results.length === 0) {
+ list.innerHTML = '<p class="empty">No nearby listings within 25 miles. Try widening the radius.</p>';
+ return;
+ }
+ list.innerHTML = j.results.map(b =>
+ `<a class="near-card" href="/clinic/${b.id}">
+ <strong>${escapeHtml(b.name)}</strong>
+ <small>${escapeHtml(b.category.replace(/_/g, ' '))} · ${b.distance_mi.toFixed(1)} mi · ${escapeHtml(b.city || '')}${b.state ? ', ' + escapeHtml(b.state) : ''}</small>
+ </a>`
+ ).join('');
+ } catch (e) {
+ list.innerHTML = '<p class="empty">Could not load nearby listings.</p>';
+ console.warn('near-me fetch failed', e);
+ }
+ }
+
+ function escapeHtml(s) {
+ return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c]));
+ }
+
+ function tryBrowserGeolocation() {
+ return new Promise((resolve) => {
+ if (!navigator.geolocation) return resolve(null);
+ navigator.geolocation.getCurrentPosition(
+ (pos) => resolve({
+ lat: pos.coords.latitude, lng: pos.coords.longitude,
+ accuracy: pos.coords.accuracy, source: 'gps'
+ }),
+ () => resolve(null),
+ { enableHighAccuracy: true, timeout: 7000, maximumAge: 60000 }
+ );
+ });
+ }
+
+ async function tryIpFallback() {
+ try {
+ const r = await fetch('https://ipapi.co/json/', { headers: { accept: 'application/json' } });
+ if (!r.ok) return null;
+ const j = await r.json();
+ if (!j.latitude || !j.longitude) return null;
+ return {
+ lat: parseFloat(j.latitude), lng: parseFloat(j.longitude),
+ city: j.city, state: j.region_code, country: j.country, source: 'ip'
+ };
+ } catch { return null; }
+ }
+
+ async function reverseGeocode(geo) {
+ if (geo.city) return geo;
+ try {
+ // Nominatim free, polite UA required; rate-limited 1 req/sec
+ const r = await fetch(`https://nominatim.openstreetmap.org/reverse?format=json&lat=${geo.lat}&lon=${geo.lng}&zoom=10`, {
+ headers: { 'accept': 'application/json' }
+ });
+ if (!r.ok) return geo;
+ const j = await r.json();
+ const a = j.address || {};
+ return { ...geo,
+ city: a.city || a.town || a.village || a.hamlet || a.county,
+ state: (a.state_code || a.state || '').slice(0, 2).toUpperCase()
+ };
+ } catch { return geo; }
+ }
+
+ function promptManualLocation() {
+ const v = prompt('Enter a city or ZIP (e.g., "Los Angeles, CA" or "10001"):');
+ if (!v) return;
+ fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(v + ', USA')}&limit=1`)
+ .then(r => r.json())
+ .then(arr => {
+ if (!arr || !arr.length) return alert('Could not find that location.');
+ const hit = arr[0];
+ const parts = (hit.display_name || '').split(',').map(s => s.trim());
+ setReady({
+ lat: parseFloat(hit.lat), lng: parseFloat(hit.lon),
+ city: parts[0], state: (parts[parts.length - 2] || '').slice(0, 2).toUpperCase(),
+ source: 'manual'
+ });
+ });
+ }
+
+ async function init() {
+ // Cached?
+ const cached = readCache();
+ if (cached) { setReady(cached); return; }
+
+ // Try GPS (asks user permission)
+ const gps = await tryBrowserGeolocation();
+ if (gps) { setReady(await reverseGeocode(gps)); return; }
+
+ // IP fallback
+ const ip = await tryIpFallback();
+ if (ip) { setReady(ip); return; }
+
+ // Nothing worked — prompt manual
+ const el = document.getElementById('geo-banner');
+ if (el) {
+ el.innerHTML = `<span class="geo-pin">📍</span> ` +
+ `<span>Set your location to see nearby vets, groomers, shelters.</span> ` +
+ `<button type="button" class="geo-change">set location</button>`;
+ el.querySelector('.geo-change').addEventListener('click', promptManualLocation);
+ }
+ }
+
+ // Auto-init when DOM is ready
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', init);
+ } else {
+ init();
+ }
+
+ // Public API
+ window.AnimalsGeo = { promptManualLocation, init };
+})();
diff --git a/public/js/sort-table.js b/public/js/sort-table.js
new file mode 100644
index 0000000..910694a
--- /dev/null
+++ b/public/js/sort-table.js
@@ -0,0 +1,67 @@
+// Tiny dep-free sortable table. Add data-sortable to a <table>.
+// Click a <th> to sort by that column. Click again to reverse.
+// Numeric vs. string detected from the column's data; null/empty sort last.
+(function() {
+ 'use strict';
+ function detectKind(cells) {
+ let n = 0, total = 0;
+ for (const v of cells) {
+ const t = (v ?? '').toString().trim();
+ if (!t) continue;
+ total++;
+ if (/^-?\$?\d[\d,.]*\d*%?$/.test(t)) n++;
+ }
+ return total > 0 && n / total >= 0.7 ? 'num' : 'str';
+ }
+ function toNum(s) {
+ const cleaned = (s ?? '').toString().replace(/[$,%\s]/g, '');
+ const n = parseFloat(cleaned);
+ return isNaN(n) ? null : n;
+ }
+ function init(table) {
+ if (table.dataset.sortableInit) return;
+ table.dataset.sortableInit = '1';
+ const ths = table.querySelectorAll('thead th');
+ const tbody = table.querySelector('tbody');
+ if (!ths.length || !tbody) return;
+ ths.forEach((th, idx) => {
+ th.style.cursor = 'pointer';
+ th.style.userSelect = 'none';
+ const arrow = document.createElement('span');
+ arrow.className = 'sort-arrow';
+ arrow.style.cssText = 'opacity:.4;margin-left:.4em;font-size:.85em';
+ arrow.textContent = '↕';
+ th.appendChild(arrow);
+ th.addEventListener('click', () => {
+ const rows = Array.from(tbody.querySelectorAll('tr'));
+ const cells = rows.map(r => (r.children[idx]?.textContent || '').trim());
+ const kind = th.dataset.sort || detectKind(cells);
+ const cur = th.dataset.dir === 'asc' ? 'desc' : 'asc';
+ ths.forEach(o => { o.dataset.dir = ''; const a = o.querySelector('.sort-arrow'); if (a) { a.textContent = '↕'; a.style.opacity = .4; } });
+ th.dataset.dir = cur;
+ arrow.textContent = cur === 'asc' ? '▲' : '▼';
+ arrow.style.opacity = 1;
+ rows.sort((a, b) => {
+ let av = (a.children[idx]?.textContent || '').trim();
+ let bv = (b.children[idx]?.textContent || '').trim();
+ if (kind === 'num') {
+ const na = toNum(av), nb = toNum(bv);
+ if (na === null && nb === null) return 0;
+ if (na === null) return 1; // empties sort last
+ if (nb === null) return -1;
+ return cur === 'asc' ? na - nb : nb - na;
+ }
+ av = av.toLowerCase(); bv = bv.toLowerCase();
+ if (!av && !bv) return 0;
+ if (!av) return 1;
+ if (!bv) return -1;
+ return cur === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av);
+ });
+ rows.forEach(r => tbody.appendChild(r));
+ });
+ });
+ }
+ function bootstrap() { document.querySelectorAll('table[data-sortable]').forEach(init); }
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bootstrap);
+ else bootstrap();
+})();
diff --git a/src/enrich/extract_emails.js b/src/enrich/extract_emails.js
new file mode 100644
index 0000000..73eea4c
--- /dev/null
+++ b/src/enrich/extract_emails.js
@@ -0,0 +1,236 @@
+#!/usr/bin/env node
+// Email harvester — emails are the platform's most valuable contact field.
+// Without an email we can't run leads (Rail B) or the upgrade pitch (Rail C).
+//
+// For each business with a website:
+// 1. Fetch home + a few high-signal contact-style URLs.
+// 2. Extract mailto: links (highest confidence) + plaintext email regex.
+// 3. Decode common obfuscations (name [at] domain [dot] com, @ entities).
+// 4. Drop junk (sentry, example, your-email, image cdns).
+// 5. Classify each as role / personal / staff / intake / admin.
+// 6. Insert into business_emails (provenance preserved).
+// 7. Backfill businesses.email with the best primary-looking address.
+//
+// Run:
+// node src/enrich/extract_emails.js # all businesses w/ website
+// node src/enrich/extract_emails.js --limit 20
+// node src/enrich/extract_emails.js --biz 5
+// node src/enrich/extract_emails.js --refresh-days 30 # re-check older than N days
+
+import 'dotenv/config';
+import { fetch as undiciFetch } from 'undici';
+import * as cheerio from 'cheerio';
+import { pool, many, one } from '../lib/db.js';
+import { log } from '../lib/log.js';
+
+// constants used by helpers below — defined up here to avoid TDZ when the
+// async for-loop calls them before module-level evaluation finishes.
+const ROLE_PREFIXES = ['info','contact','hello','hi','admin','office','reception','frontdesk','front-desk','team','support','help','appointments','book','booking','schedule','intake','newpatient','new-patient','newpatients','clinic'];
+
+const args = process.argv.slice(2);
+const limit = parseInt(args[args.indexOf('--limit') + 1], 10) || 200;
+const onlyBiz = parseInt(args[args.indexOf('--biz') + 1], 10) || null;
+const refreshDays = parseInt(args[args.indexOf('--refresh-days') + 1], 10) || 0;
+
+// 4 high-signal pages — past these the marginal hit rate drops sharply.
+const CONTACT_PATHS = ['/', '/contact', '/contact-us', '/about'];
+const FETCH_TIMEOUT_MS = 6000;
+const CONCURRENCY = 8;
+
+const UA = 'Mozilla/5.0 (compatible; AnimalsDirectoryEmailBot/1.0; +https://animalsdirectory.com/bot)';
+const REQ_OPTS = { headers: { 'user-agent': UA, accept: 'text/html,*/*' }, redirect: 'follow' };
+
+const where = onlyBiz
+ ? `id = ${onlyBiz}`
+ : refreshDays
+ ? `website IS NOT NULL AND website <> '' AND (email_search_at IS NULL OR email_search_at < NOW() - INTERVAL '${refreshDays} days')`
+ : `website IS NOT NULL AND website <> '' AND email_search_at IS NULL`;
+const targets = await many(`SELECT id, name, website FROM businesses WHERE ${where} ORDER BY id LIMIT ${limit}`);
+log.info(`harvesting emails from ${targets.length} businesses (concurrency=${CONCURRENCY})`);
+
+// Worker-pool: run N at once, each owns its own (host) so politeness is preserved.
+let cursor = 0;
+let processed = 0, found = 0;
+async function worker() {
+ while (true) {
+ const i = cursor++;
+ if (i >= targets.length) return;
+ const biz = targets[i];
+ try {
+ const n = await harvestOne(biz);
+ if (n) found += n;
+ } catch (err) { log.error('harvest failed', { biz: biz.id, err: err.message }); }
+ processed++;
+ if (processed % 25 === 0) log.info(`progress ${processed}/${targets.length} (${found} emails so far)`);
+ }
+}
+await Promise.all(Array.from({length: CONCURRENCY}, worker));
+await pool.end();
+log.info(`done — processed ${processed} businesses, found ${found} emails`);
+
+async function harvestOne(biz) {
+ const baseUrl = normalizeUrl(biz.website);
+ if (!baseUrl) return;
+ const found = new Map(); // email → {kind, context, source_url, method}
+ let pagesTried = 0;
+ for (const p of CONTACT_PATHS) {
+ if (found.size >= 5) break;
+ const url = absoluteUrl(baseUrl, p);
+ if (!url) continue;
+ let html;
+ try {
+ const r = await undiciFetch(url, { ...REQ_OPTS, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
+ pagesTried++;
+ if (!r.ok) continue;
+ const ct = r.headers.get('content-type') || '';
+ if (!/text\/html/i.test(ct)) continue;
+ // cap response size at 1MB; vet sites bigger than that are usually full SPAs that won't have emails inline
+ const buf = Buffer.from(await r.arrayBuffer());
+ html = buf.slice(0, 1024 * 1024).toString('utf8');
+ } catch { continue; }
+ extractFromHtml(html, url, found);
+ await sleep(150); // small intra-host pause
+ }
+
+ // Persist
+ const emails = [...found.entries()]
+ .filter(([e]) => isPlausibleEmail(e))
+ .filter(([e]) => !looksLikeJunk(e));
+ for (const [email, meta] of emails) {
+ await pool.query(
+ `INSERT INTO business_emails (business_id, email, email_kind, context_label, source_url, source_method, verification_status)
+ VALUES ($1,$2,$3,$4,$5,$6,'syntactically_valid')
+ ON CONFLICT (business_id, LOWER(email), source_url) DO NOTHING`,
+ [biz.id, email, meta.kind, meta.context, meta.source_url, meta.method]
+ );
+ }
+
+ // Pick best primary + backfill businesses.email
+ const primary = pickPrimary(emails);
+ await pool.query(
+ `UPDATE businesses
+ SET email_search_at = NOW(),
+ email_search_pages_tried = $2,
+ email = COALESCE(email, $3)
+ WHERE id = $1`,
+ [biz.id, pagesTried, primary]
+ );
+ if (emails.length > 0) log.info(`harvested ${biz.name}`, { found: emails.length, primary });
+ return emails.length;
+}
+
+// ─── extraction helpers ────────────────────────────────────────────────────
+
+function extractFromHtml(html, sourceUrl, foundMap) {
+ // 1. mailto: links (cheerio for href + nearby context)
+ let $;
+ try { $ = cheerio.load(html); } catch { $ = null; }
+ if ($) {
+ $('a[href^="mailto:"]').each((_, el) => {
+ const raw = ($(el).attr('href') || '').replace(/^mailto:/i, '').split('?')[0].trim();
+ const emails = raw.split(/[,;]/).map(s => s.trim()).filter(Boolean);
+ const context = collapseWhitespace($(el).closest('li, p, div, td, h1, h2, h3, h4, span').first().text() || $(el).text() || '').slice(0, 160);
+ for (const e of emails) {
+ const lower = e.toLowerCase();
+ if (!foundMap.has(lower)) {
+ foundMap.set(lower, { kind: classifyEmail(lower, context), context, source_url: sourceUrl, method: 'mailto' });
+ }
+ }
+ });
+ // 2. JSON-LD
+ $('script[type="application/ld+json"]').each((_, el) => {
+ try {
+ const j = JSON.parse($(el).text());
+ const list = Array.isArray(j) ? j : [j];
+ for (const node of list) walkJsonLdForEmails(node, foundMap, sourceUrl);
+ } catch {}
+ });
+ }
+ // 3. plaintext regex (post-decode HTML entities + common obfuscations)
+ const text = decodeObfuscation(stripScripts(html));
+ const RE = /[a-z0-9][a-z0-9._%+\-]{0,63}@[a-z0-9](?:[a-z0-9\-]{0,62}\.)+[a-z]{2,24}/gi;
+ for (const m of text.matchAll(RE)) {
+ const lower = m[0].toLowerCase();
+ if (foundMap.has(lower)) continue;
+ foundMap.set(lower, { kind: classifyEmail(lower, ''), context: '', source_url: sourceUrl, method: 'plaintext' });
+ }
+}
+
+function walkJsonLdForEmails(node, foundMap, sourceUrl) {
+ if (!node || typeof node !== 'object') return;
+ if (typeof node.email === 'string') {
+ for (const e of node.email.split(/[,;]/)) {
+ const lower = e.trim().toLowerCase();
+ if (lower && !foundMap.has(lower)) {
+ foundMap.set(lower, { kind: classifyEmail(lower, node.name || ''), context: node.name || '', source_url: sourceUrl, method: 'jsonld' });
+ }
+ }
+ }
+ for (const v of Object.values(node)) {
+ if (Array.isArray(v)) v.forEach(x => walkJsonLdForEmails(x, foundMap, sourceUrl));
+ else if (v && typeof v === 'object') walkJsonLdForEmails(v, foundMap, sourceUrl);
+ }
+}
+
+function decodeObfuscation(s) {
+ return s
+ .replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n,10)))
+ .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCharCode(parseInt(n,16)))
+ .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, "'")
+ .replace(/\s*\(at\)\s*|\s*\[at\]\s*|\s+at\s+/gi, '@')
+ .replace(/\s*\(dot\)\s*|\s*\[dot\]\s*|\s+dot\s+/gi, '.');
+}
+
+function stripScripts(html) {
+ return html
+ .replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, ' ')
+ .replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, ' ');
+}
+function collapseWhitespace(s) { return (s || '').replace(/\s+/g, ' ').trim(); }
+
+function isPlausibleEmail(e) {
+ return /^[a-z0-9][a-z0-9._%+\-]{0,63}@[a-z0-9](?:[a-z0-9\-]{0,62}\.)+[a-z]{2,24}$/i.test(e) && e.length <= 254;
+}
+
+function looksLikeJunk(e) {
+ if (/(sentry\.io|wixpress|squarespace|godaddy|@example\.|@yourdomain|@your-)/i.test(e)) return true;
+ if (/(no-?reply|do-?not-?reply|postmaster|abuse|webmaster@)/i.test(e)) return true;
+ // image extensions caught by greedy regex
+ if (/\.(jpe?g|png|gif|webp|svg|ico|css|js)$/i.test(e)) return true;
+ // obvious placeholder patterns
+ if (/^(your(name|email)|user|name|info)@(example|domain|sample)\./i.test(e)) return true;
+ // Sentry-style hex prefixes
+ if (/^[a-f0-9]{16,}@/i.test(e)) return true;
+ return false;
+}
+
+function classifyEmail(email, context) {
+ const local = email.split('@')[0].toLowerCase();
+ if (ROLE_PREFIXES.some(p => local === p)) return 'role';
+ if (/^(intake|newpatient|appointments?|book(ing)?|schedule)/i.test(local)) return 'intake';
+ if (/^(admin|owner|office|manager|hr)/i.test(local)) return 'admin';
+ if (/^[a-z]+\.?[a-z]+$/.test(local)) return 'personal'; // jane.doe / janedoe / drjsmith
+ if (/dr[._-]?[a-z]+/i.test(local)) return 'personal';
+ if (context && /\b(dr\.?|doctor|owner|manager|director|dvm|vmd)\b/i.test(context)) return 'staff';
+ return 'unknown';
+}
+
+function pickPrimary(emails) {
+ if (!emails.length) return null;
+ const order = ['intake','role','admin','staff','personal','unknown'];
+ const scored = emails
+ .map(([e, m]) => ({ e, kind: m.kind, score: order.indexOf(m.kind) === -1 ? 99 : order.indexOf(m.kind) }))
+ .sort((a, b) => a.score - b.score);
+ return scored[0].e;
+}
+
+function normalizeUrl(u) {
+ try {
+ const parsed = new URL(u.startsWith('http') ? u : `https://${u}`);
+ return parsed.origin;
+ } catch { return null; }
+}
+function absoluteUrl(base, p) {
+ try { return new URL(p, base).href; } catch { return null; }
+}
+function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
diff --git a/src/ingest/nextdoor.js b/src/ingest/nextdoor.js
new file mode 100644
index 0000000..b520ce3
--- /dev/null
+++ b/src/ingest/nextdoor.js
@@ -0,0 +1,56 @@
+// Nextdoor ingestor — STUB (does not run by default).
+//
+// Honest status: Nextdoor has no public API and aggressively blocks
+// scrapers (Cloudflare bot-detection + login-required). Three free-ish
+// surfaces exist:
+//
+// 1. https://nextdoor.com/pages/<business-slug>/ — public business pages
+// that show name/address/phone/hours but rate-limit anonymous fetches
+// to a few per minute and 429 quickly. Useful for ENRICHING a known
+// business, not for discovery.
+//
+// 2. https://nextdoor.com/sitemap.xml — public sitemap of business
+// pages and neighborhood profile pages. Free to crawl politely.
+// → Best free starting point.
+//
+// 3. Logged-in Browserbase session — required for actual neighborhood
+// feed posts ("recommendations" threads). NOT free (Browserbase has
+// paid tiers; Nextdoor account also needed). Per Steve's "must be
+// free" constraint, not implemented here.
+//
+// Free fallback that actually delivers similar signal: r/Patch (Patch.com
+// city-by-city local-news + business mentions) and Yelp's free public
+// review excerpts. These are wired in `src/ingest/patch.js` and the
+// Yelp public-fetch path inside reddit.js's secondary pass.
+//
+// To activate the Browserbase route later:
+// - Add NEXTDOOR_EMAIL / NEXTDOOR_PASS to .env
+// - Set USE_BROWSERBASE=1
+// - Uncomment the Browserbase block below
+//
+// For now this file documents the architecture so a future run picks it up.
+
+import 'dotenv/config';
+import { log } from '../lib/log.js';
+
+const USE_BROWSERBASE = process.env.USE_BROWSERBASE === '1';
+
+async function ingestNextdoorSitemap() {
+ log.warn('Nextdoor sitemap ingest: not yet implemented (free path)');
+ log.info(' Plan: pull https://nextdoor.com/sitemap.xml, dedupe business URLs against businesses.website, polite crawl public pages with 5s delays, extract phone/hours via cheerio, log to raw_records as entity_type=nextdoor_business.');
+}
+
+async function ingestNextdoorBrowserbase() {
+ if (!USE_BROWSERBASE) {
+ log.warn('Nextdoor Browserbase ingest skipped: USE_BROWSERBASE != 1 (paid tier; not "free")');
+ return;
+ }
+ log.warn('Nextdoor Browserbase route: stub — add credentials and uncomment Browserbase block to enable.');
+}
+
+async function run() {
+ await ingestNextdoorSitemap();
+ await ingestNextdoorBrowserbase();
+}
+
+run().catch(e => { log.error('fatal', { err: e.message }); process.exit(1); });
diff --git a/src/ingest/osm_california.js b/src/ingest/osm_california.js
new file mode 100644
index 0000000..e3d2106
--- /dev/null
+++ b/src/ingest/osm_california.js
@@ -0,0 +1,184 @@
+#!/usr/bin/env node
+// OSM Overpass California bulk crawl — primary source for everything pet-related.
+//
+// One Overpass query returns every vet clinic, pet store, shelter, boarder,
+// breeder, and dog park in California. Free. No API key. Just be polite.
+//
+// Run:
+// node src/ingest/osm_california.js # all categories
+// node src/ingest/osm_california.js --only=veterinary,pet
+//
+// Maps OSM tags → our businesses.category:
+// amenity=veterinary → vet_clinic
+// shop=pet|pets → pet_store
+// amenity=animal_shelter → shelter
+// amenity=animal_boarding → boarding
+// amenity=animal_breeding → breeder
+// amenity=dog_park OR leisure=dog_park → dog_park
+
+import 'dotenv/config';
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { fetch as undiciFetch } from 'undici';
+import { pool } from '../lib/db.js';
+import { slugify } from '../lib/slug.js';
+import { log } from '../lib/log.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const RAW_DIR = path.resolve(__dirname, '..', '..', 'raw_html');
+fs.mkdirSync(RAW_DIR, { recursive: true });
+
+// Multiple endpoints for fallback — Overpass overload is real
+const ENDPOINTS = [
+ 'https://overpass-api.de/api/interpreter',
+ 'https://overpass.kumi.systems/api/interpreter',
+ 'https://overpass.openstreetmap.ru/api/interpreter',
+];
+
+const TAG_MAP = {
+ veterinary: 'vet_clinic',
+ pet: 'pet_store',
+ pets: 'pet_store',
+ animal_shelter: 'shelter',
+ animal_boarding: 'boarding',
+ animal_breeding: 'breeder',
+ dog_park: 'dog_park',
+};
+
+const args = process.argv.slice(2);
+const onlyArg = args.find(a => a.startsWith('--only='));
+const only = onlyArg ? onlyArg.split('=')[1].split(',') : null;
+
+const sourceId = (await pool.query(
+ `SELECT id FROM sources WHERE source_name = 'OpenStreetMap Overpass'`
+)).rows[0]?.id;
+if (!sourceId) {
+ log.error('source row missing — run seed:sources first');
+ process.exit(1);
+}
+
+const QUERY = `
+[out:json][timeout:600];
+area["ISO3166-2"="US-CA"][admin_level=4]->.ca;
+(
+ nwr["amenity"="veterinary"](area.ca);
+ nwr["shop"="pet"](area.ca);
+ nwr["shop"="pets"](area.ca);
+ nwr["amenity"="animal_shelter"](area.ca);
+ nwr["amenity"="animal_boarding"](area.ca);
+ nwr["amenity"="animal_breeding"](area.ca);
+ nwr["amenity"="dog_park"](area.ca);
+ nwr["leisure"="dog_park"](area.ca);
+);
+out center tags;
+`.trim();
+
+let data = null;
+for (const ep of ENDPOINTS) {
+ try {
+ log.info(`querying ${ep}`);
+ const r = await undiciFetch(ep, {
+ method: 'POST',
+ headers: {
+ 'content-type': 'application/x-www-form-urlencoded',
+ 'user-agent': 'AnimalsDirectoryCABot/1.0 (+https://animalsdirectory.com/bot)'
+ },
+ body: 'data=' + encodeURIComponent(QUERY),
+ signal: AbortSignal.timeout(180000),
+ });
+ if (!r.ok) { log.warn(`endpoint ${ep} → HTTP ${r.status}`); continue; }
+ data = await r.json();
+ log.info(`got ${data.elements?.length || 0} elements from ${ep}`);
+ break;
+ } catch (err) {
+ log.warn(`endpoint ${ep} → ${err.message}`);
+ }
+}
+if (!data) { log.error('all overpass endpoints failed'); process.exit(1); }
+
+// Save raw response for compliance
+const rawPath = path.join(RAW_DIR, `osm-california-${new Date().toISOString().slice(0,10)}.json`);
+fs.writeFileSync(rawPath, JSON.stringify(data, null, 0));
+
+let kept = 0, skipped = 0;
+const bySlug = new Set();
+
+for (const el of data.elements || []) {
+ const tags = el.tags || {};
+ let cat = null;
+ if (only) {
+ // skip if user filtered
+ const tagKey = tags.amenity || tags.shop || tags.leisure;
+ if (!only.includes(tagKey)) { skipped++; continue; }
+ }
+ if (tags.amenity === 'veterinary') cat = 'vet_clinic';
+ else if (tags.shop === 'pet' || tags.shop === 'pets') cat = 'pet_store';
+ else if (tags.amenity === 'animal_shelter') cat = 'shelter';
+ else if (tags.amenity === 'animal_boarding') cat = 'boarding';
+ else if (tags.amenity === 'animal_breeding') cat = 'breeder';
+ else if (tags.amenity === 'dog_park' || tags.leisure === 'dog_park') cat = 'dog_park';
+ else { skipped++; continue; }
+
+ const name = (tags.name || tags['name:en'] || '').trim();
+ if (!name) { skipped++; continue; } // unnamed nodes are noise
+
+ const lat = el.lat ?? el.center?.lat;
+ const lng = el.lon ?? el.center?.lon;
+ const addr = [tags['addr:housenumber'], tags['addr:street']].filter(Boolean).join(' ').trim() || null;
+ const city = tags['addr:city'] || null;
+ const state = (tags['addr:state'] || 'CA').toUpperCase();
+ const zip = tags['addr:postcode'] || null;
+ const phone = tags.phone || tags['contact:phone'] || null;
+ const website = tags.website || tags['contact:website'] || tags.url || null;
+ const email = tags.email || tags['contact:email'] || null;
+ const opening_hours = tags.opening_hours || null;
+
+ let slug = slugify(`${name}-${city || 'ca'}-${state}`);
+ // dedupe within this run if the same business appears as both a node and a way
+ if (bySlug.has(`${cat}/${slug}`)) { skipped++; continue; }
+ bySlug.add(`${cat}/${slug}`);
+
+ try {
+ const r = await pool.query(`
+ INSERT INTO businesses
+ (category, name, slug, address, city, state, zip, latitude, longitude,
+ phone, email, website, source_url, source_id, hours_json)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
+ ON CONFLICT (category, slug) DO UPDATE SET
+ latitude = COALESCE(businesses.latitude, EXCLUDED.latitude),
+ longitude = COALESCE(businesses.longitude, EXCLUDED.longitude),
+ phone = COALESCE(businesses.phone, EXCLUDED.phone),
+ email = COALESCE(businesses.email, EXCLUDED.email),
+ website = COALESCE(businesses.website, EXCLUDED.website),
+ address = COALESCE(businesses.address, EXCLUDED.address),
+ city = COALESCE(businesses.city, EXCLUDED.city),
+ zip = COALESCE(businesses.zip, EXCLUDED.zip),
+ hours_json = COALESCE(businesses.hours_json, EXCLUDED.hours_json),
+ updated_at = NOW()
+ RETURNING (xmax = 0) AS inserted`,
+ [cat, name, slug, addr, city, state, zip, lat, lng,
+ phone, email, website,
+ `https://www.openstreetmap.org/${el.type}/${el.id}`,
+ sourceId,
+ opening_hours ? JSON.stringify({ raw: opening_hours }) : null]
+ );
+ if (r.rows[0]?.inserted) kept++;
+ } catch (err) {
+ log.warn(`insert failed for ${name}`, { err: err.message });
+ skipped++;
+ }
+}
+
+log.info(`OSM CA ingest done: ${kept} new, ${skipped} skipped, raw saved → ${rawPath}`);
+
+// Summary by category
+const counts = await pool.query(`
+ SELECT category, COUNT(*)::int AS n
+ FROM businesses
+ WHERE state = 'CA'
+ GROUP BY category ORDER BY n DESC`);
+console.log('\nCalifornia inventory:');
+for (const row of counts.rows) console.log(` ${row.category.padEnd(20)} ${row.n}`);
+
+await pool.end();
diff --git a/src/ingest/reddit.js b/src/ingest/reddit.js
new file mode 100644
index 0000000..2731dcc
--- /dev/null
+++ b/src/ingest/reddit.js
@@ -0,0 +1,168 @@
+// Reddit ingestor — 100% free, no API key.
+// Uses Reddit's public .json endpoints (rate-limited to ~60 req/min anonymous).
+// Pipeline:
+// 1. For each subreddit (city subs + pet care subs), fetch latest N posts as JSON
+// 2. Triage with gemma3:4b: "is this post about a pet-care business or service?"
+// 3. Extract with qwen3:14b: business names, services, city/state, sentiment
+// 4. Insert raw_records (idempotent on hash) and upsert mentions into businesses
+//
+// Run:
+// node src/ingest/reddit.js # default subreddit set
+// SUBREDDITS=LosAngeles,nyc node src/ingest/reddit.js
+// LIMIT=25 node src/ingest/reddit.js # smaller per-sub fetch
+//
+// Cron via launchd: ~/Library/LaunchAgents/com.steve.animals-reddit-weekly.plist
+
+import 'dotenv/config';
+import crypto from 'node:crypto';
+import { pool, query, one } from '../lib/db.js';
+import { log } from '../lib/log.js';
+import { triageYesNo, extractJson } from '../lib/llm.js';
+
+const UA = 'animals-directory-research/0.1 (contact: steve@designerwallcoverings.com)';
+
+const PET_KEYWORDS = /\b(vet|veterinarian|veterinary|animal hospital|emergency vet|pet|dog|cat|puppy|kitten|groomer|grooming|boarding|kennel|trainer|training|shelter|rescue|adopt|breeder|pet store|pet sitter|dog walker|daycare)\b/i;
+
+// Default subreddits — top US metros + pet-care subs.
+const DEFAULT_SUBREDDITS = [
+ // Pet-care subs (recommendations come up here daily)
+ 'AskVet', 'dogs', 'cats', 'Pets', 'Petloss', 'puppy101', 'CatAdvice',
+ // Top metro city subs
+ 'LosAngeles', 'AskLosAngeles', 'sandiego', 'sanfrancisco', 'BayArea',
+ 'nyc', 'AskNYC', 'longisland',
+ 'chicago', 'houston', 'dallas', 'austin', 'seattle', 'Portland', 'Denver',
+ 'atlanta', 'Boston', 'philadelphia', 'phoenix', 'Miami', 'StPetersburgFL',
+ 'Minneapolis', 'StLouis', 'kansascity', 'Charlotte', 'nashville',
+ 'baltimore', 'WashingtonDC', 'pittsburgh', 'cleveland', 'detroit',
+ 'Cincinnati', 'columbus', 'indianapolis', 'milwaukee', 'okc', 'lasvegas',
+ 'Sacramento', 'orlando', 'Tampa', 'Jacksonville', 'NewOrleans',
+];
+
+const SUBREDDITS = (process.env.SUBREDDITS || DEFAULT_SUBREDDITS.join(','))
+ .split(',').map(s => s.trim()).filter(Boolean);
+const LIMIT = parseInt(process.env.LIMIT || '50', 10);
+const SLEEP_MS = parseInt(process.env.SLEEP_MS || '1100', 10); // ~55 req/min
+const DRY = process.env.DRY === '1';
+
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
+
+async function ensureSource() {
+ const existing = await one(`SELECT id FROM sources WHERE source_name = 'reddit'`);
+ if (existing) return existing.id;
+ const r = await one(
+ `INSERT INTO sources (source_name, source_type, base_url, terms_notes, allowed_method, rate_limit_rps, robots_txt_url, last_checked_at)
+ VALUES ('reddit', 'other', 'https://www.reddit.com', 'Public .json endpoints; ~60 req/min anonymous; UGC under reddit.com/help/contentpolicy. Used as discovery/enrichment signal, not as the primary record.', 'crawl', 1.0, 'https://www.reddit.com/robots.txt', NOW())
+ RETURNING id`);
+ log.info('created sources row', { source: 'reddit', id: r.id });
+ return r.id;
+}
+
+async function fetchSub(sub, limit) {
+ const url = `https://www.reddit.com/r/${encodeURIComponent(sub)}/new.json?limit=${limit}&raw_json=1`;
+ const r = await fetch(url, { headers: { 'user-agent': UA, 'accept': 'application/json' } });
+ if (r.status === 429) { await sleep(5000); return fetchSub(sub, limit); }
+ if (!r.ok) { log.warn(`reddit ${sub} ${r.status}`); return []; }
+ const j = await r.json();
+ return (j?.data?.children || []).map(c => c.data);
+}
+
+// LLM helpers — local Ollama (free, no quota)
+async function isPetCareRelevant(post) {
+ const text = `${post.title}\n\n${post.selftext || ''}`.slice(0, 4000);
+ // Cheap regex prefilter — kills 80% of noise without an LLM call
+ if (!PET_KEYWORDS.test(text)) return false;
+ // Triage with gemma3:4b — has the post any actionable info about a pet-care business?
+ return triageYesNo(
+ 'Does this post mention or ask about a specific pet-care business, vet, groomer, trainer, shelter, breeder, boarding facility, or pet store? It must reference a real provider, not just abstract advice.',
+ text
+ );
+}
+
+async function extractMentions(post) {
+ const text = `Title: ${post.title}\n\nBody: ${post.selftext || ''}`;
+ return extractJson(
+ 'You extract mentions of real-world pet-care businesses from Reddit posts. ' +
+ 'Only return businesses the author or commenter has personally used or is asking about. ' +
+ 'Skip generic advice. Skip national chains unless a specific branch (city) is named. ' +
+ 'Use sentiment values: "positive" | "negative" | "neutral" | "asking" (asking for recs).',
+ text,
+ `{
+ "mentions": [
+ {
+ "business_name": "string",
+ "service_type": "vet_clinic | emergency_vet | groomer | trainer | boarding | daycare | pet_store | shelter | breeder | other",
+ "city": "string or null",
+ "state": "2-letter US state or null",
+ "sentiment": "positive | negative | neutral | asking",
+ "context": "one short sentence summary"
+ }
+ ]
+}`
+ );
+}
+
+async function ingestPost(post, subId, sub) {
+ const sourceUrl = `https://www.reddit.com${post.permalink}`;
+ const hash = sha256(sourceUrl);
+
+ // Idempotency check
+ const existing = await one(`SELECT id FROM raw_records WHERE hash = $1`, [hash]);
+ if (existing) return { skipped: true };
+
+ if (!await isPetCareRelevant(post)) {
+ if (DRY) log.debug('not relevant', { sub, title: post.title.slice(0, 60) });
+ return { not_relevant: true };
+ }
+
+ const mentions = await extractMentions(post);
+ if (!mentions || !Array.isArray(mentions.mentions) || mentions.mentions.length === 0) {
+ return { no_mentions: true };
+ }
+
+ if (DRY) {
+ log.info('DRY mention', { sub, title: post.title.slice(0, 80), mentions: mentions.mentions });
+ return { dry: true };
+ }
+
+ await query(
+ `INSERT INTO raw_records (source_id, source_url, entity_type, entity_id, raw_json, http_status, hash)
+ VALUES ($1, $2, 'reddit_post', $3, $4, 200, $5)`,
+ [subId, sourceUrl, String(post.id), JSON.stringify({ post, mentions: mentions.mentions, sub }), hash]
+ );
+ log.info('ingested post', {
+ sub, title: post.title.slice(0, 80),
+ mention_count: mentions.mentions.length,
+ sentiments: mentions.mentions.map(m => m.sentiment).join(',')
+ });
+ return { ingested: true, mention_count: mentions.mentions.length };
+}
+
+async function run() {
+ const sourceId = await ensureSource();
+ const stats = { subs: 0, posts: 0, ingested: 0, mentions: 0, skipped: 0, not_relevant: 0, no_mentions: 0, errors: 0 };
+ for (const sub of SUBREDDITS) {
+ stats.subs++;
+ log.info('fetching subreddit', { sub, limit: LIMIT });
+ const posts = await fetchSub(sub, LIMIT);
+ stats.posts += posts.length;
+ for (const post of posts) {
+ try {
+ const r = await ingestPost(post, sourceId, sub);
+ if (r.ingested) { stats.ingested++; stats.mentions += r.mention_count; }
+ else if (r.skipped) stats.skipped++;
+ else if (r.not_relevant) stats.not_relevant++;
+ else if (r.no_mentions) stats.no_mentions++;
+ } catch (e) {
+ stats.errors++;
+ log.error('ingest failed', { sub, post_id: post.id, err: e.message });
+ }
+ await sleep(50); // small breath between LLM calls; rate-limit is on reddit fetch
+ }
+ await sleep(SLEEP_MS); // reddit rate-limit
+ }
+ log.info('reddit ingest complete', stats);
+ await pool.end();
+}
+
+run().catch(e => { log.error('fatal', { err: e.message, stack: e.stack }); process.exit(1); });
diff --git a/src/lib/llm.js b/src/lib/llm.js
new file mode 100644
index 0000000..c392180
--- /dev/null
+++ b/src/lib/llm.js
@@ -0,0 +1,59 @@
+// Local Ollama wrapper. 100% free, runs against http://localhost:11434.
+//
+// IMPORTANT — single-model default: Ollama on Mac Studio 2 only fits ONE 12-14B
+// model in GPU memory at a time. If TRIAGE and EXTRACT use different models,
+// every post pays a 30-60s model-swap tax. Default both to qwen3:14b (already
+// the project standard per memory) so the model warms once and stays warm.
+//
+// Override per-call via env if you have headroom for two models loaded at once:
+// LLM_TRIAGE=gemma3:4b LLM_EXTRACT=qwen3:14b npm run ingest:reddit
+
+const OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://localhost:11434';
+const TRIAGE_MODEL = process.env.LLM_TRIAGE || 'qwen3:14b';
+const EXTRACT_MODEL = process.env.LLM_EXTRACT || 'qwen3:14b';
+const KEEP_ALIVE = process.env.LLM_KEEPALIVE || '15m';
+
+async function callOllama(model, prompt, opts = {}) {
+ const r = await fetch(`${OLLAMA_HOST}/api/generate`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ model, prompt, stream: false,
+ keep_alive: KEEP_ALIVE, // hold the model in GPU between calls
+ options: { temperature: opts.temperature ?? 0.2, num_predict: opts.num_predict ?? 1024 },
+ format: opts.json ? 'json' : undefined,
+ }),
+ });
+ if (!r.ok) throw new Error(`ollama ${model} ${r.status}: ${await r.text()}`);
+ const j = await r.json();
+ return j.response;
+}
+
+// Triage: ask gemma3:4b a yes/no question. Returns boolean.
+export async function triageYesNo(question, text) {
+ const prompt =
+ `You are a fast triage classifier. Answer with one word: YES or NO.\n` +
+ `Question: ${question}\n` +
+ `Text:\n"""${text.slice(0, 4000)}"""\n` +
+ `Answer:`;
+ const out = (await callOllama(TRIAGE_MODEL, prompt, { temperature: 0, num_predict: 5 })).trim().toUpperCase();
+ return out.startsWith('Y');
+}
+
+// Extract: ask qwen3:14b for structured JSON. Returns parsed object or null.
+export async function extractJson(systemPrompt, text, schemaHint) {
+ const prompt =
+ `${systemPrompt}\n\n` +
+ `Output valid JSON only, matching this shape:\n${schemaHint}\n\n` +
+ `Input text:\n"""${text.slice(0, 8000)}"""\n\n` +
+ `JSON output:`;
+ const raw = await callOllama(EXTRACT_MODEL, prompt, { temperature: 0.1, num_predict: 1500, json: true });
+ try { return JSON.parse(raw); }
+ catch {
+ // qwen sometimes wraps in code fence; strip and retry
+ const stripped = raw.replace(/^```(?:json)?/i, '').replace(/```$/, '').trim();
+ try { return JSON.parse(stripped); } catch { return null; }
+ }
+}
+
+export const MODELS = { TRIAGE_MODEL, EXTRACT_MODEL };
diff --git a/src/server/index.js b/src/server/index.js
index ef1cb14..19dc6fb 100644
--- a/src/server/index.js
+++ b/src/server/index.js
@@ -45,6 +45,45 @@ const CAT_TO_DB = {
app.get('/healthz', (_req, res) => res.json({ ok: true, ts: Date.now() }));
+// Near-me — used by /static/js/geo.js after browser geolocation resolves.
+// Haversine in SQL with a bbox prefilter so we don't trig over every business.
+app.get('/api/near-me', async (req, res) => {
+ const lat = parseFloat(req.query.lat);
+ const lng = parseFloat(req.query.lng);
+ const radiusMi = Math.min(100, Math.max(1, parseFloat(req.query.radius_mi || '25')));
+ if (!Number.isFinite(lat) || !Number.isFinite(lng)) {
+ return res.status(400).json({ error: 'lat and lng required' });
+ }
+ const dLat = radiusMi / 69.0;
+ const dLng = radiusMi / (69.0 * Math.cos(lat * Math.PI / 180));
+ const sql = `
+ WITH params AS (
+ SELECT $1::float8 AS lat, $2::float8 AS lng, $3::float8 AS dlat, $4::float8 AS dlng
+ ), bbox AS (
+ SELECT b.* FROM businesses b, params p
+ WHERE b.opt_out_flag = FALSE
+ AND b.latitude BETWEEN p.lat - p.dlat AND p.lat + p.dlat
+ AND b.longitude BETWEEN p.lng - p.dlng AND p.lng + p.dlng
+ )
+ SELECT b.id, b.name, b.category, b.city, b.state, b.latitude, b.longitude,
+ 3958.8 * 2 * asin(sqrt(
+ power(sin(radians((b.latitude - p.lat) / 2)), 2) +
+ cos(radians(p.lat)) * cos(radians(b.latitude)) *
+ power(sin(radians((b.longitude - p.lng) / 2)), 2)
+ )) AS distance_mi
+ FROM bbox b, params p
+ ORDER BY distance_mi ASC
+ LIMIT 24
+ `;
+ try {
+ const rows = (await many(sql, [lat, lng, dLat, dLng])).filter(r => r.distance_mi <= radiusMi);
+ res.json({ lat, lng, radius_mi: radiusMi, count: rows.length, results: rows });
+ } catch (e) {
+ log.error('near-me failed', { err: e.message });
+ res.status(500).json({ error: 'lookup failed' });
+ }
+});
+
// Home
app.get('/', async (_req, res) => {
const breeds = await many(`
@@ -95,11 +134,12 @@ app.get('/:cat', async (req, res, next) => {
const dbCat = CAT_TO_DB[req.params.cat];
if (!dbCat) return next();
const businesses = await many(`
- SELECT id, name, slug, city, state, zip, latitude, longitude, rating, review_count, website
+ SELECT id, name, slug, city, state, zip, latitude, longitude, rating, review_count,
+ website, email, phone
FROM businesses
WHERE category=$1 AND opt_out_flag=FALSE
- ORDER BY rating DESC NULLS LAST, review_count DESC NULLS LAST
- LIMIT 200`, [dbCat]);
+ ORDER BY rating DESC NULLS LAST, review_count DESC NULLS LAST, name
+ LIMIT 2000`, [dbCat]);
const ad = await pickAdForPage({ pagePath: req.path, keywords: [req.params.cat], category: dbCat });
res.send(renderCategory({ category: req.params.cat, dbCategory: dbCat, businesses, ad }));
});
diff --git a/src/server/render.js b/src/server/render.js
index fb42611..d304b94 100644
--- a/src/server/render.js
+++ b/src/server/render.js
@@ -19,6 +19,8 @@ function head(title, extra = '') {
<title>${esc(title)} · AnimalsDirectory</title>
<link rel="stylesheet" href="/static/css/site.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
+<script defer src="/static/js/sort-table.js"></script>
+<script defer src="/static/js/geo.js"></script>
${extra}
</head><body>`;
}
@@ -99,10 +101,17 @@ function leadForm() {
}
export function renderHome({ breeds, counts, categories }) {
- const catCards = categories.map(c => `
+ // url-slug ('emergency-vets') → db-key ('emergency_vet'); the slugs use plurals,
+ // the db uses singular for some. Map explicitly so counts always render.
+ const URL_TO_DB = {
+ 'vets':'vet_clinic','emergency-vets':'emergency_vet','specialty-hospitals':'specialty_hospital',
+ 'groomers':'groomer','pet-stores':'pet_store','shelters':'shelter','breeders':'breeder',
+ 'trainers':'trainer','boarding':'boarding','daycare':'daycare','dog-parks':'dog_park',
+ };
+ const catCards = categories.filter(c => (counts[URL_TO_DB[c]]||0) > 0).map(c => `
<a href="/${c}" class="cat-card">
<strong>${esc(c.replace(/-/g,' '))}</strong>
- <span>${esc((counts[c.replace(/-/g,'_')]||0).toLocaleString())} listings</span>
+ <span>${esc((counts[URL_TO_DB[c]]||0).toLocaleString())} listings</span>
</a>`).join('');
const breedCards = breeds.map(b => `
<a href="/breeds/${esc(b.slug)}" class="breed-card">
@@ -115,8 +124,13 @@ export function renderHome({ breeds, counts, categories }) {
<section class="hero">
<h1>Everything for your animal — in one place.</h1>
<p>Browse 500+ breeds. Find vets, groomers, pet stores, trainers, breeders, and shelters near you. With maps. With reviews. Always free for pet owners.</p>
+ <div id="geo-banner" class="geo-banner" aria-live="polite"><span class="geo-pin">📍</span><span>Locating you…</span></div>
<a class="cta-big" href="#lead-form">Get matched with a pro →</a>
</section>
+ <section class="near-me">
+ <h2>Nearest to you</h2>
+ <div id="near-me-list" class="near-grid"><p class="empty">Sharing your location loads the closest 24 listings…</p></div>
+ </section>
<section class="cat-grid">${catCards}</section>
<h2>Popular breeds</h2>
<section class="breed-grid">${breedCards}</section>
@@ -179,33 +193,48 @@ export function renderBreed({ breed, relatedVets, ad }) {
}
export function renderCategory({ category, dbCategory, businesses, ad }) {
- const list = businesses.map(b => `
- <li>
- <a href="/clinic/${b.id}"><strong>${esc(b.name)}</strong></a>
- <small>${esc(b.city||'')}, ${esc(b.state||'')} ${b.zip?`· ${esc(b.zip)}`:''}${b.rating?` · ★ ${b.rating} (${b.review_count||0})`:''}</small>
- ${b.website ? `<a class="ext" href="${esc(b.website)}" rel="nofollow noopener" target="_blank">website ↗</a>` : ''}
- </li>`).join('');
- // mini-map for the first ~50 with coords
- const points = businesses.filter(b=>b.latitude&&b.longitude).slice(0,200).map(b => ({id:b.id,name:b.name,lat:Number(b.latitude),lng:Number(b.longitude)}));
+ const rows = businesses.map(b => `
+ <tr>
+ <td><a href="/clinic/${b.id}"><strong>${esc(b.name)}</strong></a></td>
+ <td>${esc(b.city||'')}</td>
+ <td>${esc(b.state||'')}</td>
+ <td>${esc(b.zip||'')}</td>
+ <td>${b.phone ? `<a href="tel:${esc(b.phone)}">${esc(b.phone)}</a>` : ''}</td>
+ <td>${b.email ? `<a href="mailto:${esc(b.email)}">${esc(b.email)}</a>` : ''}</td>
+ <td>${b.website ? `<a href="${esc(b.website)}" rel="nofollow noopener" target="_blank">site ↗</a>` : ''}</td>
+ <td data-sort="num">${b.rating ?? ''}</td>
+ <td data-sort="num">${b.review_count ?? ''}</td>
+ </tr>`).join('');
+ // mini-map for the first ~200 with coords
+ const geoPoints = businesses.filter(b=>b.latitude&&b.longitude);
+ const points = geoPoints.slice(0,200).map(b => ({id:b.id,name:b.name,lat:Number(b.latitude),lng:Number(b.longitude)}));
+ const mapCapNotice = geoPoints.length > 200
+ ? `<p class="muted" style="font-size:.85em;margin:.25em 0 0">Map shows first 200 of ${geoPoints.length} geocoded listings — full table below.</p>`
+ : '';
return head(`${category.replace(/-/g,' ')} directory`) + nav() + `
<main class="container">
<h1>${esc(category.replace(/-/g,' '))} (${businesses.length})</h1>
- <div id="cat-map" class="map" style="height:360px"></div>
+ <div id="cat-map" class="map" style="height:380px"></div>
+ ${mapCapNotice}
${adSlot(ad, 'header', `/${category}`)}
- <ul class="business-list">${list || '<li class="muted">No listings yet — ingest pending.</li>'}</ul>
+ ${businesses.length ? `<table data-sortable class="biz-table"><thead><tr>
+ <th>Name</th><th>City</th><th>State</th><th>ZIP</th><th>Phone</th><th>Email</th><th>Website</th><th data-sort="num">Rating</th><th data-sort="num">Reviews</th>
+ </tr></thead><tbody>${rows}</tbody></table>` : '<p class="muted">No listings yet — ingest pending.</p>'}
${leadForm()}
</main>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script>
+(function(){
+ const el = document.getElementById('cat-map');
+ if (typeof L === 'undefined') { el.innerHTML = '<p class="muted" style="padding:1em">Map library failed to load (network blocked?).</p>'; return; }
const pts = ${JSON.stringify(points)};
- if (pts.length) {
- const map = L.map('cat-map').setView([pts[0].lat, pts[0].lng], 9);
- L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution:'© OpenStreetMap contributors', maxZoom:18 }).addTo(map);
- const grp = L.featureGroup(pts.map(p => L.marker([p.lat,p.lng]).bindPopup('<a href="/clinic/'+p.id+'">'+p.name+'</a>'))).addTo(map);
- if (pts.length > 1) map.fitBounds(grp.getBounds().pad(0.2));
- } else {
- document.getElementById('cat-map').innerHTML = '<p class="muted" style="padding:1em">Map shows here once we have geocoded listings.</p>';
- }
+ if (!pts.length) { el.innerHTML = '<p class="muted" style="padding:1em">Map shows here once we have geocoded listings.</p>'; return; }
+ const map = L.map('cat-map').setView([pts[0].lat, pts[0].lng], 9);
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution:'© OpenStreetMap contributors', maxZoom:18 }).addTo(map);
+ const grp = L.featureGroup(pts.map(p => L.circleMarker([p.lat,p.lng], { radius:8, color:'#0f4d3a', fillColor:'#0f4d3a', fillOpacity:0.85, weight:2 }).bindPopup('<strong>'+p.name+'</strong><br><a href="/clinic/'+p.id+'">View →</a>'))).addTo(map);
+ if (pts.length > 1) map.fitBounds(grp.getBounds().pad(0.2));
+ setTimeout(() => map.invalidateSize(), 100);
+})();
</script>` + footer() + '</body></html>';
}
@@ -219,9 +248,14 @@ export function renderBusiness({ biz, audit, ad }) {
const map = (biz.latitude && biz.longitude) ? `<div id="biz-map" class="map" style="height:300px"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script>
+ (function(){
+ const el = document.getElementById('biz-map');
+ if (typeof L === 'undefined') { el.innerHTML = '<p class="muted" style="padding:1em">Map library failed to load.</p>'; return; }
const m = L.map('biz-map').setView([${biz.latitude}, ${biz.longitude}], 14);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {attribution:'© OpenStreetMap contributors',maxZoom:18}).addTo(m);
- L.marker([${biz.latitude}, ${biz.longitude}]).addTo(m).bindPopup(${JSON.stringify(biz.name)});
+ L.circleMarker([${biz.latitude}, ${biz.longitude}], { radius:10, color:'#0f4d3a', fillColor:'#c97a3e', fillOpacity:0.9, weight:3 }).addTo(m).bindPopup(${JSON.stringify(biz.name)}).openPopup();
+ setTimeout(() => m.invalidateSize(), 100);
+ })();
</script>` : '';
return head(biz.name) + nav() + `
<main class="container biz-page">
@@ -259,22 +293,25 @@ export function renderCityMap({ city, state, businesses, ad }) {
</main>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script>
+(function(){
+ const el = document.getElementById('city-map');
+ if (typeof L === 'undefined') { el.innerHTML = '<p class="muted" style="padding:1em">Map library failed to load.</p>'; return; }
const pts = ${JSON.stringify(points)};
- if (!pts.length) { document.getElementById('city-map').innerHTML = '<p>No listings yet for this city.</p>'; }
- else {
- const colors = { vet_clinic:'#0f4d3a', emergency_vet:'#c0382b', groomer:'#c97a3e', pet_store:'#3a6ea5', shelter:'#7e57c2', breeder:'#5d4037', trainer:'#388e3c', boarding:'#0288d1', dog_park:'#6a994e' };
- const map = L.map('city-map').setView([pts[0].lat, pts[0].lng], 12);
- L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{attribution:'© OpenStreetMap contributors',maxZoom:18}).addTo(map);
- const grp = L.featureGroup(pts.map(p => L.circleMarker([p.lat,p.lng], { radius:7, color:colors[p.cat]||'#333', fillOpacity:0.85 }).bindPopup('<strong>'+p.name+'</strong><br>'+p.cat.replace(/_/g,' ')+'<br><a href="/clinic/'+p.id+'">View →</a>'))).addTo(map);
- if (pts.length > 1) map.fitBounds(grp.getBounds().pad(0.15));
- }
+ if (!pts.length) { el.innerHTML = '<p>No listings yet for this city.</p>'; return; }
+ const colors = { vet_clinic:'#0f4d3a', emergency_vet:'#c0382b', specialty_hospital:'#5e35b1', groomer:'#c97a3e', pet_store:'#3a6ea5', shelter:'#7e57c2', breeder:'#5d4037', trainer:'#388e3c', boarding:'#0288d1', daycare:'#26a69a', dog_park:'#6a994e' };
+ const map = L.map('city-map').setView([pts[0].lat, pts[0].lng], 12);
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',{attribution:'© OpenStreetMap contributors',maxZoom:18}).addTo(map);
+ const grp = L.featureGroup(pts.map(p => L.circleMarker([p.lat,p.lng], { radius:8, color:colors[p.cat]||'#333', fillColor:colors[p.cat]||'#333', fillOpacity:0.85, weight:2 }).bindPopup('<strong>'+p.name+'</strong><br><span style="color:#666">'+p.cat.replace(/_/g,' ')+'</span><br><a href="/clinic/'+p.id+'">View →</a>'))).addTo(map);
+ if (pts.length > 1) map.fitBounds(grp.getBounds().pad(0.15));
+ setTimeout(() => map.invalidateSize(), 100);
+})();
</script>` + footer() + '</body></html>';
}
export function renderLeadForm() { return head('Get matched') + nav() + `<main class="container">${leadForm()}</main>` + footer() + '</body></html>'; }
export function renderAdmin({ upgradeQ, leadsQ, auditQ }) {
- const tbl = (rows, fields) => rows.length ? `<table><thead><tr>${fields.map(f=>`<th>${f}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${fields.map(f=>`<td>${esc(r[f]||'')}</td>`).join('')}</tr>`).join('')}</tbody></table>` : '<p class="muted">empty</p>';
+ const tbl = (rows, fields) => rows.length ? `<table data-sortable><thead><tr>${fields.map(f=>`<th>${f}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${fields.map(f=>`<td>${esc(r[f]||'')}</td>`).join('')}</tr>`).join('')}</tbody></table>` : '<p class="muted">empty</p>';
return head('Admin') + nav() + `<main class="container">
<h1>Admin queue</h1>
<h2>Upgrade orders</h2>${tbl(upgradeQ,['id','business_name','email','status','created_at'])}
← 474af3c init: project animals scaffold (schema + viewer + audit + mo
·
back to Animals
·
fix(community): debate-driven fixes for visibility, comments 95e913a →