← back to Costa Rica

scripts/ingest/meic-pymes.js

153 lines

'use strict';

// MEIC PYMEs Activas — bulk national list of registered SMEs.
// Source: datos.go.cr CKAN — package "Lista de Pymes activas - Empresas Activas".

const { fetchJson, fetchBuffer, slug, regionMap, ensureRegion, upsertPlace, startRun, finishRun, pool } = require('./_lib');
const fs = require('fs');
const path = require('path');
const XLSX = require('xlsx');

const CKAN_HOSTS = (process.env.CKAN_BASE
  ? [process.env.CKAN_BASE]
  : [
      'https://www.datosabiertos.go.cr/api/3/action',
      'https://datos.go.cr/api/3/action',
      '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');
const ROW_CAP = parseInt(process.env.MEIC_ROW_CAP || '15000', 10);

function findResource(pkg) {
  const resources = pkg.result?.resources || [];
  return resources.find(r => /xlsx?$/i.test(r.format||r.url||''))
      || resources.find(r => /csv$/i.test(r.format||r.url||''))
      || resources[0];
}

function findCol(headers, ...needles) {
  for (let i = 0; i < headers.length; i++) {
    const h = String(headers[i] || '').toLowerCase().trim();
    if (needles.some(n => h.includes(n))) return i;
  }
  return -1;
}

(async () => {
  const runId = await startRun('meic-pymes', 'Fetch MEIC PYMEs Activas via CKAN + parse XLSX');
  let added = 0, updated = 0, total = 0;
  try {
    fs.mkdirSync(CACHE_DIR, { recursive: true });

    let CKAN_BASE = null, search = null;
    for (const host of CKAN_HOSTS) {
      try { search = await fetchJson(`${host}/package_search?${SEARCH_QS}`); CKAN_BASE = host; break; }
      catch (e) { console.warn(`[meic-pymes] ${host} → ${e.message}`); }
    }
    if (!search) throw new Error('all CKAN hosts unreachable');

    const pkg = (search.result?.results || [])[0];
    if (!pkg) throw new Error('no PYMEs package in CKAN');
    const pkgFull = await fetchJson(`${CKAN_BASE}/package_show?id=${encodeURIComponent(pkg.id)}`);
    const res = findResource(pkgFull);
    if (!res?.url) throw new Error('no resource on package');

    const ext = (path.extname(res.url) || '.xlsx').toLowerCase();
    const cachePath = path.join(CACHE_DIR, `meic-pymes${ext}`);
    let buf;
    if (fs.existsSync(cachePath) && Buffer.byteLength(fs.readFileSync(cachePath)) > 100000) {
      buf = fs.readFileSync(cachePath);
      console.log(`[meic-pymes] using cache (${(buf.length/1024).toFixed(1)} KB)`);
    } else {
      buf = await fetchBuffer(res.url);
      fs.writeFileSync(cachePath, buf);
      console.log(`[meic-pymes] downloaded ${(buf.length/1024).toFixed(1)} KB → ${cachePath}`);
    }

    const wb = XLSX.read(buf, { type: 'buffer' });
    const sheetName = wb.SheetNames[0];
    const ws = wb.Sheets[sheetName];
    const rows = XLSX.utils.sheet_to_json(ws, { header: 1, defval: null });
    if (!rows.length) throw new Error('XLSX has no rows');

    // Header is usually row 0 or 1 (sometimes a title banner is at row 0).
    let headerIdx = 0;
    for (let i = 0; i < Math.min(5, rows.length); i++) {
      const r = rows[i] || [];
      const lower = r.map(c => String(c||'').toLowerCase());
      if (lower.some(c => /(nombre|raz[oó]n)/i.test(c)) && lower.some(c => /(canton|cantón|provincia|distrito)/i.test(c))) {
        headerIdx = i; break;
      }
    }
    const headers = rows[headerIdx] || [];
    console.log(`[meic-pymes] sheet=${sheetName} headerIdx=${headerIdx} cols=${headers.length}`);
    console.log(`[meic-pymes] headers: ${headers.map(h => String(h||'').trim()).join(' | ')}`);

    const iName    = findCol(headers, 'nombre','razon','razón','denominación','empresa');
    const iAct     = findCol(headers, 'actividad','rubro','sector','codiiso','ciiu');
    const iCedula  = findCol(headers, 'cedula','cédula','identif','id_juri');
    const iProv    = findCol(headers, 'provincia');
    const iCanton  = findCol(headers, 'canton','cantón');
    const iDist    = findCol(headers, 'distrito');
    const iEmail   = findCol(headers, 'email','correo');
    const iPhone   = findCol(headers, 'telefono','teléfono');
    const iSize    = findCol(headers, 'tamaño','tamano','tamaño_empresa','classification');

    if (iName < 0) throw new Error(`no name column found; headers=${headers.slice(0,12)}`);
    console.log(`[meic-pymes] mapping: name=${iName} cedula=${iCedula} canton=${iCanton} prov=${iProv} act=${iAct}`);

    const rmap = await regionMap();
    const dataRows = rows.slice(headerIdx + 1).filter(r => Array.isArray(r) && r[iName]);

    for (const r of dataRows.slice(0, ROW_CAP)) {
      const name = String(r[iName] || '').trim(); if (!name) continue;
      const canton   = String(r[iCanton] || '').trim();
      const province = String(r[iProv] || '').trim();
      const region = canton ? await ensureRegion(rmap, canton, province) : rmap.fallback;
      const act = String(r[iAct] || '').toLowerCase();
      const vertical =
        /restaurante|gastronom|comida|cafeter|bar|panade|repostería|alimentos/.test(act) ? 'service_food' :
        /hotel|hospeda|cabin|alojami|villa|posada|lodge/.test(act) ? 'tourism_hotel' :
        /tour|turismo|aventura|operador|recreac/.test(act) ? 'tourism_tour' :
        /belleza|spa|peluquer|barber|estética|estetica/.test(act) ? 'service_beauty' :
        /tienda|comerc|venta|retail|supermerc|abarrot|ferreter/.test(act) ? 'service_retail' :
        /gimna|fitness|crossfit|yoga|deporte/.test(act) ? 'service_fitness' :
        /mascot|veterin|pet|animal/.test(act) ? 'service_pet' :
        /taller|mecan|automotr|car wash|llanter|repuesto/.test(act) ? 'service_auto' :
        /limpie|aseo|janitor|lavander/.test(act) ? 'service_cleaning' :
        /diseñ|publicid|fotograf|estudio|art|creativ|imprent/.test(act) ? 'service_creative' :
        /alquiler|rental|arrendami|inmobil/.test(act) ? 'rentals_realestate' :
        'service_retail';
      const category = vertical.startsWith('tourism_') ? 'tourism' : (vertical.startsWith('rentals_') ? 'rentals' : 'service');
      const cedula = iCedula >= 0 ? String(r[iCedula] || '').trim() : '';
      const placeSlug = cedula ? `meic-${cedula}` : slug(`meic-${name}-${region.slug}`);
      const upsertResult = await upsertPlace({
        slug: placeSlug,
        name,
        category, vertical,
        region_id: region.id,
        description: act ? `MEIC-registered: ${act}` : 'MEIC-registered PYME.',
        address: [iDist >= 0 ? r[iDist] : null, canton].filter(Boolean).join(', '),
        phone: iPhone >= 0 ? (String(r[iPhone] || '').trim() || null) : null,
        email: iEmail >= 0 ? (String(r[iEmail] || '').trim() || null) : null,
        cedula_juridica: cedula || null,
        source: 'meic-pymes',
        source_url: res.url,
      });
      if (upsertResult.inserted) added++; else updated++;
      total++;
      if (total % 1000 === 0) console.log(`[meic-pymes] progress: ${total}`);
    }

    await finishRun(runId, { rows_in: total, rows_added: added, rows_updated: updated, status: 'ok' });
    console.log(`[meic-pymes] DONE 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();
  }
})();