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(/]*>([\s\S]*?)<\/tr>/gi)].map(([, row]) => ({ html: row, cells: [...row.matchAll(/]*>([\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(/]*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(/]*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(/]*>([\s\S]*?)<\/dt>\s*]*>([\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(/]*class="cb-rt-main"[^>]*>([\s\S]*?)<\/tr>/gi)]; const details = new Map([...html.matchAll(/]*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(/]*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(/([\s\S]*?)<\/strong>/i); const detailId = row.match(/]*data-id="([^"]+)"/)?.[1]; const buyer = details.get(detailId)?.match(/>Organization<\/span>\s*]*>\s*([\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(/]*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}:\\s*]*>([\\s\\S]*?)`))?.[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*:\s*([^<]*?)\s* [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], ];