const DEFAULT_FOILIO_FEED_URL = 'https://foilio.net/api/prices/feed?game=lorcana'; export function getFolioFeedUrl() { return process.env.FOILIO_PRICES_FEED_URL ?? DEFAULT_FOILIO_FEED_URL; } export function normalizeFolioName(name) { return String(name ?? '').trim().toLowerCase().replace(/\s+/g, ' '); } function parseAmount(value) { if (value == null || value === '') return null; const amount = Number(value); return Number.isFinite(amount) ? amount : null; } /** * Parse a Foilio feed row into current/market prices. * Prefer USD fields; fall back to EUR with currency noted for snapshots. */ export function parseFolioPriceEntry(entry) { const usdLow = parseAmount(entry.usd_low) ?? parseAmount(entry.low_usd) ?? parseAmount(entry.price_usd) ?? parseAmount(entry.usd); const usdMarket = parseAmount(entry.usd_market) ?? parseAmount(entry.market_usd) ?? parseAmount(entry.market) ?? parseAmount(entry.price); if (usdLow != null || usdMarket != null) { return { currentPrice: usdLow ?? usdMarket, marketPrice: usdMarket ?? usdLow, currency: 'USD', }; } const eurLow = parseAmount(entry.eur_low) ?? parseAmount(entry.low_eur); const eurMarket = parseAmount(entry.eur_market) ?? parseAmount(entry.eur) ?? parseAmount(entry.price_eur); if (eurLow != null || eurMarket != null) { return { currentPrice: eurLow ?? eurMarket, marketPrice: eurMarket ?? eurLow, currency: 'EUR', }; } return { currentPrice: null, marketPrice: null, currency: 'USD' }; } function feedEntries(payload) { if (Array.isArray(payload)) return payload; if (Array.isArray(payload?.prices)) return payload.prices; if (Array.isArray(payload?.cards)) return payload.cards; if (Array.isArray(payload?.data)) return payload.data; return []; } /** * Build lookup indexes from a Foilio feed payload. */ export function buildFolioPriceIndex(payload) { const byCardId = new Map(); const byNameSet = new Map(); for (const entry of feedEntries(payload)) { const parsed = parseFolioPriceEntry(entry); if (parsed.currentPrice == null && parsed.marketPrice == null) continue; const cardId = entry.card_id ?? entry.cardId ?? entry.id ?? entry.unique_id ?? entry.Unique_ID; if (cardId) { byCardId.set(String(cardId), parsed); } const setCode = entry.set ?? entry.set_id ?? entry.setId ?? entry.Set_ID; const name = entry.name ?? entry.Name; if (setCode && name) { const key = `${normalizeFolioName(name)}|${String(setCode).trim()}`; if (!byNameSet.has(key)) { byNameSet.set(key, parsed); } } } return { byCardId, byNameSet }; } /** * Match a Lorcana catalog row against the Foilio price index. */ export function matchFolioPrice(index, row) { const byId = index.byCardId.get(String(row.scryfallId)); if (byId) return byId; const key = `${normalizeFolioName(row.name)}|${String(row.setCode ?? '').trim()}`; return index.byNameSet.get(key) ?? null; } /** * Fetch the Foilio Lorcana price feed once per sync run. */ export async function fetchFolioPriceFeed() { const response = await fetch(getFolioFeedUrl(), { headers: { Accept: 'application/json', 'User-Agent': 'DeckHearth/1.0' }, }); if (!response.ok) { throw new Error(`Foilio feed error: ${response.status}`); } return response.json(); } export async function fetchFolioPriceIndex() { const payload = await fetchFolioPriceFeed(); return buildFolioPriceIndex(payload); }