deckhearth/lib/card-import/pokemon.js
varutasu 0a47362103
feat(catalog): weekly Vercel Cron sync for MTG and Pokémon sets (#48)
Extract shared import logic into lib/card-import, discover missing sets via
Scryfall/Pokémon TCG APIs, and expose GET /api/cron/sync-catalog protected
by CRON_SECRET (max 3 sets/run, paced imports).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-27 14:59:59 -05:00

122 lines
3.6 KiB
JavaScript

import { sql } from '@vercel/postgres';
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function pokemonHeaders() {
const headers = {
'User-Agent': 'Deck-Hearth/1.0',
Accept: 'application/json',
};
if (process.env.POKEMON_TCG_API_KEY) {
headers['X-Api-Key'] = process.env.POKEMON_TCG_API_KEY;
}
return headers;
}
export async function fetchWithRetry(url, maxRetries = 3, delayMs = 2000) {
for (let attempt = 1; attempt <= maxRetries; attempt += 1) {
try {
const response = await fetch(url, { headers: pokemonHeaders() });
if (response.ok) {
return response;
}
if (response.status === 404) {
throw new Error(`Resource not found: ${response.status}`);
}
if (response.status === 504 || response.status === 503) {
console.log(
`[fetchWithRetry] Attempt ${attempt}: ${response.status}, retrying in ${delayMs * attempt}ms`
);
await delay(delayMs * attempt);
continue;
}
throw new Error(`Pokemon TCG API error: ${response.status}`);
} catch (error) {
if (attempt === maxRetries) {
throw error;
}
console.log(`[fetchWithRetry] Attempt ${attempt} failed:`, error.message);
await delay(delayMs * attempt);
}
}
throw new Error('fetchWithRetry exhausted retries');
}
/**
* Import all cards for a Pokémon TCG set id. Skips rows already present by scryfall_id
* (legacy column name stores Pokémon TCG API card ids too).
*/
export async function importPokemonSet(setCode) {
const response = await fetchWithRetry(
`https://api.pokemontcg.io/v2/cards?q=set.id:${setCode}&pageSize=250`
);
const data = await response.json();
const cards = data.data || [];
if (cards.length === 0) {
return { setCode, imported: 0, skipped: 0, total: 0 };
}
let imported = 0;
let skipped = 0;
for (const card of cards) {
try {
const existingCard = await sql`
SELECT id FROM cards WHERE scryfall_id = ${card.id}
`;
if (existingCard.rows.length > 0) {
skipped += 1;
continue;
}
let currentPrice = null;
if (card.tcgplayer?.prices?.normal?.market) {
currentPrice = parseFloat(card.tcgplayer.prices.normal.market);
} else if (card.tcgplayer?.prices?.holofoil?.market) {
currentPrice = parseFloat(card.tcgplayer.prices.holofoil.market);
}
let rarity = card.rarity || 'Unknown';
if (rarity.includes('Holo')) {
rarity = 'Holographic';
} else if (rarity.includes('Secret')) {
rarity = 'Secret Rare';
} else if (rarity.includes('Ultra')) {
rarity = 'Ultra Rare';
}
await sql`
INSERT INTO cards (
name, set_name, set_code, card_number, rarity, game,
mana_cost, cmc, card_type, colors, oracle_text,
power, toughness, image_url, stock_image_url,
current_price, market_price, scryfall_id, verified
) VALUES (
${card.name}, ${card.set.name}, ${card.set.id}, ${card.number},
${rarity}, 'Pokemon', null, null, ${card.supertype || 'Pokemon'},
${JSON.stringify(card.types || [])}, ${card.flavorText || null},
${card.attacks?.[0]?.damage || null}, null,
${card.images?.small || null}, ${card.images?.large || null},
${currentPrice}, null, ${card.id}, true
)
`;
imported += 1;
} catch (error) {
console.error(`[importPokemonSet] Error importing card ${card.name}:`, error);
skipped += 1;
}
}
return { setCode, imported, skipped, total: cards.length };
}
export { pokemonHeaders };