// Turkey's crude oil imports by country of origin, from the monthly petroleum // market sector reports of EPDK (Enerji Piyasası Düzenleme Kurumu, the energy // market regulator). Turkey files no partner-level HS 2709 imports to UN // Comtrade, so this is the only recurring origin source for it. // // The listing page links one entry per report month. Reports up to 2025 are // Word files whose table 2.1.1 gives the month's imports by country; from // 2026 the tables ship as an Excel annex whose Tablo 2 is cumulative from // January, so a month is the difference of two cumulative tables. Quantities // are tonnes ("Ham Petrol" column), so shares from them are volume shares. import { createRequire } from 'node:module'; import JSZip from 'jszip'; import { assertZipExpansionWithin } from './_zip-expansion.mjs'; export { assertZipExpansionWithin }; export const EPDK_LISTING_URL = 'https://www.epdk.gov.tr/Detay/Icerik/3-0-104/aylik-sektor-raporu'; const EPDK_ORIGIN = 'https://www.epdk.gov.tr'; const TR_MONTHS = ['ocak', 'şubat', 'mart', 'nisan', 'mayıs', 'haziran', 'temmuz', 'ağustos', 'eylül', 'ekim', 'kasım', 'aralık']; const MONTH_WORD = TR_MONTHS.join('|'); const lowerTr = (s) => String(s ?? '').toLocaleLowerCase('tr'); const pad2 = (n) => String(n).padStart(2, '0'); function decodeEntities(s) { return String(s) .replace(/&#(\d+);/g, (_, n) => String.fromCodePoint(Number(n))) .replace(/&#x([0-9a-f]+);/gi, (_, n) => String.fromCodePoint(parseInt(n, 16))) .replace(/ /g, ' ') .replace(/"/g, '"') .replace(/</g, '<') .replace(/>/g, '>') .replace(/&/g, '&'); } const plainText = (html) => decodeEntities(String(html).replace(/<[^>]+>/g, ' ')).replace(/\s+/g, ' ').trim(); /** "2026 Yılı Petrol Piyasası Ağustos Ayı Sektör Raporu" -> "2026-08"; null without one year and one month name. */ export function monthFromLabel(label) { const text = lowerTr(label).replace(/[_-]/g, ' '); const year = text.match(/\b(20\d{2})\b/); const month = text.match(new RegExp(`(?:^|[^\\p{L}])(${MONTH_WORD})(?=$|[^\\p{L}])`, 'u')); if (!year || !month) return null; return `${year[1]}-${pad2(TR_MONTHS.indexOf(month[1]) + 1)}`; } /** True only for https://www.epdk.gov.tr URLs: listing links and fetches never leave that origin. */ export function isEpdkUrl(url) { try { const u = new URL(url); return u.protocol === 'https:' && u.origin === EPDK_ORIGIN; } catch { return false; } } /** * Report months on the listing page, each with its downloads in page order. * `kind` comes from the link's icon: word, excel or pdf. * @returns {Array<{month: string, label: string, documents: Array<{url: string, kind: string}>}>} */ export function parseEpdkListing(html) { const reports = []; const items = String(html).split(/]*class="accordion-pop"[^>]*>/).slice(1); for (const item of items) { const labelMatch = item.match(/]*data-toggle="collapse"[^>]*>([\s\S]*?)<\/a>/); if (!labelMatch) continue; const label = plainText(labelMatch[1]); const month = monthFromLabel(label); if (!month) continue; // Downloads sit between this label and the next nested item. const own = item.slice(labelMatch.index + labelMatch[0].length).split(/]*href="([^"]*DownloadDocument\?id=[^"]*)"[^>]*>([\s\S]*?)<\/a>/g)) { const icon = a[2].match(/\/(word|excel|pdf)\.png/); let url; try { url = new URL(decodeEntities(a[1]), EPDK_ORIGIN).href; } catch { continue; } // An absolute or protocol-relative href could point anywhere. if (!isEpdkUrl(url)) continue; documents.push({ url, kind: icon ? icon[1] : 'unknown' }); } if (documents.length > 0) reports.push({ month, label, documents }); } return reports; } const TABLE_TITLE = /^Tablo\s*\d+(?:\.\d+)*\s*[:.]/; // Elements named `name` (`` and ``, not ``; let at = 0; for (;;) { const start = xml.indexOf(open, at); if (start < 0) return; if (/[\w:-]/.test(xml[start + open.length] ?? '')) { at = start + open.length; continue; } const tagEnd = xml.indexOf('>', start); if (tagEnd < 0) return; const attrs = xml.slice(start + open.length, tagEnd); if (attrs.endsWith('/')) { yield { attrs: attrs.slice(0, -1), body: null }; at = tagEnd + 1; continue; } const end = xml.indexOf(close, tagEnd); if (end < 0) return; yield { attrs, body: xml.slice(tagEnd + 1, end) }; at = end + close.length; } } const xmlAttr = (attrs, name) => attrs.match(new RegExp(`(?:^|\\s)${name}="([^"]*)"`))?.[1] ?? null; // Text of an or : its runs joined, phonetic () runs dropped. const runText = (xml) => decodeEntities( [...String(xml).replace(//g, '').matchAll(/]*)?>([^<]*)<\/t>/g)].map((m) => m[1]).join(''), ); // "AB12" -> { col: 28, row: 12 } function cellRef(ref) { const m = /^([A-Z]+)(\d+)$/.exec(ref ?? ''); if (!m) return null; let col = 0; for (const ch of m[1]) col = col * 26 + ch.charCodeAt(0) - 64; return { col, row: Number(m[2]) }; } function cellValue(attrs, body, sharedStrings) { if (body === null) return undefined; const type = xmlAttr(attrs, 't'); if (type === 'inlineStr') return runText(body.match(/([\s\S]*?)<\/is>/)?.[1] ?? ''); const v = body.match(/([^<]*)<\/v>/)?.[1]; if (v === undefined) return undefined; if (type === 's') return sharedStrings[Number(v)] ?? ''; if (type === 'str') return decodeEntities(v); if (type === 'b') return v === '1'; if (type === 'e') return decodeEntities(v); const n = Number(v); return Number.isFinite(n) ? n : decodeEntities(v); } /** * Rows of every worksheet in workbook order, each a sparse array indexed * from column A at 0. A merged range's cells all read as its top-left value. * Reads only cell values: a full workbook model (ExcelJS) needed ~480 MB for * a 514 KB annex with ~2,100 merged ranges. */ async function xlsxSheetRows(buffer) { const zip = await JSZip.loadAsync(buffer); const read = (path) => zip.file(path)?.async('string') ?? Promise.resolve(null); const sstXml = await read('xl/sharedStrings.xml'); const sharedStrings = sstXml ? [...xmlElements(sstXml, 'si')].map((si) => runText(si.body ?? '')) : []; const relsXml = await read('xl/_rels/workbook.xml.rels') ?? ''; const targets = new Map([...xmlElements(relsXml, 'Relationship')].map((r) => [xmlAttr(r.attrs, 'Id'), xmlAttr(r.attrs, 'Target')])); const sheets = []; for (const sheet of xmlElements(await read('xl/workbook.xml') ?? '', 'sheet')) { const target = targets.get(xmlAttr(sheet.attrs, 'r:id')); if (!target) continue; const xml = await read(target.startsWith('/') ? target.slice(1) : `xl/${target}`); if (!xml) continue; const rows = new Map(); for (const row of xmlElements(xml, 'row')) { if (row.body === null) continue; const rowNumber = Number(xmlAttr(row.attrs, 'r')); const cells = []; for (const c of xmlElements(row.body, 'c')) { const ref = cellRef(xmlAttr(c.attrs, 'r')); const value = cellValue(c.attrs, c.body, sharedStrings); if (ref && value !== undefined) cells[ref.col - 1] = value; } if (Number.isInteger(rowNumber)) rows.set(rowNumber, cells); } for (const merge of xmlElements(xml, 'mergeCell')) { const [from, to] = (xmlAttr(merge.attrs, 'ref') ?? '').split(':').map(cellRef); if (!from || !to) continue; const master = rows.get(from.row)?.[from.col - 1]; if (master === undefined) continue; for (let r = from.row; r <= to.row; r++) { if (!rows.has(r)) rows.set(r, []); for (let col = from.col; col <= to.col; col++) rows.get(r)[col - 1] = master; } } sheets.push([...rows.entries()].sort((a, b) => a[0] - b[0]).map(([, cells]) => cells)); } return sheets; } /** Tables of an .xlsx, each titled by the nearest "Tablo N:" row above it. */ export async function tablesFromXlsx(buffer) { const tables = []; for (const rows of await xlsxSheetRows(buffer)) { let current = null; for (const row of rows) { if (!row.some((v) => v !== undefined)) continue; const cells = row.map((v) => (typeof v === 'string' ? v.trim() : v)); const title = cells.find((c) => typeof c === 'string' && TABLE_TITLE.test(c)); if (title) { current = { title, rows: [] }; tables.push(current); continue; } if (current) current.rows.push(cells); } } return tables; } // Word splits one word across runs ("202" + "5"), so text is the runs joined // as written, never tags replaced by spaces. const wordText = (xml) => decodeEntities( [...String(xml).matchAll(/]*)?>([^<]*)<\/w:t>||/g)].map((m) => m[1] ?? ' ').join(''), ).replace(/\s+/g, ' ').trim(); // Element spans found with indexOf, never a lazy `[\s\S]*?` regex: that // rescans the rest of the document for every unclosed tag. `open` is the tag // name without `>`, so `` and `` but not // ``. Stops at the first unclosed element. function elementSpans(xml, open, close) { const spans = []; let at = 0; for (;;) { const start = xml.indexOf(open, at); if (start < 0) return spans; if (/[\w:-]/.test(xml[start + open.length] ?? '')) { at = start + open.length; continue; } const end = xml.indexOf(close, start + open.length); if (end < 0) return spans; spans.push(xml.slice(start, end + close.length)); at = end + close.length; } } /** Tables of a .docx, each titled by the last paragraph before it. */ export async function tablesFromDocx(buffer) { const zip = await JSZip.loadAsync(buffer); const file = zip.file('word/document.xml'); if (!file) return []; const xml = await file.async('string'); const tables = []; let cursor = 0; let lastParagraph = ''; for (;;) { const start = xml.indexOf('', cursor); if (start < 0) break; const end = xml.indexOf('', start); if (end < 0) break; for (const p of elementSpans(xml.slice(cursor, start), '')) { const text = wordText(p); if (text) lastParagraph = text; } cursor = end + ''.length; const rows = elementSpans(xml.slice(start, cursor), '') .map((r) => elementSpans(r, '').map(wordText)); tables.push({ title: lastParagraph, rows }); } return tables; } /** 2.923.092,68 (Word) or a number (Excel) -> tonnes; blank -> 0; anything else null. */ export function parseTonnes(v) { if (typeof v === 'number') return Number.isFinite(v) ? v : null; const s = String(v ?? '').trim(); if (s === '' || s === '-') return 0; if (!/^-?[\d.]+(,\d+)?$/.test(s)) return null; const n = Number(s.replace(/\./g, '').replace(',', '.')); return Number.isFinite(n) ? n : null; } /** * Crude imports by country from one table, or null when the table is not the * imports-by-country table. `cumulative` marks a January-to-month table. * @returns {{month: string, cumulative: boolean, totalT: number, rows: Array<{name: string, tonnes: number}>} | null} */ export function crudeImportsFromTable(table) { const title = String(table?.title ?? ''); if (!/Ülkelere Göre/.test(title) || !/İthalat/.test(title)) return null; const t = lowerTr(title); const range = t.match(new RegExp(`ocak\\s*-\\s*(${MONTH_WORD})\\s+(20\\d{2})`, 'u')); const single = t.match(new RegExp(`(${MONTH_WORD})\\s+(20\\d{2})\\s+dönemi`, 'u')) ?? (/kümülatif/.test(t) ? t.match(new RegExp(`\\((${MONTH_WORD})\\s+(20\\d{2})\\)`, 'u')) : null); const hit = range ?? single; if (!hit) return null; const month = `${hit[2]}-${pad2(TR_MONTHS.indexOf(hit[1]) + 1)}`; const cumulative = Boolean(range) || /kümülatif/.test(t); const rows = table.rows ?? []; const headerAt = rows.findIndex((r) => r.includes('Ülke') && r.includes('Ham Petrol')); if (headerAt < 0) return null; const nameCol = rows[headerAt].indexOf('Ülke'); const crudeCol = rows[headerAt].indexOf('Ham Petrol'); const out = []; let totalT = null; for (const r of rows.slice(headerAt + 1)) { const name = String(r[nameCol] ?? '').trim(); if (!name) continue; const tonnes = parseTonnes(r[crudeCol]); if (tonnes === null || tonnes < 0) return null; if (name === 'Toplam') { totalT = tonnes; break; } out.push({ name, tonnes }); } if (totalT === null) return null; return { month, cumulative, totalT, rows: out }; } /** Turkish country name -> ISO2, from ICU region names plus the forms EPDK uses. */ const ALIASES = { 'rusya federasyonu': 'RU', abd: 'US', 'amerika birleşik devletleri': 'US', ingiltere: 'GB', 'birleşik krallık': 'GB', bae: 'AE', 'kktc': 'CY', hollanda: 'NL', 'güney kore': 'KR', 'kuzey makedonya': 'MK', 'fildişi sahili': 'CI', 'kongo cumhuriyeti': 'CG', 'demokratik kongo cumhuriyeti': 'CD', 'ekvator ginesi': 'GQ', 'ekvatoral gine': 'GQ', }; const normalizeName = (name) => lowerTr(name).replace(/[.()]/g, '').replace(/\s+/g, ' ').trim(); let nameIndex = null; function countryNameIndex() { if (nameIndex) return nameIndex; const iso3ToIso2 = createRequire(import.meta.url)('./shared/iso3-to-iso2.json'); const display = new Intl.DisplayNames(['tr'], { type: 'region' }); nameIndex = new Map(); for (const iso2 of new Set(Object.values(iso3ToIso2))) { const name = display.of(iso2); if (name && name !== iso2) nameIndex.set(normalizeName(name), iso2); } for (const [name, iso2] of Object.entries(ALIASES)) nameIndex.set(normalizeName(name), iso2); return nameIndex; } export function iso2FromTurkishName(name) { return countryNameIndex().get(normalizeName(name)) ?? null; } /** * Per-month crude tonnes by origin, from parsed tables (any order). A month * comes from its own monthly table, else from its cumulative table less the * previous month's of the same year (January's cumulative is the month). * A month is void (absent) when a row with crude cannot be named, the rows * do not add up to the table total within 0.5% (a derived month is checked * again: two tables each within tolerance can difference to one outside * it), or a difference goes negative beyond rounding. * @returns {{months: Map}>, unmatched: string[]}} */ const addsUp = (byIso2, totalT) => { const sum = Object.values(byIso2).reduce((a, b) => a + b, 0); return Math.abs(sum - totalT) <= Math.max(1, totalT * 0.005); }; export function monthlyCrudeByOrigin(parsed) { const unmatched = new Set(); const named = (entry) => { const byIso2 = {}; for (const { name, tonnes } of entry.rows) { if (!(tonnes > 0)) continue; const iso2 = iso2FromTurkishName(name); if (!iso2) { unmatched.add(name); return null; } byIso2[iso2] = (byIso2[iso2] ?? 0) + tonnes; } if (!addsUp(byIso2, entry.totalT)) return null; return { totalT: entry.totalT, byIso2 }; }; const monthly = new Map(); const cumulative = new Map(); for (const entry of parsed) { const target = entry.cumulative && !entry.month.endsWith('-01') ? cumulative : monthly; if (target.has(entry.month)) continue; const value = named(entry); if (value) target.set(entry.month, value); } const months = new Map(monthly); for (const [month, cum] of cumulative) { if (months.has(month)) continue; const m = Number(month.slice(5, 7)); const prevKey = `${month.slice(0, 4)}-${pad2(m - 1)}`; const prev = cumulative.get(prevKey) ?? (m - 1 === 1 ? monthly.get(prevKey) : undefined); if (!prev) continue; const byIso2 = {}; let ok = true; for (const iso2 of new Set([...Object.keys(cum.byIso2), ...Object.keys(prev.byIso2)])) { const diff = (cum.byIso2[iso2] ?? 0) - (prev.byIso2[iso2] ?? 0); if (diff < -1) ok = false; if (diff > 0.5) byIso2[iso2] = diff; } const totalT = cum.totalT - prev.totalT; if (ok && totalT > 0 && addsUp(byIso2, totalT)) months.set(month, { totalT, byIso2 }); } return { months, unmatched: [...unmatched].sort() }; } /** * The importer columns the shock model reads, aligned to `months`: World * and Gulf crude tonnes, plus the SA, AE and IQ parts. A month without data * is null in every column. */ export function epdkImporterColumns(byMonth, months, gulfIso2, bypassIso2) { const gulf = new Set(gulfIso2); const round = (v) => Math.round(v * 1000) / 1000; const worldT = []; const gulfT = []; const bypass = Object.fromEntries(bypassIso2.map((p) => [p, []])); for (const month of months) { const m = byMonth.get(month); if (!m || !(m.totalT > 0)) { worldT.push(null); gulfT.push(null); for (const p of bypassIso2) bypass[p].push(null); continue; } worldT.push(round(m.totalT)); gulfT.push(round(Object.entries(m.byIso2).filter(([iso2]) => gulf.has(iso2)).reduce((s, [, v]) => s + v, 0))); for (const p of bypassIso2) bypass[p].push(round(m.byIso2[p] ?? 0)); } return { worldT, gulfT, bypassPartnersT: bypass }; } const isZip = (buf) => buf.length > 4 && buf[0] === 0x50 && buf[1] === 0x4b; // A real report inflates to a few MB; the cap only stops a crafted archive. export const EPDK_MAX_INFLATED_BYTES = 200 * 1024 * 1024; /** * Downloads each wanted month's report (Word first, then Excel; PDF is not * parsed) until one carries the imports-by-country table for that month. * `fetchBuffer(url)` resolves to a Buffer. A listing that cannot be read * throws. `failedMonths` are wanted months EPDK lists but that yielded no * usable month (download, parse or consistency failure, or the deadline); * months EPDK has not published yet are not failures. */ export async function fetchEpdkCrudeImports({ wantedMonths, fetchText, fetchBuffer, log = () => {}, deadlineAtMs = Infinity, maxInflatedBytes = EPDK_MAX_INFLATED_BYTES }) { const reports = parseEpdkListing(await fetchText(EPDK_LISTING_URL)); if (reports.length === 0) throw new Error('EPDK listing has no report months'); const wanted = new Set(wantedMonths); const listed = new Set(); const parsed = []; const failures = []; const rank = { word: 0, excel: 1 }; for (const report of reports) { if (!wanted.has(report.month)) continue; listed.add(report.month); if (Date.now() >= deadlineAtMs) { failures.push(`${report.month}: deadline`); continue; } const docs = report.documents.filter((d) => d.kind in rank).sort((a, b) => rank[a.kind] - rank[b.kind]); let found = null; for (const doc of docs) { if (Date.now() >= deadlineAtMs) { failures.push(`${report.month}: deadline`); break; } try { const buf = await fetchBuffer(doc.url); if (!isZip(buf)) continue; assertZipExpansionWithin(buf, maxInflatedBytes); const zip = await JSZip.loadAsync(buf); const tables = zip.file('word/document.xml') ? await tablesFromDocx(buf) : zip.file('xl/workbook.xml') ? await tablesFromXlsx(buf) : []; const entries = tables.map(crudeImportsFromTable).filter((e) => e && e.month === report.month); // A Word report has both the month and January-to-month tables; keep the month's. found = entries.find((e) => !e.cumulative) ?? entries[0] ?? null; if (found) break; } catch (err) { failures.push(`${report.month}: ${err.message}`); } } if (found) parsed.push(found); } if (failures.length > 0) log(` EPDK: ${failures.length} document(s) failed (${failures.slice(0, 3).join('; ')})`); const result = monthlyCrudeByOrigin(parsed); const failedMonths = [...listed].filter((m) => !result.months.has(m)).sort(); return { ...result, failedMonths }; }