163 lines
4.6 KiB
JavaScript
163 lines
4.6 KiB
JavaScript
|
|
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 };
|