deckhearth/lib/pricing/tcgdex-client.js

163 lines
4.6 KiB
JavaScript
Raw Permalink Normal View History

const DEFAULT_TCGDEX_BASE_URL = 'https://api.tcgdex.net/v2/en';
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export function getTcgdexBaseUrl() {
return process.env.TCGDEX_BASE_URL ?? DEFAULT_TCGDEX_BASE_URL;
}
function tcgplayerVariant(tcgplayer) {
if (!tcgplayer || typeof tcgplayer !== 'object') return null;
const preferredKeys = ['normal', 'holofoil', 'reverse', 'reverse-holofoil', 'unlimited', '1st-edition'];
for (const key of preferredKeys) {
const variant = tcgplayer[key];
if (variant && typeof variant === 'object') {
return variant;
}
}
return Object.values(tcgplayer).find(
(value) => value && typeof value === 'object' && ('marketPrice' in value || 'lowPrice' in value)
) ?? null;
}
/**
* Parse TCGdex pricing into Deck Hearth card price fields.
* USD from TCGPlayer; Cardmarket EUR fills market_price only when USD is missing.
*/
export function parseTcgdexPricing(card) {
let currentPrice = null;
let marketPrice = null;
let currency = 'USD';
const variant = tcgplayerVariant(card.pricing?.tcgplayer);
if (variant) {
const low = variant.lowPrice ?? variant.directLowPrice;
const market = variant.marketPrice ?? variant.midPrice;
currentPrice = low != null ? Number(low) : (market != null ? Number(market) : null);
marketPrice = market != null ? Number(market) : currentPrice;
}
// Cardmarket (EUR) — use for market_price only when TCGPlayer USD is unavailable.
if (currentPrice == null && marketPrice == null) {
const cardmarket = card.pricing?.cardmarket;
const eur = cardmarket?.trend ?? cardmarket?.avg ?? cardmarket?.low;
if (eur != null) {
marketPrice = Number(eur);
currency = 'EUR';
}
}
if (currentPrice != null && Number.isNaN(currentPrice)) currentPrice = null;
if (marketPrice != null && Number.isNaN(marketPrice)) marketPrice = null;
return { currentPrice, marketPrice, currency };
}
export function normalizeTcgdexName(name) {
return String(name ?? '').trim().toLowerCase();
}
function cardNumberKeys(card) {
const keys = new Set();
const localId = card.localId ?? card.id?.split('-').pop();
if (localId != null && localId !== '') {
keys.add(String(localId).trim().toLowerCase());
const official = card.set?.cardCount?.official;
if (official) {
keys.add(`${String(parseInt(localId, 10))}/${official}`.toLowerCase());
}
}
return keys;
}
/**
* Build lookup indexes for matching DB rows by set_code + card_number or name.
*/
export function buildTcgdexPriceIndex(cards) {
const byNumber = new Map();
const byName = new Map();
for (const card of cards) {
for (const key of cardNumberKeys(card)) {
if (!byNumber.has(key)) {
byNumber.set(key, card);
}
}
const nameKey = normalizeTcgdexName(card.name);
if (!byName.has(nameKey)) {
byName.set(nameKey, []);
}
byName.get(nameKey).push(card);
}
return { byNumber, byName };
}
/**
* Match a catalog row to a TCGdex card within a set.
*/
export function matchTcgdexCard(index, row) {
const cardNumber = String(row.cardNumber ?? '').trim().toLowerCase();
if (cardNumber) {
const direct = index.byNumber.get(cardNumber);
if (direct) return direct;
const slashIndex = cardNumber.indexOf('/');
if (slashIndex !== -1) {
const numerator = String(parseInt(cardNumber.slice(0, slashIndex), 10));
if (numerator !== 'NaN') {
const byNumerator = index.byNumber.get(numerator);
if (byNumerator) return byNumerator;
}
}
}
const nameKey = normalizeTcgdexName(row.name);
const candidates = index.byName.get(nameKey) ?? [];
return candidates.length === 1 ? candidates[0] : null;
}
async function fetchTcgdexJson(path) {
const response = await fetch(`${getTcgdexBaseUrl()}${path}`, {
headers: { Accept: 'application/json', 'User-Agent': 'DeckHearth/1.0' },
});
if (!response.ok) {
throw new Error(`TCGdex API error: ${response.status} for ${path}`);
}
return response.json();
}
/**
* Fetch all cards for a Pokémon set, including pricing when available.
*/
export async function fetchSetCards(setId) {
const setData = await fetchTcgdexJson(`/sets/${encodeURIComponent(setId)}`);
const summaries = Array.isArray(setData?.cards) ? setData.cards : [];
const cards = [];
for (const summary of summaries) {
if (!summary?.id) continue;
if (summary.pricing) {
cards.push(summary);
continue;
}
try {
const fullCard = await fetchTcgdexJson(`/cards/${encodeURIComponent(summary.id)}`);
cards.push(fullCard);
} catch {
cards.push(summary);
}
}
return cards;
}
export { delay };