import feeds from './shared/regional-energy-feeds.json' with { type: 'json' }; import { readFileSync } from 'node:fs'; import { rootCertificates } from 'node:tls'; import { Agent, fetch as undiciFetch } from 'undici'; import { getOptionalUpstashCreds, upstashCommand } from './_upstash-rest.mjs'; import { assertZipExpansionWithin } from './_zip-expansion.mjs'; import { parsePgcb, parseNiso, parseCammesa, parseEskom, parseOns, parseAnpTrade, parseAnpPriceRows, parsePetrobangla, parseGse, pdfItems, pdfAllItems, discoverEskomCsv, discoverGsePdf, discoverPetrobanglaPdf, buildFeedSnapshot, inEffect, effectiveAt, discoverEvnPost, parseEvn, KOREM_FIXED_URLS, koremEnergySeriesUrl, koremLastDateUrl, koremCtUrls, parseKorem, COES_DEMAND_URL, coesDemandBody, parseCoes, ICCC_INDEX_URL, discoverPngFuelPosts, parsePngFuelPost, FCCC_INDEX_URL, discoverFijiFuelPdfs, parseFijiFuel, MEM_MAX_UPLOADS_PER_MONTH, memUploadFolders, memWorkbookUrl, parseMemPriceRows, } from './lib/regional-energy-feeds.mjs'; import { QATARENERGY_PDF_URL, parseQatarFuel, ERB_INDEX_URL, discoverErbBuildUps, parseErbBuildUp, npaMediaUrl, discoverNpaWorkbook, parseNpaExPumpRows, parseNpaIndicatorRows, SONABHY_PAGE_URL, SONABHY_API_HOSTS, discoverSonabhyState, discoverSonabhyApi, parseSonabhyTariffs, ZERA_HOME_URL, parseZeraPrices, DOE_HOST, DOE_MEDIA_HOST, DOE_NCR_PAGE_URL, discoverDoeNcrPdfs, parseDoeNcrPrices, ANPTL_INDEX_URL, discoverAnpTlPosts, discoverAnpTlPdf, parseAnpTlStations, CREG_PAGE_URL, parseCregPrices, URSEA_TOPIC_URL, discoverUrseaPages, parseUrseaMaximumPrices, parseUrseaReferencePrices, } from './lib/regional-fuel-feeds.mjs'; const USER_AGENT = 'WorldMonitor/1.0 (+https://www.worldmonitor.app)'; // The ANP national price sheet uses about a dozen columns, MEM's ten. const SHEET_COLUMNS = 64; const DOWNLOAD_HOSTS = new Set([ 'erp.powergrid.gov.bd', 'petrobangla.org.bd', 'www.niggrid.org', 'www.eskom.co.za', 'api.cammesa.com', 'admin.gse.com.ge', 'dados.ons.org.br', 'ons-aws-prod-opendata.s3.amazonaws.com', 'www.gov.br', 'objectstorage.ap-dcc-gazipur-1.oraclecloud15.com', 'evn.com.vn', 'portal.korem.kz', 'www.coes.org.pe', 'iccc.gov.pg', 'fccc.gov.fj', 'mem.gob.gt', 'www.qatarenergy.qa', 'www.erb.org.zm', 'npa.gov.gh', 'www.sonabhy.bf', ...SONABHY_API_HOSTS, 'www.zera.co.zw', DOE_HOST, DOE_MEDIA_HOST, 'www.anp.tl', 'creg.gov.co', 'www.gub.uy', ]); export const regionalEnergyKey = id => `energy:regional:${id}:v1`; export const regionalEnergyMetaKey = id => `seed-meta:energy:regional-${id}`; const issuerAgents = new Map(); // @railway-runtime-dependency ./data/regional-energy/pgcb-issuer.pem // @railway-runtime-dependency ./data/regional-energy/petrobangla-issuer.pem function sourceFetch(url, options) { const issuer = { 'erp.powergrid.gov.bd': 'pgcb', 'petrobangla.org.bd': 'petrobangla', }[new URL(url).hostname]; if (!issuer) return globalThis.fetch(url, options); if (!issuerAgents.has(issuer)) { // These servers omit their intermediates. Complete their chains using // the public Sectigo issuers, while retaining Node's roots and TLS checks. const intermediate = readFileSync(new URL(`./data/regional-energy/${issuer}-issuer.pem`, import.meta.url), 'utf8'); issuerAgents.set(issuer, new Agent({ connect: { ca: [...rootCertificates, intermediate] } })); } return undiciFetch(url, { ...options, dispatcher: issuerAgents.get(issuer) }); } // ZIP headers are not trusted: inflate each entry with a bounded output // before ExcelJS inflates the same bytes without one. function assertWorkbookWithinBudget(bytes, source) { try { assertZipExpansionWithin(bytes, 50_000_000); } catch (err) { throw new Error(/expands past/.test(err.message) ? `${source} workbook exceeds size budget` : `${source} workbook is ${err.message}`); } } const cellValue = cell => (cell.value && typeof cell.value === 'object' && !(cell.value instanceof Date) ? ('result' in cell.value ? cell.value.result : 'richText' in cell.value ? cell.value.richText.map(r => r.text).join('') : cell.value) : cell.value); // Loads one worksheet as arrays of cell values within the first SHEET_COLUMNS. async function readSheetRows(bytes, sheetName, source) { assertWorkbookWithinBudget(bytes, source); const { default: ExcelJS } = await import('exceljs'); const book = new ExcelJS.Workbook(); await book.xlsx.load(bytes); const sheet = book.getWorksheet(sheetName); if (!sheet) throw new Error(`Missing ${source} price sheet`); // ExcelJS indexes row values by column, so copying row.values would // allocate up to a far-right cell. Copy only cells within the price table. const sheetRows = []; sheet.eachRow(row => { const values = []; row.eachCell((cell, column) => { if (column <= SHEET_COLUMNS) values[column - 1] = cellValue(cell); }); sheetRows.push(Array.from(values)); }); return sheetRows; } // Streams only the first worksheet. The NPA ex-pump workbook keeps one sheet // per publication day (hundreds of sheets, ~3 MB); loading it whole costs // about 600 MB, streaming the newest sheet about 160 MB. async function readFirstSheetRows(bytes, source) { assertWorkbookWithinBudget(bytes, source); const { default: ExcelJS } = await import('exceljs'); const { Readable } = await import('node:stream'); const reader = new ExcelJS.stream.xlsx.WorkbookReader(Readable.from([bytes]), { worksheets: 'emit', sharedStrings: 'cache', hyperlinks: 'ignore', styles: 'ignore', entries: 'emit' }); for await (const sheet of reader) { const sheetRows = []; for await (const row of sheet) { const values = []; row.eachCell((cell, column) => { if (column <= SHEET_COLUMNS) values[column - 1] = cellValue(cell); }); sheetRows.push(Array.from(values)); } return sheetRows; } throw new Error(`Missing ${source} worksheet`); } // `pdfReader` is an injection seam for tests ONLY: it receives the downloaded // bytes and their URL and returns text items in place of pdfjs. export async function fetchRegionalEnergyFeed(id, { fetchFn = sourceFetch, nowMs = Date.now(), pdfReader = null } = {}) { const feed = feeds.find(f => f.id === id); if (!feed) throw new Error(`Unknown regional energy feed ${id}`); const downloaded = []; const approved = url => { if (url.protocol !== 'https:' || url.username || url.password || url.port || !DOWNLOAD_HOSTS.has(url.hostname)) throw new Error('Unapproved regional energy URL'); }; // Probes a file a provider names predictably. Only 404 means absent: a // challenge, redirect or outage fails the run rather than skipping a file. const exists = async input => { const url = new URL(input); approved(url); const response = await fetchFn(url.href, { method: 'HEAD', headers: { 'User-Agent': USER_AGENT }, signal: AbortSignal.timeout(25000), redirect: 'manual' }); if (response.status === 404) return false; if (!response.ok) throw new Error(`${id} provider HTTP ${response.status}`); return true; }; // `form` sends a urlencoded POST body; a redirect would drop it, so a POST must not redirect. // `timeoutMs` covers the whole response, body included. const read = async (input, format = 'text', form = undefined, timeoutMs = 25000) => { let url = new URL(input); for (let hop = 0; hop < 4; hop++) { approved(url); const request = form === undefined ? { headers: { 'User-Agent': USER_AGENT } } : { method: 'POST', body: form, headers: { 'User-Agent': USER_AGENT, 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' } }; const response = await fetchFn(url.href, { ...request, signal: AbortSignal.timeout(timeoutMs), redirect: 'manual' }); if ([301, 302, 303, 307, 308].includes(response.status)) { if (form !== undefined) throw new Error('Unexpected regional energy POST redirect'); const location = response.headers.get('location'); if (!location) throw new Error('Missing regional energy redirect location'); url = new URL(location, url); continue; } if (!response.ok) throw new Error(`${id} provider HTTP ${response.status}`); const limit = 25_000_000; if (Number(response.headers.get('content-length')) > limit) throw new Error('Energy download exceeds size budget'); const parts = []; let size = 0; for await (const chunk of response.body) { size += chunk.byteLength; if (size > limit) throw new Error('Energy download exceeds size budget'); parts.push(chunk); } const bytes = Buffer.concat(parts); downloaded.push(url.href); if (format === 'bytes') return bytes; const body = bytes.toString('utf8'); return format === 'json' ? JSON.parse(body) : body; } throw new Error('Too many regional energy redirects'); }; const readPdf = async (input, pages = 'first') => { const bytes = await read(input, 'bytes'); return pdfReader ? pdfReader(bytes, new URL(input).href) : (pages === 'all' ? pdfAllItems : pdfItems)(bytes); }; let points; if (id === 'pgcb') points = parsePgcb(await read(feed.sourceUrl)); if (id === 'niso') points = parseNiso(await read(feed.sourceUrl)); if (id === 'petrobangla') { const url = discoverPetrobanglaPdf(await read(`${feed.sourceUrl}?filters=${encodeURIComponent(JSON.stringify({ reports_type: '6922d2b181fc96cef9e99f16' }))}`)); points = parsePetrobangla(await pdfItems(await read(url, 'bytes'))); } if (id === 'cammesa') { const base = 'https://api.cammesa.com/demanda-svc'; const demand = await read(`${base}/demanda/ObtieneDemandaYTemperaturaRegion?id_region=1002`, 'json'); const generation = await read(`${base}/generacion/ObtieneGeneracioEnergiaPorRegion?id_region=1002`, 'json'); points = parseCammesa(demand, generation); } if (id === 'eskom') { const datasets = ['System_hourly_actual_and_forecasted_demand', 'Weekly_unplanned_outages', 'Hourly_Generation']; const csvs = []; for (const title of datasets) { const media = await read(`https://www.eskom.co.za/dataportal/wp-json/wp/v2/media?search=${encodeURIComponent(title)}&per_page=20&orderby=date&order=desc`, 'json'); csvs.push(await read(discoverEskomCsv(media, title))); } points = parseEskom(...csvs); } if (id === 'gse') { const url = discoverGsePdf(await read(feed.sourceUrl, 'json')); points = parseGse(await pdfItems(await read(url, 'bytes'))); } if (id === 'ons') { const catalog = await read(feed.sourceUrl, 'json'); if (catalog.success !== true) throw new Error('ONS catalog unavailable'); const resources = (catalog.result?.resources ?? []).filter(r => r.format === 'CSV' && /BALANCO_ENERGIA_SUBSISTEMA_20\d{2}\.csv$/.test(r.url)).sort((a, b) => b.url.localeCompare(a.url)); if (!resources.length) throw new Error('Missing ONS balance CSV'); points = parseOns(await read(resources[0].url)); } if (id === 'anp') { const tradePage = await read('https://www.gov.br/anp/pt-br/centrais-de-conteudo/dados-abertos/importacoes-e-exportacoes'); const tradeUrl = [...tradePage.matchAll(/href="([^"]+)"/g)].find(m => /\/ie\/derivados\/importacoes-exportacoes-derivados-[^/]+\.csv$/.test(m[1]))?.[1]; if (!tradeUrl) throw new Error('Missing ANP petroleum trade CSV'); const trade = parseAnpTrade(await read(tradeUrl)); const pricePage = await read('https://www.gov.br/anp/pt-br/assuntos/precos-e-defesa-da-concorrencia/precos/levantamento-de-precos-de-combustiveis-ultimas-semanas-pesquisadas'); const priceUrl = [...pricePage.matchAll(/href="([^"]+)"/g)].find(m => /\/resumo[_-]semanal[_-]lpc[_-].+\.xlsx$/.test(m[1]))?.[1]; if (!priceUrl) throw new Error('Missing ANP weekly price workbook'); const sheetRows = await readSheetRows(await read(priceUrl, 'bytes'), 'BRASIL', 'ANP'); points = [...trade, ...parseAnpPriceRows(sheetRows)]; } if (id === 'mem') { // Newest folder first; within a folder the highest suffix is the newest upload. let url; for (const folder of memUploadFolders(nowMs)) { for (const nameYear of folder.nameYears) { for (let n = 0; n <= MEM_MAX_UPLOADS_PER_MONTH; n++) { const candidate = memWorkbookUrl(folder.year, folder.month, nameYear, n); if (!(await exists(candidate))) break; url = candidate; } if (url) break; } if (url) break; } if (!url) throw new Error('Missing MEM daily price workbook'); points = parseMemPriceRows(await readSheetRows(await read(url, 'bytes'), 'PUBLICACIÓN WEB', 'MEM')); } if (id === 'evn') points = parseEvn(await read(discoverEvnPost(await read(feed.sourceUrl)))); if (id === 'korem') { const summary = await read(KOREM_FIXED_URLS.summary, 'json'); const maxConsumption = await read(KOREM_FIXED_URLS.maxConsumption, 'json'); const torgNames = await read(KOREM_FIXED_URLS.torgNames, 'json'); const zones = await read(KOREM_FIXED_URLS.zones, 'json'); const series = await read(koremEnergySeriesUrl(summary), 'json'); const ct = []; for (const plan of koremCtUrls(torgNames, zones, await read(koremLastDateUrl(torgNames), 'json'))) { ct.push({ zone: plan.zone, prices: await read(plan.prices, 'json'), volumes: await read(plan.volumes, 'json') }); } points = parseKorem({ series, maxConsumption, ct }); } if (id === 'coes') points = parseCoes(await read(COES_DEMAND_URL, 'json', coesDemandBody(nowMs))); if (id === 'iccc' || id === 'fccc') { // Releases are posted before they take effect. Use the newest release // already in effect, reading at most three. const candidates = id === 'iccc' ? discoverPngFuelPosts(await read(ICCC_INDEX_URL, 'json')) : discoverFijiFuelPdfs(await read(FCCC_INDEX_URL, 'json')); for (const url of candidates.slice(0, 3)) { const parsed = id === 'iccc' ? parsePngFuelPost(await read(url, 'json')) : parseFijiFuel(await pdfAllItems(await read(url, 'bytes'))); points = inEffect(parsed, nowMs, feed.utcOffsetMin); if (points.length) break; } if (!points?.length) throw new Error(`No ${id} fuel price release in effect`); } if (id === 'qatarenergy') points = parseQatarFuel(await readPdf(QATARENERGY_PDF_URL, 'all')); if (id === 'erb') { // Each monthly release is posted the day before its prices start; read // the newest release in effect, at most three. for (const release of discoverErbBuildUps(await read(ERB_INDEX_URL, 'json')).slice(0, 3)) { points = inEffect(parseErbBuildUp(await readPdf(release.url), release.period), nowMs, feed.utcOffsetMin); if (points.length) break; } if (!points?.length) throw new Error('No erb pump price release in effect'); } if (id === 'npa') { // The two lookups and the two downloads are independent, so each pair // runs together: the fetch phase has 80 s, the host answers in 5-20 s, // and the 3 MB listing workbook took 3 s to 47 s on 2026-11-02. The // worst case is then 20 s + 55 s. const [listing, indicators] = await Promise.all([ read(npaMediaUrl('Ex-Pump Prices of OMCs'), 'json', undefined, 20000).then(media => discoverNpaWorkbook(media, 'expump')), read(npaMediaUrl('Petroleum Price Indicators'), 'json', undefined, 20000).then(media => discoverNpaWorkbook(media, 'indicators')), ]); const [listingBytes, indicatorBytes] = await Promise.all([read(listing.url, 'bytes', undefined, 55000), read(indicators.url, 'bytes', undefined, 20000)]); // The workbook keeps one sheet per day with the newest first; only that // sheet is read, and it must be the day the upload is named for. const prices = parseNpaExPumpRows(await readFirstSheetRows(listingBytes, 'NPA')); if (prices[0].period !== listing.day) throw new Error('NPA listing sheet does not match its upload day'); points = [...prices, ...parseNpaIndicatorRows(await readSheetRows(indicatorBytes, 'Media Publication', 'NPA'))]; } if (id === 'sonabhy') { const state = await read(discoverSonabhyState(await read(SONABHY_PAGE_URL))); points = parseSonabhyTariffs(await read(discoverSonabhyApi(state), 'json')); } if (id === 'zera') points = parseZeraPrices(await read(ZERA_HOME_URL)); if (id === 'doe') { // The monitoring sheet is posted during its week; a sheet posted early // waits for its week to start, and the previous week serves meanwhile. for (const week of discoverDoeNcrPdfs(await read(DOE_NCR_PAGE_URL), nowMs).slice(0, 3)) { const parsed = parseDoeNcrPrices(await readPdf(week.url, 'all')); if (parsed[0].period !== week.weekStart) throw new Error('DOE monitoring week does not match its link'); points = inEffect(parsed, nowMs, feed.utcOffsetMin); if (points.length) break; } if (!points?.length) throw new Error('No doe price monitoring week in effect'); } if (id === 'anptl') { const [post] = discoverAnpTlPosts(await read(ANPTL_INDEX_URL, 'json')); points = parseAnpTlStations(await readPdf(discoverAnpTlPdf(await read(post.url, 'json')), 'all'), post.period); } if (id === 'creg') points = parseCregPrices(await read(CREG_PAGE_URL)); if (id === 'ursea') { // Maximum prices and reference prices are published days ahead of the // month they apply to; each falls back to its previous publication. const pages = discoverUrseaPages(await read(URSEA_TOPIC_URL)); const inForce = async (urls, parse) => { for (const url of urls.slice(0, 2)) { const current = inEffect(parse(await read(url)), nowMs, feed.utcOffsetMin); if (current.length) return current; } throw new Error('No ursea price publication in effect'); }; points = [...await inForce(pages.maximum, parseUrseaMaximumPrices), ...await inForce(pages.reference, parseUrseaReferencePrices)]; } if (feed.publishesAhead) points = inEffect(points, nowMs, feed.utcOffsetMin); const snapshot = buildFeedSnapshot(id, points, downloaded.at(-1), nowMs); return { ...snapshot, dataUrls: downloaded }; } // Health reads a future newestItemAt as stale content. Date periods start at // the provider's local midnight, and items published up to an hour ahead (COES // schedules) count only once they have started. export function regionalEnergyContentMeta(data, runStartedAtMs = Date.now()) { const offset = feeds.find(f => f.id === data.sourceId)?.utcOffsetMin; const times = data.observations.map(p => effectiveAt(p.period, offset)).filter(t => t <= runStartedAtMs); if (!times.length) return null; return { newestItemAt: Math.max(...times), oldestItemAt: Math.min(...times) }; } export function regionalEnergySeedOptions(id) { const feed = feeds.find(f => f.id === id); if (!feed) throw new Error('Unknown regional energy feed'); return { ttlSeconds: 14 * 86400, sourceVersion: `regional-energy-${id}-v1`, schemaVersion: 1, declareRecords: data => data.observations.length, recordCount: data => data.observations.length, validateFn: data => data.sourceId === id && data.observations.length > 0, emptyDataIsFailure: true, recordInitialFetchFailure: true, maxStaleMin: feed.intervalMin * 3, contentMeta: regionalEnergyContentMeta, maxContentAgeMin: feed.maxContentAgeMin, lockTtlMs: 300000, fetchPhaseTimeoutMs: 240000, afterFreshness: async () => { const creds = getOptionalUpstashCreds(); if (!creds) throw new Error('Missing regional energy activation credentials'); await upstashCommand(creds, ['SET', `seed-activated:energy:regional-${id}`, '1']); }, }; }