Ctrl K
ComunidadGuías…

scripts/_global-tender-feeds.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import { XMLParser } from 'fast-xml-parser';
import Papa from 'papaparse';
import { decodeHtmlEntities } from './_html-entities.mjs';
import { recordTenderBucket, readTenderBody } from './_global-tender-receipts.mjs';
import { dedupeTenders, isOpenOpportunity, normalizePublicTender, safeOfficialUrl } from './_global-tenders.mjs';

const DAY = 86_400_000;
const MAX_RECORDS = 100;
const MAX_DETAILS = 20;
const text = (value) => typeof value === 'string' ? value.trim() : '';
const isoDay = (now) => new Date(now).toISOString().slice(0, 10);

function rows(value, field) {
  if (!Array.isArray(value)) throw new Error(`Procurement response missing ${field}`);
  return value;
}

function requireRow(valid, source) {
  if (!valid) throw new Error(`${source} response contains malformed notices`);
}

function reader(fetchFn, timeoutMs = 15_000) {
  const expiresAt = Date.now() + 75_000;
  return (url, options = {}) => {
    const remaining = expiresAt - Date.now();
    if (remaining <= 0) throw new Error('Procurement source exceeded its 75s request budget');
    return fetchFn(url, { maxRetries: 0, retry429: false, ...options, timeoutMs: Math.min(options.timeoutMs ?? timeoutMs, remaining) });
  };
}

function finish(source, records, now) {
  const open = dedupeTenders(records).filter((record) => isOpenOpportunity(record, now)).slice(0, MAX_RECORDS);
  const fetchedAt = new Date(now).toISOString();
  return { records: open, status: { source, state: 'ok', recordCount: open.length, fetchedAt,
    lastSuccessfulAt: fetchedAt, stale: false } };
}

function localDate(value, timeZone) {
  const raw = text(value).replace(' ', 'T');
  if (!raw) return '';
  if (/(Z|[+-]\d{2}:\d{2})$/.test(raw)) return raw;
  const match = raw.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?$/);
  if (!match) throw new Error('Procurement response contains an invalid local timestamp');
  const parts = match.slice(1).map((part) => Number(part || 0));
  const utc = Date.UTC(parts[0], parts[1] - 1, parts[2], parts[3], parts[4], parts[5]);
  const checked = new Date(utc);
  if (checked.getUTCFullYear() !== parts[0] || checked.getUTCMonth() + 1 !== parts[1]
    || checked.getUTCDate() !== parts[2] || parts[3] > 23 || parts[4] > 59 || parts[5] > 59) {
    throw new Error('Procurement response contains an invalid local timestamp');
  }
  const formatter = new Intl.DateTimeFormat('en-GB', { timeZone, year: 'numeric', month: '2-digit',
    day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' });
  let adjusted = utc;
  for (let iteration = 0; iteration < 2; iteration++) {
    const zoned = Object.fromEntries(formatter.formatToParts(adjusted).map(({ type, value: part }) => [type, Number(part)]));
    adjusted += utc - Date.UTC(zoned.year, zoned.month - 1, zoned.day, zoned.hour, zoned.minute, zoned.second);
  }
  return new Date(adjusted).toISOString();
}

function urlWithParams(base, params) {
  const url = new URL(base);
  for (const [key, value] of Object.entries(params)) url.searchParams.set(key, String(value));
  return url;
}

function ocdsRecords(releases, { source, countryCode, region, noticeUrl }) {
  const latest = new Map();
  for (const release of releases) {
    requireRow(text(release?.ocid) && Number.isFinite(Date.parse(release?.date)), source);
    const previous = latest.get(release.ocid);
    if (!previous || Date.parse(release.date) >= Date.parse(previous.date)) latest.set(release.ocid, release);
  }
  return [...latest.values()].flatMap((release) => {
    const tender = release.tender;
    if (tender?.status && tender.status !== 'active') return [];
    if (!tender?.status && (release.awards?.length || release.contracts?.length)) return [];
    requireRow(text(tender?.title) && text(String(tender?.id ?? '')), source);
    const lots = Array.isArray(tender.lots) && tender.lots.length ? tender.lots : [null];
    return lots.filter((lot) => !lot?.status || lot.status === 'active').map((lot) => normalizePublicTender({
      source, sourceNoticeId: `${release.ocid}${lot ? `:${lot.id}` : ''}`, countryCode, region,
      officialUrl: noticeUrl(release), title: text(lot?.title) || tender.title,
      description: text(lot?.description) || tender.description, buyer: text(release.buyer?.name) || tender.procuringEntity?.name,
      publishedAt: release.date, updatedAt: release.date, deadline: lot?.tenderPeriod?.endDate || tender.tenderPeriod?.endDate,
      status: 'open', noticeType: Array.isArray(release.tag) ? release.tag.join(', ') : '',
      moneyAmount: (lot?.value || tender.value)?.amount, currency: (lot?.value || tender.value)?.currency,
      categoryCodes: [tender.classification?.id, ...(tender.items || []).map((item) => item?.classification?.id)],
      sectors: tender.mainProcurementCategory,
    }));
  });
}

async function detailsInBatches(items, readDetail) {
  const results = [];
  for (let offset = 0; offset < Math.min(items.length, MAX_DETAILS); offset += 4) {
    const settled = await Promise.allSettled(items.slice(offset, offset + 4).map(readDetail));
    const failed = settled.find(result => result.status === 'rejected');
    if (failed) throw failed.reason;
    results.push(...settled.map(result => result.value));
  }
  return results.flat();
}

export async function fetchColombia({ now = Date.now(), fetchJsonFn } = {}) {
  const source = 'colombia-secop';
  const read = reader(fetchJsonFn);
  const payload = rows(await read(urlWithParams('https://www.datos.gov.co/resource/p6dx-8zbt.json', {
    $limit: MAX_RECORDS, $order: 'fecha_de_recepcion_de ASC',
    $where: `fecha_de_recepcion_de > '${new Date(now - 5 * 3_600_000).toISOString().slice(0, 19)}' AND estado_del_procedimiento = 'Publicado'`,
    $select: 'distinct id_del_proceso,nombre_del_procedimiento,descripci_n_del_procedimiento,entidad,fecha_de_publicacion_del,fecha_de_ultima_publicaci,fecha_de_recepcion_de,urlproceso,precio_base,codigo_principal_de_categoria,estado_del_procedimiento',
  })), 'processes');
  const records = payload.map((raw) => {
    requireRow(text(raw?.id_del_proceso) && text(raw?.nombre_del_procedimiento) && text(raw?.fecha_de_recepcion_de), source);
    requireRow(safeOfficialUrl(raw.urlproceso?.url, source), source);
    return normalizePublicTender({ source, sourceNoticeId: raw.id_del_proceso, officialUrl: raw.urlproceso.url,
      countryCode: 'CO', region: 'South America', title: raw.nombre_del_procedimiento,
      description: raw.descripci_n_del_procedimiento, buyer: raw.entidad,
      publishedAt: localDate(raw.fecha_de_publicacion_del, 'America/Bogota'),
      updatedAt: localDate(raw.fecha_de_ultima_publicaci, 'America/Bogota'),
      deadline: localDate(raw.fecha_de_recepcion_de, 'America/Bogota'),
      status: raw.estado_del_procedimiento === 'Publicado' ? 'open' : 'closed',
      moneyAmount: raw.precio_base, currency: 'COP', categoryCodes: raw.codigo_principal_de_categoria });
  });
  return finish(source, records, now);
}

export async function fetchKenya({ now = Date.now(), fetchJsonFn } = {}) {
  const source = 'kenya-ppip';
  const read = reader(fetchJsonFn);
  const payload = await read(urlWithParams('https://tenders.go.ke/api/active-tenders', { perpage: MAX_RECORDS, page: 1 }));
  const records = rows(payload?.data, 'data').map((raw) => {
    requireRow(Number.isSafeInteger(raw?.id) && text(raw?.title) && text(raw?.close_at), source);
    return normalizePublicTender({ source, sourceNoticeId: String(raw.id), officialUrl: `https://tenders.go.ke/tenders/${raw.id}`,
      countryCode: 'KE', region: 'Africa', title: raw.title, buyer: raw.pe?.name,
      publishedAt: localDate(raw.published_at, 'Africa/Nairobi'), updatedAt: raw.updated_at,
      deadline: localDate(raw.close_at, 'Africa/Nairobi'),
      status: raw.status_id === 1 && raw.published === 1 && raw.terminated === 0 ? 'open' : 'closed',
      sectors: raw.procurement_category?.title, noticeType: raw.procurement_method?.title });
  });
  return finish(source, records, now);
}

export async function fetchSouthAfrica({ now = Date.now(), fetchJsonFn } = {}) {
  const source = 'south-africa-etenders';
  const read = reader(fetchJsonFn);
  const payload = await read(urlWithParams('https://ocds-api.etenders.gov.za/api/OCDSReleases', {
    PageNumber: 1, PageSize: 20, dateFrom: isoDay(now - DAY), dateTo: isoDay(now),
  }));
  return finish(source, ocdsRecords(rows(payload?.releases, 'releases'), { source, countryCode: 'ZA', region: 'Africa',
    noticeUrl: (release) => `https://www.etenders.gov.za/home/AdvertDetails/${encodeURIComponent(release.tender.id)}` }), now);
}

export async function fetchChile({ now = Date.now(), fetchJsonFn } = {}) {
  const source = 'chile-mercado-publico';
  const read = reader(fetchJsonFn);
  const month = new Date(now);
  const url = `https://api.mercadopublico.cl/APISOCDS/OCDS/listaOCDSAgnoMes/${month.getUTCFullYear()}/${month.getUTCMonth() + 1}/0/${MAX_DETAILS}`;
  const payload = await read(url);
  const releases = await detailsInBatches(rows(payload?.data, 'data'), async (row) => {
    requireRow(/^ocds-70d2nz-[\w-]+$/.test(text(row?.ocid)), source);
    const id = row.ocid.slice('ocds-70d2nz-'.length);
    const detail = await read(`https://api.mercadopublico.cl/APISOCDS/OCDS/tender/${encodeURIComponent(id)}`);
    return rows(detail?.releases, 'releases');
  });
  return finish(source, ocdsRecords(releases, { source, countryCode: 'CL', region: 'South America',
    noticeUrl: (release) => `https://www.mercadopublico.cl/Procurement/Modules/RFB/DetailsAcquisition.aspx?idlicitacion=${encodeURIComponent(release.tender.id)}` }), now);
}

export async function fetchBrazil({ now = Date.now(), fetchJsonFn } = {}) {
  const source = 'brazil-pncp';
  const read = reader(fetchJsonFn, 30_000);
  const payload = await read(urlWithParams('https://pncp.gov.br/api/consulta/v1/contratacoes/proposta', {
    dataFinal: isoDay(now + 30 * DAY).replaceAll('-', ''), pagina: 1, tamanhoPagina: 10,
  }));
  const records = rows(payload?.data, 'data').map((raw) => {
    requireRow(text(raw?.numeroControlePNCP) && text(raw?.objetoCompra) && text(raw?.dataEncerramentoProposta)
      && /^\d{14}$/.test(text(raw?.orgaoEntidade?.cnpj)) && Number.isSafeInteger(raw?.anoCompra)
      && Number.isSafeInteger(raw?.sequencialCompra), source);
    return normalizePublicTender({ source, sourceNoticeId: raw.numeroControlePNCP,
      officialUrl: `https://pncp.gov.br/app/editais/${raw.orgaoEntidade.cnpj}/${raw.anoCompra}/${raw.sequencialCompra}`,
      countryCode: 'BR', region: 'South America', title: raw.objetoCompra, buyer: raw.orgaoEntidade.razaoSocial,
      publishedAt: localDate(raw.dataPublicacaoPncp, 'America/Sao_Paulo'),
      updatedAt: localDate(raw.dataAtualizacaoGlobal, 'America/Sao_Paulo'),
      deadline: localDate(raw.dataEncerramentoProposta, 'America/Sao_Paulo'),
      status: raw.situacaoCompraId === 1 ? 'open' : 'closed', noticeType: raw.modalidadeNome,
      moneyAmount: raw.valorTotalEstimado, currency: 'BRL' });
  });
  return finish(source, records, now);
}

export async function fetchUkraine({ now = Date.now(), fetchJsonFn } = {}) {
  const source = 'ukraine-prozorro';
  const read = reader(fetchJsonFn);
  const payload = await read(urlWithParams('https://public-api.prozorro.gov.ua/api/2.5/tenders', { descending: 1, limit: MAX_DETAILS }));
  const records = await detailsInBatches(rows(payload?.data, 'data'), async (row) => {
    requireRow(/^[\w-]+$/.test(text(row?.id)), source);
    const detail = await read(`https://public-api.prozorro.gov.ua/api/2.5/tenders/${encodeURIComponent(row.id)}`);
    const raw = detail?.data;
    requireRow(raw?.id === row.id && text(raw?.title) && text(raw?.status), source);
    if (raw.status !== 'active.tendering') return [];
    return [normalizePublicTender({ source, sourceNoticeId: raw.id,
      officialUrl: `https://prozorro.gov.ua/tender/${encodeURIComponent(text(raw.tenderID) || raw.id)}`,
      countryCode: 'UA', region: 'Europe', title: raw.title, description: raw.description, buyer: raw.procuringEntity?.name,
      publishedAt: raw.dateCreated, updatedAt: raw.dateModified, deadline: raw.tenderPeriod?.endDate,
      status: 'open', moneyAmount: raw.value?.amount, currency: raw.value?.currency,
      categoryCodes: (raw.items || []).map((item) => item?.classification?.id), sectors: raw.mainProcurementCategory })];
  });
  return finish(source, records, now);
}

export async function fetchBulgaria({ now = Date.now(), fetchJsonFn, fetchTextFn } = {}) {
  const source = 'bulgaria-eop';
  const read = reader(fetchJsonFn);
  const readText = reader(fetchTextFn);
  // Day D's bucket appears around 06:00 UTC on D+1 and answers 403 until then,
  // so the early-UTC runs read the day before instead of failing.
  const bucket = { requestedDate: isoDay(now - DAY), attemptedDates: [isoDay(now - DAY)], selectedDate: null, fallbackStatus: null };
  recordTenderBucket(bucket);
  let base = `https://storage.eop.bg/open-data-${bucket.requestedDate}/`;
  let listingXml;
  try {
    listingXml = await readText(base);
  } catch (error) {
    if (![403, 404].includes(error?.status)) throw error;
    bucket.fallbackStatus = error.status;
    bucket.attemptedDates.push(isoDay(now - 2 * DAY));
    recordTenderBucket(bucket);
    base = `https://storage.eop.bg/open-data-${isoDay(now - 2 * DAY)}/`;
    listingXml = await readText(base);
  }
  const listing = new XMLParser().parse(listingXml);
  const contents = listing?.ListBucketResult?.Contents;
  const files = contents ? (Array.isArray(contents) ? contents : [contents]) : [];
  const key = files.map((entry) => entry.Key).find((name) => typeof name === 'string' && name.includes('OCDS') && name.endsWith('.json'));
  requireRow(key, source);
  const payload = await read(`${base}${encodeURIComponent(key)}`);
  const result = finish(source, ocdsRecords(rows(payload?.releases, 'releases'), { source, countryCode: 'BG', region: 'Europe',
    noticeUrl: (release) => `https://app.eop.bg/today/${encodeURIComponent(release.tender.id)}` }), now);
  bucket.selectedDate = bucket.attemptedDates.at(-1);
  recordTenderBucket(bucket);
  return result;
}

export async function fetchLatvia({ now = Date.now(), fetchJsonFn } = {}) {
  const source = 'latvia-iub';
  const read = reader(fetchJsonFn);
  const [year, month, day] = isoDay(now - DAY).split('-');
  const payload = rows(await read(`https://open.iub.gov.lv/data/notice/${year}/${month}/${day}-${month}-${year}.json`), 'notices');
  const records = payload.flatMap((raw) => {
    requireRow(text(raw?.identifier) && text(raw?.name) && text(raw?.formType), source);
    if (raw.formType !== 'competition') return [];
    return rows(raw.lots, 'lots').map((lot) => {
      const process = lot?.tenderingProcess;
      const date = text(process?.deadlineReceiptTendersEndDate).match(/^(\d{2})\/(\d{2})\/(\d{4})$/);
      const time = text(process?.deadlineReceiptTendersEndTime);
      if (!date || !/^\d{2}:\d{2}$/.test(time)) return null;
      return normalizePublicTender({ source, sourceNoticeId: `${raw.identifier}:${lot.id}`,
        officialUrl: raw.tenderingProcess?.documentsURL, countryCode: 'LV', region: 'Europe',
        title: text(lot.name) || raw.name, description: lot.description, buyer: raw.organizationData?.name,
        deadline: localDate(`${date[3]}-${date[2]}-${date[1]}T${time}:00`, 'Europe/Riga'),
        status: 'open', noticeType: raw.noticeType, categoryCodes: raw.cpvType, sectors: raw.procurementProject?.mainNatureType });
    });
  });
  return finish(source, records, now);
}

export async function fetchPoland({ now = Date.now(), fetchJsonFn } = {}) {
  const source = 'poland-bzp';
  const read = reader(fetchJsonFn);
  const payload = rows(await read(urlWithParams('https://ezamowienia.gov.pl/mo-board/api/v1/Board/Search', {
    PageSize: MAX_RECORDS, PageNumber: 1, NoticeType: 'ContractNotice',
    PublicationDateFrom: isoDay(now - DAY), PublicationDateTo: isoDay(now),
  })), 'notices');
  const records = payload.map((raw) => {
    requireRow(text(raw?.objectId) && text(raw?.orderObject) && text(raw?.noticeType), source);
    return normalizePublicTender({ source, sourceNoticeId: raw.objectId,
      officialUrl: `https://ezamowienia.gov.pl/mo-board/bzp/notice-details/id/${encodeURIComponent(raw.objectId)}`,
      countryCode: 'PL', region: 'Europe', title: raw.orderObject, buyer: raw.organizationName,
      publishedAt: raw.publicationDate, deadline: raw.submittingOffersDate,
      status: raw.outdated === false && raw.noticeType === 'ContractNotice' ? 'open' : 'closed',
      categoryCodes: text(raw.cpvCode).split(','), sectors: raw.orderType });
  });
  return finish(source, records, now);
}

function htmlText(value) {
  return decodeHtmlEntities(value.replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim();
}

function tableRows(html) {
  return [...html.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)].map(([, row]) => ({
    html: row,
    cells: [...row.matchAll(/<td\b[^>]*>([\s\S]*?)<\/td>/gi)].map(([, cell]) => cell),
  }));
}

function europeanDate(value, separator, timeZone) {
  const escaped = separator === '.' ? '\\.' : separator;
  const match = text(value).match(new RegExp(`^(\\d{2})${escaped}(\\d{2})${escaped}(\\d{4}) (\\d{2}):(\\d{2})$`));
  if (!match) throw new Error('Procurement response missing a complete closing timestamp');
  return localDate(`${match[3]}-${match[2]}-${match[1]}T${match[4]}:${match[5]}:00`, timeZone);
}

// Portals that publish a closing DATE without a time (Albania APP, Malaysia
// MyProcurement) are kept at the start of that day in the publisher's zone, the
// same reading TED's date-only deadlines get. A notice therefore leaves the open
// feed at local midnight before its real closing hour and is never shown after it.
function dateOnly(value, separator, timeZone) {
  const escaped = separator === '.' ? '\\.' : separator;
  const match = text(value).match(new RegExp(`^(\\d{2})${escaped}(\\d{2})${escaped}(\\d{4})$`));
  if (!match) throw new Error('Procurement response contains an invalid closing date');
  return localDate(`${match[3]}-${match[2]}-${match[1]}T00:00:00`, timeZone);
}

function pakistanDate(value) {
  const match = text(value).match(/^(?:[A-Za-z]+, )?([A-Za-z]+) (\d{1,2}), (\d{4}) (\d{1,2}):(\d{2}) (AM|PM)$/);
  const month = match && ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'].indexOf(match[1]) + 1;
  if (!match || !month || Number(match[4]) < 1 || Number(match[4]) > 12) throw new Error('EPADS response contains an invalid closing timestamp');
  const hour = Number(match[4]) % 12 + (match[6] === 'PM' ? 12 : 0);
  return localDate(`${match[3]}-${String(month).padStart(2, '0')}-${match[2].padStart(2, '0')}T${String(hour).padStart(2, '0')}:${match[5]}:00`, 'Asia/Karachi');
}

function sessionReader(fetchResponseFn) {
  const cookies = new Map();
  let origin;
  const read = reader(fetchResponseFn);
  return async (url, options = {}) => {
    for (let hop = 0; hop <= 3; hop++) {
      const target = new URL(url);
      origin ??= target.origin;
      if (target.origin !== origin) throw new Error('Procurement session cannot change publisher origin');
      const response = await read(target.href, {
        ...options, redirect: 'manual',
        headers: { Accept: 'text/html', ...(cookies.size ? { Cookie: [...cookies].map(([key, value]) => `${key}=${value}`).join('; ') } : {}), ...options.headers },
      });
      for (const cookie of response.headers.getSetCookie()) {
        const pair = cookie.split(';', 1)[0];
        const equals = pair.indexOf('=');
        if (equals > 0) cookies.set(pair.slice(0, equals), pair.slice(equals + 1));
      }
      const body = await readTenderBody(response, 'text');
      if (![301, 302, 303, 307, 308].includes(response.status)) return body;
      const location = response.headers.get('Location');
      if (!location || hop === 3) throw new Error('Procurement session redirect limit or missing location');
      url = new URL(location, target).href;
      if (response.status === 303 || ([301, 302].includes(response.status) && options.method === 'POST')) {
        options = { ...options, method: 'GET', body: undefined };
      }
    }
  };
}

export async function fetchPakistanEpads({ now = Date.now(), fetchTextFn } = {}) {
  const source = 'pakistan-epads';
  const read = reader((url, options) => fetchTextFn(url, { ...options, redirect: 'error' }));
  const html = await read('https://epads.gov.pk/', { headers: { Accept: 'text/html' } });
  requireRow(/Procurement Title/.test(html) && /table-border-bottom-0/.test(html), source);
  const tenderRows = tableRows(html).filter(({ html: row }) => /\/opportunities\/federal\/procurements\//.test(row));
  if (!tenderRows.length && !/No (?:procurements|records|opportunities) found/i.test(html)) throw new Error('EPADS response missing tender rows');
  const records = await detailsInBatches(tenderRows, async ({ cells }) => {
    const link = cells[1]?.match(/href="(\/opportunities\/federal\/procurements\/(\d+))"/);
    const labels = [...(cells[1] || '').matchAll(/<span\b[^>]*title="([^"]+)"[^>]*>/gi)].map(([, value]) => htmlText(value));
    const dates = htmlText(cells[2] || '').match(/Published On:\s*(.*?)\s*Closing On:\s*(.*)$/);
    requireRow(cells.length === 5 && link && labels.length >= 2 && dates, source);
    const officialUrl = `https://epads.gov.pk${link[1]}`;
    let deadline;
    if (/^(?:\d+[dhms]\s*)+Left$/.test(dates[2])) {
      const detail = await read(officialUrl, { headers: { Accept: 'text/html' } });
      const timestamp = detail.match(/showBiddingTimer\("(\d{10})",\s*"0"\)/)?.[1];
      requireRow(timestamp, source);
      deadline = new Date(Number(timestamp) * 1000).toISOString();
    } else {
      deadline = pakistanDate(dates[2]);
    }
    return [normalizePublicTender({ source, sourceNoticeId: link[2], officialUrl,
      countryCode: 'PK', region: 'Asia', title: labels[0], buyer: labels[1],
      publishedAt: pakistanDate(dates[1]), deadline, status: 'open', noticeType: htmlText(cells[3]) })];
  });
  return finish(source, records, now);
}

export async function fetchGeorgia({ now = Date.now(), fetchResponseFn } = {}) {
  const source = 'georgia-spa';
  const read = sessionReader(fetchResponseFn);
  const base = 'https://tenders.procurement.gov.ge/public/';
  await read(`${base}?lang=en`);
  const html = await read(`${base}library/controller.php?action=search_app&search=1&app_status=10&app_t=0`);
  requireRow(/id="list_apps_by_subject"/.test(html), source);
  const notices = [...html.matchAll(/ShowApp\((\d+),'[^']*',\d+,'([a-f0-9]+)'\)/g)];
  if (!notices.length && !/0\s+Record\(s\)/.test(html)) throw new Error('Georgia response missing tender rows');
  const records = await detailsInBatches(notices, async ([, id, key]) => {
    const detail = await read(`${base}library/controller.php?action=app_main&app_id=${id}&key=${key}`);
    const fields = Object.fromEntries(tableRows(detail).filter(({ cells }) => cells.length === 2)
      .map(({ cells }) => cells.map(htmlText)));
    requireRow(fields['Announcment number'] && fields['Procurement proceeding status'], source);
    if (!['Tender announced', 'Bidding commenced'].includes(fields['Procurement proceeding status'])) return [];
    const category = fields['Procuring category'];
    const title = detail.match(/<div\b[^>]*class="blabla"[^>]*>([\s\S]*?)<\/div>/)?.[1];
    requireRow(category && fields['Procuring entities'], source);
    const amount = fields['Estimated value of procurement']?.match(/^([\d`.,]+)\s+GEL$/);
    return [normalizePublicTender({ source, sourceNoticeId: fields['Announcment number'],
      officialUrl: `${base}?go=${id}&lang=en`, countryCode: 'GE', region: 'Asia',
      title: title ? htmlText(title) : category, buyer: fields['Procuring entities'],
      publishedAt: europeanDate(fields['Procurement announcment date'], '.', 'Asia/Tbilisi'),
      deadline: europeanDate(fields['Deadline for bid submission'], '.', 'Asia/Tbilisi'), status: 'open',
      noticeType: fields['Procurement type'], categoryCodes: category.match(/\b\d{8}\b/g) || [],
      moneyAmount: amount ? amount[1].replace(/[` ,]/g, '') : undefined, currency: amount ? 'GEL' : '' })];
  });
  return finish(source, records, now);
}

export async function fetchArmenia({ now = Date.now(), fetchResponseFn } = {}) {
  const source = 'armenia-armeps';
  const read = sessionReader(fetchResponseFn);
  const base = 'https://www.armeps.am';
  await read(`${base}/epps/home.do`);
  await read(`${base}/epps/changeLanguage.do?language=en&url=`);
  const html = await read(`${base}/epps/viewCFTSAction.do`, { timeoutMs: 50_000, method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({ status: 'cft.status.tender.submission', title: '', uniqueId: '', contractAuthority: '', contractType: '', procedure: '' }).toString() });
  requireRow(/Search results/.test(html) && /CfT Title:/.test(html), source);
  requireRow(/name="status"\s+value="cft\.status\.tender\.submission"/.test(html), source);
  const notices = [...html.matchAll(/href="(\/epps\/cft\/prepareViewCfTWS\.do\?resourceId=(\d+))"/g)];
  if (!notices.length && !/0\s+results in total/.test(html)) throw new Error('Armenia response missing tender rows');
  const records = await detailsInBatches(notices.slice(0, 10), async ([, path, id]) => {
    const detail = await read(`${base}${path}`);
    const fields = Object.fromEntries([...detail.matchAll(/<dt\b[^>]*>([\s\S]*?)<\/dt>\s*<dd\b[^>]*>([\s\S]*?)<\/dd>/gi)]
      .map(([, label, value]) => [htmlText(label).replace(/\s*:\s*$/, ''), htmlText(value)]));
    requireRow(fields['CfT Id'] && fields['Title'] && fields['Name of Contracting Authority'], source);
    if (fields['Date of Awarding']) return [];
    const deadline = europeanDate(fields['Time-limit for receipt of tenders or requests to participate'], '/', 'Asia/Yerevan');
    const amount = fields['Estimated value (AMD)'];
    return [normalizePublicTender({ source, sourceNoticeId: id, officialUrl: `${base}${path}`,
      countryCode: 'AM', region: 'Asia', title: fields['Title (EN)'] || fields.Title,
      buyer: fields['Name of Contracting Authority'], publishedAt: europeanDate(fields['Date of Publication/Invitation'], '/', 'Asia/Yerevan'),
      deadline, status: 'open', noticeType: fields.Procedure, categoryCodes: fields['CPV Codes']?.match(/\b\d{8}\b/g) || [],
      moneyAmount: amount && /^[\d,.\s]+$/.test(amount) ? amount.replace(/[,\s]/g, '') : undefined,
      currency: amount ? 'AMD' : '' })];
  });
  return finish(source, records, now);
}

export async function fetchPakistan({ now = Date.now(), fetchTextFn } = {}) {
  const source = 'pakistan-ppra';
  const read = reader(fetchTextFn);
  const html = await read('https://epms.ppra.gov.pk/public/tenders/active-tenders', { headers: { Accept: 'text/html' } });
  const mainRows = [...html.matchAll(/<tr\b[^>]*class="cb-rt-main"[^>]*>([\s\S]*?)<\/tr>/gi)];
  const details = new Map([...html.matchAll(/<tr\b[^>]*id="cb-rt-detail-([^"]+)"[^>]*>([\s\S]*?)<\/tr>/gi)]
    .map(([, id, row]) => [id, row]));
  if (!mainRows.length) throw new Error('Pakistan response missing tender rows');
  const records = mainRows.slice(0, MAX_RECORDS).map(([, row]) => {
    const cells = Object.fromEntries([...row.matchAll(/<td\b[^>]*data-label="([^"]+)"[^>]*>([\s\S]*?)<\/td>/gi)]
      .map(([, label, content]) => [label, content]));
    const link = cells.Actions?.match(/href="(\/public\/tenders\/tender-details\/[\w-]+)"/);
    const title = cells['Tender Details']?.match(/<strong>([\s\S]*?)<\/strong>/i);
    const detailId = row.match(/<button\b[^>]*data-id="([^"]+)"/)?.[1];
    const buyer = details.get(detailId)?.match(/>Organization<\/span>\s*<span\b[^>]*>\s*<strong>([\s\S]*?)<\/strong>/i)?.[1];
    const closing = cells.Closing && htmlText(cells.Closing);
    const match = closing?.match(/^([A-Za-z]{3}) (\d{1,2}), (\d{4}) (\d{1,2}):(\d{2}) (AM|PM)$/);
    requireRow(link && title && match, source);
    const month = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'].indexOf(match[1]) + 1;
    requireRow(month, source);
    const hour = Number(match[4]) % 12 + (match[6] === 'PM' ? 12 : 0);
    return normalizePublicTender({ source, sourceNoticeId: link[1].split('/').pop(),
      officialUrl: `https://epms.ppra.gov.pk${link[1]}`, countryCode: 'PK', region: 'Asia', title: htmlText(title[1]),
      buyer: buyer ? htmlText(buyer) : '',
      deadline: localDate(`${match[3]}-${String(month).padStart(2, '0')}-${match[2].padStart(2, '0')}T${String(hour).padStart(2, '0')}:${match[5]}:00`, 'Asia/Karachi'),
      status: /Published/.test(htmlText(cells.Status || '')) ? 'open' : 'closed' });
  });
  return finish(source, records, now);
}

const ALBANIA_COLUMNS = ['Autoriteti_kontraktues', 'Numri_i_references', 'Objekti_i_prokurimit', 'Lloji_i_procedures',
  'Tipi_i_kontrates', 'Lloji_i_marreveshjes_kuader', 'Fondi_limit', 'Data_e_publikimit', 'Data_e_hapjes', 'Data_e_mbylljes',
  'Anulluar', 'Arsyeja_e_anullimit', 'Pezulluar', 'Fituesi', 'NIPT_i_fituesit', 'Vlera_e_fituesit',
  'Vlera_e_fituesit_ne_lidhjen_e_kontrates', 'Lidhja_e_kontrates_me_TVSH', 'Numri_i_ofertave_te_dorezuara',
  'Numri_i_ofertave_te_kualifikuara', 'Kodet_CPV'];
const ALBANIA_EXPORT_PAGE = 'https://www.app.gov.al/eksportimi-i-procedurave-te-publikuara/';

// APP regenerates one CSV per publication year every day (about 7.6 MB and
// 23k rows by November, served at roughly 160 KB/s), so the current year is
// one long read within the shared budget. January through March also read the
// previous year, whose late-year procedures can stay open for up to 90 days;
// two concurrent exports measured 60 s wall, inside the 70 s timeout.
export async function fetchAlbania({ now = Date.now(), fetchTextFn } = {}) {
  const source = 'albania-app';
  const read = reader(fetchTextFn, 70_000);
  const today = new Date(now);
  const years = [today.getUTCFullYear(), ...(today.getUTCMonth() <= 2 ? [today.getUTCFullYear() - 1] : [])];
  const files = await Promise.all(years.map((year) => read(`https://www.app.gov.al/GetData/ExportDocument?year=${year}`,
    { headers: { Accept: 'text/csv, text/plain, */*' } })));
  const records = files.flatMap((csv) => {
    const parsed = Papa.parse(text(csv).replace(/^\uFEFF/, ''), { header: true, skipEmptyLines: true });
    requireRow(parsed.meta?.fields?.length === ALBANIA_COLUMNS.length
      && ALBANIA_COLUMNS.every((column, index) => parsed.meta.fields[index] === column), source);
    return rows(parsed.data, 'procedures').map((raw) => {
      requireRow(Object.keys(raw).length === ALBANIA_COLUMNS.length && /^REF-\d{5}-\d{2}-\d{2}-\d{4}$/.test(text(raw.Numri_i_references))
        && text(raw.Objekti_i_prokurimit) && text(raw.Autoriteti_kontraktues) && ['Jo', 'Po'].includes(text(raw.Anulluar))
        && ['Jo', 'Po'].includes(text(raw.Pezulluar)), source);
      // Cancelled, suspended and awarded procedures are not open invitations;
      // a procedure without a closing date cannot be represented as open.
      if (raw.Anulluar === 'Po' || raw.Pezulluar === 'Po' || text(raw.Fituesi) || !text(raw.Data_e_mbylljes)) return null;
      const fund = text(raw.Fondi_limit).replace(/,/g, '');
      requireRow(!fund || /^\d+(?:\.\d+)?$/.test(fund), source);
      // A zero limit fund (ticket purchases) is an absent estimate, not a value.
      const amount = Number(fund) > 0 ? fund : undefined;
      return normalizePublicTender({ source, sourceNoticeId: raw.Numri_i_references, officialUrl: ALBANIA_EXPORT_PAGE,
        countryCode: 'AL', region: 'Europe', title: raw.Objekti_i_prokurimit, buyer: raw.Autoriteti_kontraktues,
        publishedAt: dateOnly(raw.Data_e_publikimit, '.', 'Europe/Tirane'), deadline: dateOnly(raw.Data_e_mbylljes, '.', 'Europe/Tirane'),
        status: 'open', noticeType: raw.Lloji_i_procedures, sectors: raw.Tipi_i_kontrates,
        categoryCodes: text(raw.Kodet_CPV).match(/\b\d{8}\b/g) || [], moneyAmount: amount, currency: amount ? 'ALL' : '' });
    });
  });
  return finish(source, records, now);
}

const DGCP_PROCESSES_URL = 'https://datosabiertos.dgcp.gob.do/api-dgcp/v1/procesos';

// The open-data API lists processes newest first with no total count and a
// 60 requests/minute limit; two pages of 200 cover the published processes of
// the last few days, which is where every open process lives.
export async function fetchDominicanRepublic({ now = Date.now(), fetchJsonFn } = {}) {
  const source = 'dominican-republic-dgcp';
  const read = reader(fetchJsonFn);
  const records = [];
  // Only distinct, still-open published processes count toward the sample;
  // awarded, closed-stage, already-expired and repeated rows neither fill it
  // nor fail the page.
  const open = () => dedupeTenders(records).filter((record) => isOpenOpportunity(record, now)).length;
  for (let page = 1; page <= 2 && open() < MAX_RECORDS; page++) {
    const payload = await read(urlWithParams(DGCP_PROCESSES_URL, { page, limit: 200 }));
    requireRow(payload?.hasError === false, source);
    const content = rows(payload.payload?.content, 'content');
    for (const raw of content) {
      requireRow(text(raw?.codigo_proceso) && text(raw?.estado_proceso), source);
      if (raw.estado_proceso !== 'Proceso publicado' || open() >= MAX_RECORDS) continue;
      const deadline = Date.parse(raw.fecha_fin_recepcion_ofertas);
      requireRow(Number.isFinite(deadline), source);
      if (deadline <= now) continue;
      requireRow(text(raw.titulo) && Number.isFinite(Date.parse(raw.fecha_publicacion)) && safeOfficialUrl(raw.url, source), source);
      records.push(normalizePublicTender({ source, sourceNoticeId: raw.codigo_proceso, officialUrl: raw.url,
        countryCode: 'DO', region: 'North America', title: raw.titulo, description: raw.descripcion, buyer: raw.unidad_compra,
        publishedAt: raw.fecha_publicacion, deadline: raw.fecha_fin_recepcion_ofertas, status: 'open',
        noticeType: raw.modalidad, sectors: raw.objeto_proceso, moneyAmount: raw.monto_estimado, currency: raw.divisa }));
    }
    if (content.length < 200) break;
  }
  return finish(source, records, now);
}

// MyProcurement answers its advert search as JSON wrapping rendered cards.
// The plain GET needs neither the page's session cookie nor its CSRF token.
export async function fetchMalaysia({ now = Date.now(), fetchJsonFn } = {}) {
  const source = 'malaysia-myprocurement';
  const read = reader(fetchJsonFn);
  const payload = await read(urlWithParams('https://myprocurement.treasury.gov.my/procurements/fetch',
    { type: 'advertisements', category: 'tender', itemsPerPage: MAX_RECORDS, page: 1 }));
  requireRow(typeof payload?.html === 'string' && Number.isSafeInteger(payload.total) && Number.isSafeInteger(payload.lastPage), source);
  const cards = payload.html.split(/<div\b[^>]*x-data="\{ selected: false, open: true \}"/).slice(1);
  // A page that renders fewer cards than the publisher's own total promises is
  // a partial render or a changed card marker, not a smaller catalogue.
  if (cards.length !== Math.min(MAX_RECORDS, payload.total)) throw new Error('Malaysia response missing tender cards');
  const field = (card, label) => card.match(new RegExp(`>${label}:</div>\\s*<div\\b[^>]*>([\\s\\S]*?)</div>`))?.[1];
  const records = cards.map((card) => {
    const id = card.match(/select-procurement', \{ id: (\d+) \}/)?.[1];
    const link = card.match(/href="(https:\/\/myprocurement\.treasury\.gov\.my\/advertisements\/tender\/[a-f0-9]+)"[^>]*>([\s\S]*?)<\/a>/);
    const invited = card.match(/Tarikh Pelawaan:\s*([^<]*?)\s*</)?.[1];
    const number = card.match(/No\. Tender<\/span>:\s*([^<]*?)\s*</)?.[1];
    const fields = Object.fromEntries(['Kementerian', 'Agensi', 'Kategori Perolehan', 'Kod Bidang', 'Tarikh Tutup Pelawaan', 'Harga Indikatif Jabatan']
      .map((label) => [label, field(card, label)]));
    requireRow(id && link && htmlText(link[2]) && invited && number && Object.values(fields).every((value) => typeof value === 'string'), source);
    const price = htmlText(fields['Harga Indikatif Jabatan']).match(/^RM ([\d,]+\.\d{2})$/);
    return normalizePublicTender({ source, sourceNoticeId: id, officialUrl: link[1], countryCode: 'MY', region: 'Asia',
      title: htmlText(link[2]), description: `No. Tender: ${htmlText(number)}`, buyer: htmlText(fields.Agensi),
      publishedAt: dateOnly(invited, '/', 'Asia/Kuala_Lumpur'), deadline: dateOnly(fields['Tarikh Tutup Pelawaan'], '/', 'Asia/Kuala_Lumpur'),
      status: 'open', noticeType: htmlText(fields.Kementerian), sectors: htmlText(fields['Kategori Perolehan']),
      categoryCodes: htmlText(fields['Kod Bidang']).split(',').map((code) => code.trim()).filter((code) => /^\d{6}$/.test(code)),
      moneyAmount: price ? price[1].replace(/,/g, '') : undefined, currency: price ? 'MYR' : '' });
  });
  return finish(source, records, now);
}

export const ADDITIONAL_TENDER_ADAPTERS = [
  ['colombia-secop', fetchColombia], ['kenya-ppip', fetchKenya], ['south-africa-etenders', fetchSouthAfrica],
  ['chile-mercado-publico', fetchChile], ['brazil-pncp', fetchBrazil], ['ukraine-prozorro', fetchUkraine],
  ['bulgaria-eop', fetchBulgaria], ['latvia-iub', fetchLatvia], ['poland-bzp', fetchPoland], ['pakistan-ppra', fetchPakistan],
  ['pakistan-epads', fetchPakistanEpads], ['georgia-spa', fetchGeorgia], ['armenia-armeps', fetchArmenia],
  ['albania-app', fetchAlbania], ['dominican-republic-dgcp', fetchDominicanRepublic], ['malaysia-myprocurement', fetchMalaysia],
];