← back to Costa Rica

scripts/ingest/local-portals.js

121 lines

'use strict';

// Local town-portal scrapers for static-HTML CR business directories.
// URL list from EXA-agent research 2026-05-07. Browserbase-required
// portals (TripAdvisor / Booking / Yelp) live in their own scripts.

const { fetchText, slug, sleep, regionMap, resolveRegion, upsertPlace, startRun, finishRun, pool } = require('./_lib');

const PORTALS = [
  { url: 'https://tamarindoguide.com/businesses/',                       region: 'tamarindo',     vertical: 'service_retail', label: 'tamarindoguide',   maxPages: 5 },
  { url: 'https://www.alltama.com/listings',                              region: 'tamarindo',     vertical: 'service_retail', label: 'alltama' },
  { url: 'https://www.tamarindorentals.com/tamarindo-area-directory/',    region: 'tamarindo',     vertical: 'service_retail', label: 'tamarindo-rentals-dir' },
  { url: 'https://caribeconnect.com/table/',                              region: 'puerto-viejo',  vertical: 'service_retail', label: 'caribeconnect' },
  { url: 'https://www.puertoviejosatellite.com/services.php',             region: 'puerto-viejo',  vertical: 'service_retail', label: 'pv-satellite' },
  { url: 'https://www.welcometojaco.com/costa-rica',                      region: 'jaco',          vertical: 'service_retail', label: 'welcometojaco' },
  { url: 'https://www.jacocostaricadirectory.com/',                       region: 'jaco',          vertical: 'service_retail', label: 'jaco-directory' },
  { url: 'https://www.touristinmonteverde.com/tours/?cat=11',             region: 'monteverde',    vertical: 'tourism_tour',   label: 'tourist-monteverde' },
  { url: 'https://camaraturismosamara.com/businesses/',                   region: 'samara',        vertical: 'service_retail', label: 'camara-samara' },
  { url: 'https://www.searchsamara.com/copy-of-leisure-services-1',       region: 'samara',        vertical: 'service_retail', label: 'searchsamara' },
  { url: 'https://nosaranow.com/business-directory/',                     region: 'nosara',        vertical: 'service_retail', label: 'nosaranow' },
];
// Candidate URLs that 404'd or had DNS errors — kept here for posterity / future verification:
// quepolandia.com/category/businesses, arenal.net/business-directory, santateresacostarica.com/businesses,
// thecostaricanews.com/business-directory, govisitcostarica.com/region/business-directory,
// nosaratravel.com/local-businesses, manuelantonioestates.com/businesses, uvitainformationcenter.com,
// cocosbeachcostarica.com/business-directory, dominicaldays.com/business-directory, escazunews.com/category/local-business


const KEYWORD_BLOCK = /(^#|mailto:|tel:|javascript:|^\/?$|\bhome\b|\bcontact\b|\babout\b|\blogin\b|\bregister\b|\bcart\b|\bcheckout\b|\bsearch\b|\bsubscribe\b|\bprivacy\b|\bterms\b|\bsitemap\b|\bblog\b|\bnews\b|\barchive\b)/i;
const KEYWORD_KEEP  = /(business|directory|listing|company|empresa|negocio|hotel|tour|restaurant|restaurante|salon|spa|fitness|surf|yoga|store|tienda|cabin|villa|rental|alquiler|service|servicio|gym|cafe|bar)/i;

function extractFromArticles(html, base) {
  const out = [];
  // Try common WP/listing patterns: <article>, <div class="...listing|business|card...">
  const blockRe = /<(article|div)[^>]+class="[^"]*(listing|business|card|entry|item|post|directory)[^"]*"[^>]*>([\s\S]*?)<\/\1>/gi;
  let m;
  while ((m = blockRe.exec(html)) !== null) {
    const block = m[3];
    const a = block.match(/<a[^>]+href="([^"#]+)"[^>]*>\s*([^<]{2,90})\s*<\/a>/i);
    if (a) {
      const href = a[1].trim();
      const text = a[2].replace(/\s+/g,' ').trim();
      if (text && !KEYWORD_BLOCK.test(href + ' ' + text)) out.push({ href, text });
    }
  }
  if (out.length) return out;
  // Fallback: all anchors with keep-keyword in text or href
  const re = /<a[^>]+href="([^"#]+)"[^>]*>\s*([^<]{3,90})\s*<\/a>/gi;
  while ((m = re.exec(html)) !== null) {
    const href = m[1].trim();
    const text = m[2].replace(/\s+/g,' ').trim();
    if (KEYWORD_BLOCK.test(href + ' ' + text)) continue;
    if (!KEYWORD_KEEP.test(href + ' ' + text)) continue;
    out.push({ href, text });
  }
  return out;
}

function paginatedUrls(p) {
  if (!p.maxPages) return [p.url];
  const urls = [p.url];
  for (let i = 2; i <= p.maxPages; i++) {
    if (/\/$/.test(p.url)) urls.push(`${p.url}page/${i}/`);
    else urls.push(`${p.url}/page/${i}/`);
  }
  return urls;
}

(async () => {
  const runId = await startRun('local-portals', 'Static-HTML CR town-portal directory scrape (EXA-vetted URLs)');
  const rmap = await regionMap();
  let total = 0, added = 0, updated = 0, ok = 0, fail = 0;
  const seen = new Set();

  for (const p of PORTALS) {
    const region = resolveRegion(rmap, p.region);
    let portalRows = 0;
    try {
      for (const url of paginatedUrls(p)) {
        let html;
        try { html = await fetchText(url); }
        catch (e) { console.warn(`[local-portals] ${p.label} ${url} → ${e.message}`); continue; }
        const anchors = extractFromArticles(html, url);
        const host = new URL(p.url).host;

        for (const a of anchors) {
          let absHref;
          try { absHref = new URL(a.href, url).toString(); } catch { continue; }
          if (new URL(absHref).host !== host) continue;
          const placeSlug = slug(`${p.label}-${a.text}`);
          if (seen.has(placeSlug)) continue;
          seen.add(placeSlug);
          const r = await upsertPlace({
            slug: placeSlug,
            name: a.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: absHref,
          });
          if (r.inserted) added++; else updated++;
          total++;
          portalRows++;
        }
        await sleep(700);
      }
      ok++;
      console.log(`[local-portals] ${p.label} rows=${portalRows}`);
    } 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();
})();