← back to Costa Rica

scripts/ingest/ict-cst.js

118 lines

'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();
  }
})();