← back to Costa Rica
ingest pipeline: ict-cst + local-portals + meic-pymes + google-places + run-all; ict-cst yielded 9 entities (page is AJAX-rendered, needs Browserbase upgrade for full ~130)
e517d11c82f2c83fc15e04422c1f28615893dfb4 · 2026-05-07 23:33:16 -0700 · Steve
Files touched
M .env.exampleA PLAN.mdA scripts/ingest/_lib.jsA scripts/ingest/google-places.jsA scripts/ingest/ict-cst.jsA scripts/ingest/local-portals.jsA scripts/ingest/meic-pymes.jsA scripts/ingest/run-all.js
Diff
commit e517d11c82f2c83fc15e04422c1f28615893dfb4
Author: Steve <steve@designerwallcoverings.com>
Date: Thu May 7 23:33:16 2026 -0700
ingest pipeline: ict-cst + local-portals + meic-pymes + google-places + run-all; ict-cst yielded 9 entities (page is AJAX-rendered, needs Browserbase upgrade for full ~130)
---
.env.example | 5 +-
PLAN.md | 98 +++++++++++++++++++++++++++++++++
scripts/ingest/_lib.js | 83 ++++++++++++++++++++++++++++
scripts/ingest/google-places.js | 104 +++++++++++++++++++++++++++++++++++
scripts/ingest/ict-cst.js | 117 ++++++++++++++++++++++++++++++++++++++++
scripts/ingest/local-portals.js | 84 +++++++++++++++++++++++++++++
scripts/ingest/meic-pymes.js | 112 ++++++++++++++++++++++++++++++++++++++
scripts/ingest/run-all.js | 32 +++++++++++
8 files changed, 634 insertions(+), 1 deletion(-)
diff --git a/.env.example b/.env.example
index d461296..2afb3f4 100644
--- a/.env.example
+++ b/.env.example
@@ -1,5 +1,8 @@
PORT=9791
-DATABASE_URL=postgresql://stevestudio2@localhost:5432/costa_rica_directory
+DATABASE_URL=postgresql:///costa_rica_directory?host=/tmp&user=stevestudio2
+PGHOST=/tmp
+PGUSER=stevestudio2
+PGDATABASE=costa_rica_directory
BASIC_AUTH_USER=costarica@agentabrams.com
BASIC_AUTH_PASS=__set_in_local_env__
SITE_NAME=Costa Rica Directory
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..5e924ab
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,98 @@
+# Costa Rica Directory — Plan
+
+**What:** A multi-vertical Costa Rica business directory — tourism + rentals + local services — across all 84 cantones, in the Ventura-Corridor / Ventura-Claw pattern.
+
+**Where:** `~/Projects/costa-rica/` · pm2 process `costa-rica` on `:9791` · standalone PG database `costa_rica_directory` · gated behind HTTP Basic Auth `costarica@agentabrams.com` (password in `secrets-manager` as `COSTARICA_AGENTABRAMS_PASSWORD`).
+
+**Public domain (planned):** `costarica.agentabrams.com` — DNS not yet pointed; deploy step not yet run.
+
+---
+
+## 1. Verticals (16 total)
+
+| Category | Verticals |
+|---|---|
+| **tourism** | hotel · tour · beach · restaurant · surf |
+| **rentals** | short_term · long_term · realestate |
+| **service** | food · beauty · retail · fitness · pet · auto · cleaning · creative |
+
+These mirror the Ventura-Claw 8-vertical green list for `service`, plus tourism + rentals on top.
+
+## 2. Regions (33 seeded)
+
+San José · Escazú · Santa Ana · Alajuela · Atenas · Grecia · La Fortuna/Arenal · Heredia · Cartago · Liberia · Tamarindo · Nosara · Santa Teresa · Montezuma · Jacó · Manuel Antonio · Quepos · Uvita · Dominical · Puerto Viejo · Cahuita · Monteverde · Santa Elena · Limón · Puntarenas · Papagayo · Playas del Coco · Flamingo · Sámara · Nicoya · plus 3 catch-all province buckets.
+
+---
+
+## 3. Data sources (ranked by usefulness, per EXA research 2026-05-07)
+
+### 3.1 Government (authoritative)
+| Source | Type | Bulk? | Use |
+|---|---|---|---|
+| **`api.hacienda.go.cr/fe/ae?identificacion={cedula}`** | Lookup-by-cédula | No (rate-limited) | Per-cédula enrichment (situación tributaria + actividades económicas) |
+| **MEIC PYMEs Activas (datos.go.cr CKAN)** | XLSX | Yes | National PYME-registered firms list |
+| **ICT CST (turismo-sostenible.co.cr)** | HTML scrape | ~130 entities | Tourism vertical seed (hotels, tours, rent-a-car, gastro, spa) |
+| **Heredia CKAN — patentes por actividad/distrito** | XLSX | Yes | Heredia cantón ground-truth (pattern for other cantones) |
+| **Registro Nacional — Personas Jurídicas** | Search-only | No | Cédula format validation (`3-XXX-XXXXXX`); no bulk export |
+| **CGR (Contraloría) RDF/JSON/CSV** | Bulk | Yes (procurement-only) | Government vendors list |
+
+### 3.2 Local / web directories (broad coverage)
+- **Google Places API** — Nearby Search around each region's lat/lng (key already in routes.json for the Ad-Signals Rolodex)
+- **TripAdvisor** — by town/category (Browserbase headless)
+- **Booking.com** — accommodations (Browserbase)
+- **Airbnb** — short-term rentals (existing CR pattern from `jill-website` is reference, not source)
+- **Yelp Costa Rica** — local services
+- **Tico Times classifieds** — `ticotimes.net/classifieds`
+- **AM Costa Rica classifieds** — `amcostarica.com`
+- **Town-specific portals** — `nosaranow.com`, `tamarindo.com`, `quepos-info.com`, `monteverdeinfo.com`, etc.
+
+### 3.3 Per-cantón patente CKAN (where available)
+- Heredia ✅ CKAN
+- Most others (Santa Cruz/Tamarindo, Nicoya/Nosara, Aguirre/Manuel Antonio, Garabito/Jacó, Talamanca/Puerto Viejo, San Carlos/La Fortuna, Cóbano/Santa Teresa) require per-cantón check; many are PDF/search-only.
+- **Fallback:** Ley 8968 / Ley 8220 transparency request to each municipalidad for patente list.
+
+---
+
+## 4. Ingest pipeline (planned scripts)
+
+```
+scripts/ingest/
+ _lib.js # PG upsert, slugify, fetch helpers, region resolver
+ ict-cst.js # ICT sustainable-tourism scrape (~130 rows)
+ meic-pymes.js # CKAN download + parse XLSX
+ hacienda-enrich.js # Per-cédula enrichment via api.hacienda.go.cr
+ heredia-patentes.js # Heredia CKAN download
+ google-places.js # Nearby Search per region centroid
+ tripadvisor-cr.js # Browserbase scrape per town
+ booking-cr.js # Browserbase scrape per town
+ local-portals.js # nosaranow / tamarindo / monteverdeinfo / ticotimes / etc
+ run-all.js # orchestrator → ingest_runs table
+```
+
+Each writes to `places` (UPSERT on slug), bumps `ingest_runs`, and respects per-source backoff. None run inside the Express process — they execute via launchd.
+
+## 5. Schedule (planned launchd)
+
+| Job | Frequency | Source |
+|---|---|---|
+| `com.steve.cr.ict-cst` | weekly Sun 02:00 | ICT CST |
+| `com.steve.cr.meic-pymes` | monthly 1st 03:00 | MEIC XLSX |
+| `com.steve.cr.google-places` | weekly Mon 03:00 | Google Places |
+| `com.steve.cr.local-portals` | daily 04:00 | Web directories |
+| `com.steve.cr.hacienda-enrich` | nightly 05:00 | Hacienda (only for `places` w/o `cedula_juridica` → backfill) |
+
+## 6. Compliance / safety rules
+
+- **No headless browser locally** unless Browserbase is unavailable (per MEMORY rule `feedback_browserbase_first`).
+- **Respect robots.txt** for every web-directory scrape; rate-limit at 1 req / 2s minimum.
+- **No phone-number exfiltration for SMS / DNC scrub**: phone numbers ingested are display-only, not for outreach.
+- **No CR DNC list exists nationally** (TCPA is US-only); CR has Ley 8968 (privacy) — display public-record info only, never email-blast scraped owners.
+- **Image rule** (per `feedback_no_stock_images`): no Unsplash/Pexels stock; only photos directly from the source listing or public-domain (Wikimedia, Wikicommons CR, etc.).
+- **info@ rule**: contact email is `info@agentabrams.com` until `info@<final-domain>` is provisioned.
+
+## 7. Open decisions for Steve
+
+1. Final domain — `costarica.agentabrams.com` (subdomain, fastest) vs. a registered `.cr` / `.com` (longer; needs TLD work).
+2. Public vs. gated — is the basic-auth gate temporary (drop when populated) or permanent (admin-only product)?
+3. Is `jill-website` (nosarabeachfrontrentals.com) supposed to xref into the Nosara region's `rentals_short` listings, or stay completely independent? (Currently independent per Steve's instruction.)
+4. Lead routing — same Stripe-backed `service_*` subscription pattern as Ventura-Claw, or skipped for v1?
diff --git a/scripts/ingest/_lib.js b/scripts/ingest/_lib.js
new file mode 100644
index 0000000..578fa9d
--- /dev/null
+++ b/scripts/ingest/_lib.js
@@ -0,0 +1,83 @@
+'use strict';
+
+require('dotenv').config({ path: require('path').join(__dirname, '..', '..', '.env') });
+const { Pool } = require('pg');
+
+const pool = new Pool({ connectionString: process.env.DATABASE_URL });
+
+const UA = process.env.SCRAPER_UA || 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15 (CR-Directory bot; contact info@agentabrams.com)';
+
+async function fetchText(url, opts = {}) {
+ const res = await fetch(url, {
+ headers: { 'User-Agent': UA, 'Accept': 'text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8', 'Accept-Language': 'es-CR,es;q=0.9,en;q=0.7', ...(opts.headers||{}) },
+ redirect: 'follow',
+ signal: opts.signal,
+ });
+ if (!res.ok) throw new Error(`HTTP ${res.status} on ${url}`);
+ return await res.text();
+}
+
+async function fetchJson(url, opts = {}) {
+ const res = await fetch(url, { headers: { 'User-Agent': UA, 'Accept': 'application/json', ...(opts.headers||{}) }, redirect: 'follow', signal: opts.signal });
+ if (!res.ok) throw new Error(`HTTP ${res.status} on ${url}`);
+ return await res.json();
+}
+
+const slug = (s, maxLen = 96) => String(s||'')
+ .normalize('NFD').replace(/[̀-ͯ]/g,'')
+ .toLowerCase()
+ .replace(/&/g,' and ')
+ .replace(/[^a-z0-9]+/g,'-')
+ .replace(/^-+|-+$/g,'')
+ .slice(0, maxLen);
+
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+async function regionMap() {
+ const { rows } = await pool.query('SELECT id, slug, name, lat, lng FROM regions');
+ const bySlug = new Map(rows.map(r => [r.slug, r]));
+ const byName = new Map(rows.map(r => [r.name.toLowerCase(), r]));
+ return { rows, bySlug, byName, fallback: bySlug.get('cr-other') };
+}
+
+function resolveRegion(rmap, hint) {
+ if (!hint) return rmap.fallback;
+ const h = String(hint).toLowerCase().trim();
+ if (rmap.bySlug.has(h)) return rmap.bySlug.get(h);
+ if (rmap.byName.has(h)) return rmap.byName.get(h);
+ for (const r of rmap.rows) {
+ if (h.includes(r.name.toLowerCase()) || h.includes(r.slug.replace(/-/g,' '))) return r;
+ }
+ return rmap.fallback;
+}
+
+async function upsertPlace(p) {
+ const cols = ['slug','name','category','vertical','region_id','description','address','phone','email','website','price_range','rating','image_url','tags','lat','lng','cedula_juridica','source','source_url'];
+ const vals = cols.map(c => p[c] ?? null);
+ const placeholders = cols.map((_, i) => `$${i+1}`).join(',');
+ const updates = cols.filter(c => c!=='slug').map(c => `${c} = COALESCE(EXCLUDED.${c}, places.${c})`).join(', ');
+ const sql = `
+ INSERT INTO places (${cols.join(',')}) VALUES (${placeholders})
+ ON CONFLICT (slug) DO UPDATE SET ${updates}, updated_at = NOW()
+ RETURNING (xmax = 0) AS inserted, id
+ `;
+ const { rows } = await pool.query(sql, vals);
+ return rows[0]; // { inserted: bool, id }
+}
+
+async function startRun(source, notes='') {
+ const { rows } = await pool.query(
+ `INSERT INTO ingest_runs (source, status, notes) VALUES ($1, 'running', $2) RETURNING id`,
+ [source, notes]
+ );
+ return rows[0].id;
+}
+
+async function finishRun(id, { rows_in=0, rows_added=0, rows_updated=0, status='ok', notes='' } = {}) {
+ await pool.query(
+ `UPDATE ingest_runs SET finished_at=NOW(), rows_in=$2, rows_added=$3, rows_updated=$4, status=$5, notes=COALESCE($6, notes) WHERE id=$1`,
+ [id, rows_in, rows_added, rows_updated, status, notes]
+ );
+}
+
+module.exports = { pool, UA, fetchText, fetchJson, slug, sleep, regionMap, resolveRegion, upsertPlace, startRun, finishRun };
diff --git a/scripts/ingest/google-places.js b/scripts/ingest/google-places.js
new file mode 100644
index 0000000..5c0d35b
--- /dev/null
+++ b/scripts/ingest/google-places.js
@@ -0,0 +1,104 @@
+'use strict';
+
+// Google Places Nearby Search — sweep each region centroid for businesses.
+// Reads GOOGLE_PLACES_API_KEY from env; if missing, exits with notes.
+
+const { fetchJson, slug, sleep, regionMap, upsertPlace, startRun, finishRun, pool } = require('./_lib');
+
+const KEY = process.env.GOOGLE_PLACES_API_KEY;
+const RADIUS_M = 5000;
+
+const TYPE_TO_VERTICAL = {
+ lodging: ['tourism', 'tourism_hotel'],
+ restaurant: ['tourism', 'tourism_restaurant'],
+ cafe: ['service', 'service_food'],
+ bakery: ['service', 'service_food'],
+ bar: ['service', 'service_food'],
+ meal_takeaway: ['service', 'service_food'],
+ spa: ['service', 'service_beauty'],
+ beauty_salon: ['service', 'service_beauty'],
+ hair_care: ['service', 'service_beauty'],
+ gym: ['service', 'service_fitness'],
+ pet_store: ['service', 'service_pet'],
+ veterinary_care: ['service', 'service_pet'],
+ car_repair: ['service', 'service_auto'],
+ car_wash: ['service', 'service_auto'],
+ car_rental: ['tourism', 'tourism_tour'],
+ travel_agency: ['tourism', 'tourism_tour'],
+ tourist_attraction:['tourism', 'tourism_tour'],
+ shopping_mall: ['service', 'service_retail'],
+ store: ['service', 'service_retail'],
+ real_estate_agency:['rentals', 'rentals_realestate'],
+};
+
+const TYPES = Object.keys(TYPE_TO_VERTICAL);
+
+async function nearby(lat, lng, type, pageToken = null) {
+ const url = new URL('https://maps.googleapis.com/maps/api/place/nearbysearch/json');
+ url.searchParams.set('location', `${lat},${lng}`);
+ url.searchParams.set('radius', RADIUS_M);
+ url.searchParams.set('type', type);
+ url.searchParams.set('key', KEY);
+ if (pageToken) url.searchParams.set('pagetoken', pageToken);
+ return await fetchJson(url.toString());
+}
+
+(async () => {
+ if (!KEY) { console.warn('[google-places] GOOGLE_PLACES_API_KEY missing — skipping'); process.exit(0); }
+ const runId = await startRun('google-places', 'Sweep CR regions via Places Nearby Search');
+ const rmap = await regionMap();
+ let total = 0, added = 0, updated = 0, errors = 0;
+
+ try {
+ const targets = rmap.rows.filter(r => r.lat && r.lng && r.region_type !== 'region');
+ for (const region of targets) {
+ for (const type of TYPES) {
+ try {
+ let pageToken = null, pages = 0;
+ do {
+ const res = await nearby(region.lat, region.lng, type, pageToken);
+ if (res.status !== 'OK' && res.status !== 'ZERO_RESULTS') {
+ console.warn(`[google-places] ${region.slug}/${type} status=${res.status}`);
+ break;
+ }
+ const [category, vertical] = TYPE_TO_VERTICAL[type];
+ for (const p of (res.results || [])) {
+ const name = p.name; if (!name) continue;
+ const placeSlug = slug(`gp-${p.place_id}`);
+ const r = await upsertPlace({
+ slug: placeSlug,
+ name,
+ category, vertical,
+ region_id: region.id,
+ description: `${vertical.replace(/_/g,' ')} in ${region.name}.`,
+ address: p.vicinity || null,
+ lat: p.geometry?.location?.lat ?? null,
+ lng: p.geometry?.location?.lng ?? null,
+ rating: p.rating ?? null,
+ source: 'google-places',
+ source_url: `https://www.google.com/maps/place/?q=place_id:${p.place_id}`,
+ });
+ if (r.inserted) added++; else updated++;
+ total++;
+ }
+ pageToken = res.next_page_token || null;
+ pages++;
+ if (pageToken) await sleep(2200); // Google needs ~2s before next_page_token is valid
+ } while (pageToken && pages < 3);
+ } catch (e) {
+ errors++;
+ console.warn(`[google-places] ${region.slug}/${type} err:`, e.message);
+ }
+ await sleep(120);
+ }
+ }
+ await finishRun(runId, { rows_in: total, rows_added: added, rows_updated: updated, status: errors ? 'partial' : 'ok', notes: `errors=${errors}` });
+ console.log(`[google-places] in=${total} added=${added} updated=${updated} errors=${errors}`);
+ } catch (e) {
+ await finishRun(runId, { status: 'error', notes: e.message });
+ console.error('[google-places] FAIL', e.message);
+ process.exitCode = 1;
+ } finally {
+ await pool.end();
+ }
+})();
diff --git a/scripts/ingest/ict-cst.js b/scripts/ingest/ict-cst.js
new file mode 100644
index 0000000..d2f70b0
--- /dev/null
+++ b/scripts/ingest/ict-cst.js
@@ -0,0 +1,117 @@
+'use strict';
+
+// ICT CST — Certificación para la Sostenibilidad Turística
+// Public directory of CR-government-certified sustainable-tourism businesses.
+// Source: https://turismo-sostenible.co.cr/entidades-certificadas/
+
+const { fetchText, slug, sleep, regionMap, resolveRegion, upsertPlace, startRun, finishRun, pool } = require('./_lib');
+
+const ROOT = 'https://turismo-sostenible.co.cr/entidades-certificadas/';
+
+const VERTICAL_BY_CATEGORY = {
+ 'hospedaje': 'tourism_hotel',
+ 'alojamiento': 'tourism_hotel',
+ 'tour operadores': 'tourism_tour',
+ 'tour-operadores': 'tourism_tour',
+ 'tour operador': 'tourism_tour',
+ 'agencia de viajes': 'tourism_tour',
+ 'gastronomia': 'tourism_restaurant',
+ 'gastronomía': 'tourism_restaurant',
+ 'restaurante': 'tourism_restaurant',
+ 'transporte': 'tourism_tour',
+ 'rent a car': 'tourism_tour',
+ 'spa': 'service_beauty',
+ 'wellness': 'service_beauty',
+};
+
+function pickVertical(category) {
+ const c = (category||'').toLowerCase().trim();
+ for (const [k,v] of Object.entries(VERTICAL_BY_CATEGORY)) {
+ if (c.includes(k)) return v;
+ }
+ return 'tourism_hotel';
+}
+
+function extractListings(html) {
+ // Conservative regex parse; the page renders entity cards as anchor tags.
+ // We avoid heavy DOM parsing to keep the script dep-light.
+ const out = [];
+ const cardRe = /<article[^>]*class="[^"]*entidad[^"]*"[^>]*>([\s\S]*?)<\/article>/gi;
+ let m;
+ while ((m = cardRe.exec(html)) !== null) {
+ const block = m[1];
+ const name = (block.match(/<h2[^>]*>\s*([^<]+?)\s*<\/h2>/i)||[])[1] || (block.match(/<h3[^>]*>\s*([^<]+?)\s*<\/h3>/i)||[])[1];
+ const province = (block.match(/Provincia[^:]*:\s*<\/?[a-z]+>?\s*([^<]+?)\s*</i)||[])[1];
+ const canton = (block.match(/Cant[oó]n[^:]*:\s*<\/?[a-z]+>?\s*([^<]+?)\s*</i)||[])[1];
+ const distrito = (block.match(/Distrito[^:]*:\s*<\/?[a-z]+>?\s*([^<]+?)\s*</i)||[])[1];
+ const categoria = (block.match(/Categor[ií]a[^:]*:\s*<\/?[a-z]+>?\s*([^<]+?)\s*</i)||[])[1];
+ const link = (block.match(/<a[^>]+href="([^"]+)"/i)||[])[1];
+ const img = (block.match(/<img[^>]+src="([^"]+)"/i)||[])[1];
+ if (name) {
+ out.push({
+ name: name.trim(),
+ category: 'tourism',
+ vertical: pickVertical(categoria),
+ province: (province||'').trim(),
+ canton: (canton||'').trim(),
+ distrito: (distrito||'').trim(),
+ source: 'ict-cst',
+ source_url: link ? new URL(link, ROOT).toString() : ROOT,
+ image_url: img ? new URL(img, ROOT).toString() : null,
+ address: [distrito, canton, province].filter(Boolean).join(', '),
+ });
+ }
+ }
+ return out;
+}
+
+(async () => {
+ const runId = await startRun('ict-cst', 'Scrape ICT CST sustainable-tourism directory');
+ const rmap = await regionMap();
+ let html, listings = [], inserted = 0, updated = 0;
+
+ try {
+ html = await fetchText(ROOT);
+ listings = extractListings(html);
+ if (!listings.length) {
+ // Fallback: many WP sites use AJAX; if direct HTML had no <article class=entidad>, log markup hint
+ const hint = html.match(/class="[^"]*entidad[^"]*"/i)?.[0] || '(no entidad class found)';
+ console.warn(`[ict-cst] no listings parsed from root HTML; hint=${hint}`);
+ }
+
+ for (const l of listings) {
+ const region = resolveRegion(rmap, l.canton || l.distrito || l.province);
+ const placeSlug = slug(`ict-${l.name}-${region.slug}`);
+ const r = await upsertPlace({
+ slug: placeSlug,
+ name: l.name,
+ category: 'tourism',
+ vertical: l.vertical,
+ region_id: region.id,
+ description: `ICT CST-certified ${l.vertical.replace('tourism_','')} in ${l.canton||l.province||'Costa Rica'}.`,
+ address: l.address,
+ image_url: l.image_url,
+ source: l.source,
+ source_url: l.source_url,
+ });
+ if (r.inserted) inserted++; else updated++;
+ await sleep(50);
+ }
+
+ await finishRun(runId, {
+ rows_in: listings.length,
+ rows_added: inserted,
+ rows_updated: updated,
+ status: listings.length ? 'ok' : 'empty',
+ notes: listings.length ? '' : 'parser found no <article class=entidad> blocks; ICT page likely AJAX-rendered, switch to Browserbase'
+ });
+
+ console.log(`[ict-cst] in=${listings.length} added=${inserted} updated=${updated}`);
+ } catch (e) {
+ await finishRun(runId, { status: 'error', notes: e.message });
+ console.error('[ict-cst] FAIL', e.message);
+ process.exitCode = 1;
+ } finally {
+ await pool.end();
+ }
+})();
diff --git a/scripts/ingest/local-portals.js b/scripts/ingest/local-portals.js
new file mode 100644
index 0000000..a5a4d55
--- /dev/null
+++ b/scripts/ingest/local-portals.js
@@ -0,0 +1,84 @@
+'use strict';
+
+// Local town-portal scrapers. Each portal has its own listing layout; this
+// runner is a generic "fetch + extract anchor names" pass that tags origin.
+// Browserbase-required portals (TripAdvisor, Booking, Yelp) live in their
+// own scripts; this one targets static-HTML town directories.
+
+const { fetchText, slug, sleep, regionMap, resolveRegion, upsertPlace, startRun, finishRun, pool } = require('./_lib');
+
+const PORTALS = [
+ // { url, region_hint, vertical_hint, name_selector_regex }
+ { url: 'https://nosaranow.com/business-directory/', region: 'nosara', vertical: 'service_retail', label: 'nosaranow' },
+ { url: 'https://www.tamarindo.com/directory/', region: 'tamarindo', vertical: 'service_retail', label: 'tamarindo-com' },
+ { url: 'https://monteverdeinfo.com/businesses/', region: 'monteverde', vertical: 'service_retail', label: 'monteverdeinfo' },
+ { url: 'https://www.quepos.com/business-directory', region: 'quepos', vertical: 'service_retail', label: 'quepos-com' },
+ { url: 'https://thehowlermag.com/category/directory/', region: 'tamarindo', vertical: 'service_retail', label: 'howler-tamarindo' },
+];
+
+function extractAnchors(html) {
+ const out = [];
+ const re = /<a[^>]+href="([^"]+)"[^>]*>([^<]{2,80})<\/a>/gi;
+ let m;
+ while ((m = re.exec(html)) !== null) {
+ const href = m[1].trim();
+ const text = m[2].replace(/\s+/g,' ').trim();
+ if (!text) continue;
+ if (/(^#|mailto:|tel:|javascript:|^\/$|^\/$|\bhome\b|\bcontact\b|\babout\b|\blogin\b|\bregister\b)/i.test(href + ' ' + text)) continue;
+ if (/^\d+$/.test(text) || text.length < 3) continue;
+ out.push({ href, text });
+ }
+ return out;
+}
+
+(async () => {
+ const runId = await startRun('local-portals', 'Static-HTML CR town-portal directory scrape');
+ const rmap = await regionMap();
+ let total = 0, added = 0, updated = 0, ok = 0, fail = 0;
+
+ for (const p of PORTALS) {
+ try {
+ const html = await fetchText(p.url);
+ const anchors = extractAnchors(html);
+ const seen = new Set();
+ const region = resolveRegion(rmap, p.region);
+ // Heuristic: keep only anchors whose href looks like a deeper listing page on the same domain
+ const host = new URL(p.url).host;
+ const candidates = anchors.filter(a => {
+ try {
+ const u = new URL(a.href, p.url);
+ if (u.host !== host) return false;
+ if (u.pathname === '/' || u.pathname === new URL(p.url).pathname) return false;
+ return /(business|directory|listing|company|empresa|negocio|hotel|tour|restaurant|salon|spa|fitness|surf|yoga|store|tienda)/i.test(u.pathname + ' ' + a.text);
+ } catch { return false; }
+ });
+
+ for (const c of candidates.slice(0, 200)) {
+ const placeSlug = slug(`${p.label}-${c.text}-${region.slug}`);
+ if (seen.has(placeSlug)) continue; seen.add(placeSlug);
+ const r = await upsertPlace({
+ slug: placeSlug,
+ name: c.text,
+ category: p.vertical.startsWith('tourism_') ? 'tourism' : 'service',
+ vertical: p.vertical,
+ region_id: region.id,
+ description: `Listed on ${p.label} (${region.name}).`,
+ source: p.label,
+ source_url: new URL(c.href, p.url).toString(),
+ });
+ if (r.inserted) added++; else updated++;
+ total++;
+ }
+ ok++;
+ console.log(`[local-portals] ${p.label} in=${candidates.length}`);
+ await sleep(800);
+ } catch (e) {
+ fail++;
+ console.warn(`[local-portals] ${p.label} err:`, e.message);
+ }
+ }
+
+ await finishRun(runId, { rows_in: total, rows_added: added, rows_updated: updated, status: fail ? 'partial' : 'ok', notes: `portals_ok=${ok} portals_fail=${fail}` });
+ console.log(`[local-portals] DONE in=${total} added=${added} updated=${updated} portals_ok=${ok} portals_fail=${fail}`);
+ await pool.end();
+})();
diff --git a/scripts/ingest/meic-pymes.js b/scripts/ingest/meic-pymes.js
new file mode 100644
index 0000000..3af1a46
--- /dev/null
+++ b/scripts/ingest/meic-pymes.js
@@ -0,0 +1,112 @@
+'use strict';
+
+// MEIC PYMEs Activas — bulk national list of registered SMEs.
+// Source: datos.go.cr CKAN dataset "Empresas Activas" (XLSX).
+// Discovers latest CSV/XLSX resource via CKAN API and ingests rows.
+
+const { fetchJson, fetchText, slug, regionMap, resolveRegion, upsertPlace, startRun, finishRun, pool } = require('./_lib');
+const fs = require('fs');
+const path = require('path');
+
+const CKAN_BASE = process.env.CKAN_BASE || 'https://datosabiertos.presidencia.go.cr/api/3/action';
+const SEARCH_QS = 'q=pymes+activas+OR+empresas+activas&rows=10';
+const CACHE_DIR = path.join(__dirname, '..', '..', 'data', 'cache');
+
+function findResource(pkg) {
+ const resources = pkg.result?.resources || [];
+ const csv = resources.find(r => /csv$/i.test(r.format||r.url||''));
+ const xlsx = resources.find(r => /xlsx?$/i.test(r.format||r.url||''));
+ return csv || xlsx || resources[0];
+}
+
+(async () => {
+ const runId = await startRun('meic-pymes', 'Fetch MEIC PYMEs Activas via datos.go.cr CKAN');
+ let added = 0, updated = 0, total = 0;
+ try {
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
+
+ const search = await fetchJson(`${CKAN_BASE}/package_search?${SEARCH_QS}`);
+ const pkgs = search.result?.results || [];
+ if (!pkgs.length) throw new Error('no PYMEs package found in datos.go.cr CKAN search');
+
+ const pkg = pkgs[0];
+ const pkgFull = await fetchJson(`${CKAN_BASE}/package_show?id=${encodeURIComponent(pkg.id)}`);
+ const res = findResource(pkgFull);
+ if (!res?.url) throw new Error('no downloadable resource on package');
+
+ console.log(`[meic-pymes] package=${pkg.title} resource=${res.format} url=${res.url}`);
+
+ const cachePath = path.join(CACHE_DIR, `meic-pymes${path.extname(res.url)||'.csv'}`);
+ const body = await fetchText(res.url);
+ fs.writeFileSync(cachePath, body);
+ console.log(`[meic-pymes] cached → ${cachePath} (${(body.length/1024).toFixed(1)} KB)`);
+
+ if (!/csv$/i.test(res.format||res.url||'')) {
+ await finishRun(runId, { status: 'partial', notes: `XLSX cached; CSV-parsing path only — XLSX requires xlsx pkg (not in deps)` });
+ console.log('[meic-pymes] XLSX cached; install `xlsx` to parse, or convert manually.');
+ return;
+ }
+
+ const lines = body.split(/\r?\n/).filter(Boolean);
+ if (!lines.length) throw new Error('empty CSV');
+
+ const header = lines[0].split(/[;,]/).map(h => h.trim().toLowerCase());
+ const idx = (...names) => header.findIndex(h => names.some(n => h.includes(n)));
+ const iName = idx('nombre','razon');
+ const iCanton = idx('canton','cantón');
+ const iProv = idx('provincia');
+ const iAct = idx('actividad','rubro','sector');
+ const iCedula = idx('cedula','cédula','identificacion');
+ const iDir = idx('direccion','dirección','domicilio');
+ const iEmail = idx('email','correo');
+ const iPhone = idx('telefono','teléfono','phone');
+
+ const rmap = await regionMap();
+
+ for (const line of lines.slice(1, 5001)) { // cap per-run to 5k for first pass
+ const cols = line.split(/[;,](?=(?:[^"]*"[^"]*")*[^"]*$)/).map(c => c.replace(/^"|"$/g,'').trim());
+ const name = cols[iName]; if (!name) continue;
+ const canton = cols[iCanton] || cols[iProv];
+ const region = resolveRegion(rmap, canton);
+ const act = (cols[iAct]||'').toLowerCase();
+ const vertical =
+ /restaurante|gastronom|comida|cafeter|bar|panade/.test(act) ? 'service_food' :
+ /hotel|hospeda|cabin|alojami|villa/.test(act) ? 'tourism_hotel' :
+ /tour|turismo|aventura|operador/.test(act) ? 'tourism_tour' :
+ /belleza|spa|peluquer|barber|estética/.test(act) ? 'service_beauty' :
+ /tienda|comerc|venta|retail/.test(act) ? 'service_retail' :
+ /gimna|fitness|crossfit|yoga/.test(act) ? 'service_fitness' :
+ /mascot|veterin|pet/.test(act) ? 'service_pet' :
+ /taller|mecan|automotr|car wash/.test(act) ? 'service_auto' :
+ /limpie|aseo|janitor/.test(act) ? 'service_cleaning' :
+ /diseñ|publicid|fotograf|estudio/.test(act) ? 'service_creative' :
+ 'service_retail';
+ const category = vertical.startsWith('tourism_') ? 'tourism' : 'service';
+ const placeSlug = slug(`meic-${cols[iCedula]||name}-${region.slug}`);
+ const r = await upsertPlace({
+ slug: placeSlug,
+ name,
+ category, vertical,
+ region_id: region.id,
+ description: act ? `MEIC-registered PYME: ${act}` : 'MEIC-registered PYME.',
+ address: cols[iDir] || canton || '',
+ phone: cols[iPhone] || null,
+ email: cols[iEmail] || null,
+ cedula_juridica: cols[iCedula] || null,
+ source: 'meic-pymes',
+ source_url: res.url,
+ });
+ if (r.inserted) added++; else updated++;
+ total++;
+ }
+
+ await finishRun(runId, { rows_in: total, rows_added: added, rows_updated: updated, status: 'ok' });
+ console.log(`[meic-pymes] in=${total} added=${added} updated=${updated}`);
+ } catch (e) {
+ await finishRun(runId, { status: 'error', notes: e.message });
+ console.error('[meic-pymes] FAIL', e.message);
+ process.exitCode = 1;
+ } finally {
+ await pool.end();
+ }
+})();
diff --git a/scripts/ingest/run-all.js b/scripts/ingest/run-all.js
new file mode 100644
index 0000000..952e926
--- /dev/null
+++ b/scripts/ingest/run-all.js
@@ -0,0 +1,32 @@
+'use strict';
+
+// Sequential orchestrator. Runs each ingest module as its own child process
+// so a single-source failure doesn't poison the others.
+
+const { spawn } = require('child_process');
+const path = require('path');
+
+const ORDER = [
+ 'ict-cst',
+ 'local-portals',
+ 'meic-pymes',
+ 'google-places',
+];
+
+function run(name) {
+ return new Promise(resolve => {
+ const t0 = Date.now();
+ const p = spawn('node', [path.join(__dirname, `${name}.js`)], { stdio: 'inherit', env: process.env });
+ p.on('close', code => {
+ const ms = Date.now() - t0;
+ console.log(`[run-all] ${name} → exit=${code} in ${(ms/1000).toFixed(1)}s\n`);
+ resolve(code);
+ });
+ });
+}
+
+(async () => {
+ const codes = {};
+ for (const name of ORDER) codes[name] = await run(name);
+ console.log('[run-all] summary', codes);
+})();
← 253365f initial scaffold: CR multi-vertical directory (tourism + ren
·
back to Costa Rica
·
MEIC PYMEs: full XLSX import (25,661 rows) + auto-region cre c91cf7b →