/** * Bulk catalog sync for Disney Lorcana via lorcana-api.com /bulk/cards. * * Single fetch returns all cards (~2.3k). Upserts keyed on scryfall_id (stores Unique_ID). */ import { sql as vercelSql } from '../sql.js'; const BATCH_SIZE = 100; const BULK_URL = 'https://api.lorcana-api.com/bulk/cards'; /** * Map a Lorcana bulk API card object to cards-table insert shape. */ export function mapLorcanaCard(card) { const colors = card.Color ? card.Color.split(',').map((entry) => entry.trim()).filter(Boolean) : []; const keywords = []; if (card.Classifications) { keywords.push( ...card.Classifications.split(',').map((entry) => entry.trim()).filter(Boolean) ); } if (card.Abilities) { keywords.push(...card.Abilities.split(',').map((entry) => entry.trim()).filter(Boolean)); } if (card.Inkable === true) { keywords.push('Inkable'); } const cost = card.Cost != null ? Number(card.Cost) : null; return { scryfallId: card.Unique_ID, name: card.Name, setName: card.Set_Name, setCode: card.Set_ID, cardNumber: card.Card_Num != null ? String(card.Card_Num) : null, rarity: card.Rarity || null, manaCost: cost != null ? String(cost) : null, cmc: cost, cardType: card.Type || null, colors: JSON.stringify(colors), colorIdentity: JSON.stringify(colors), oracleText: card.Body_Text || null, power: card.Strength != null ? String(card.Strength) : null, toughness: card.Willpower != null ? String(card.Willpower) : null, imageUrl: card.Image || null, stockImageUrl: card.Image || null, flavorText: card.Flavor_Text || null, artist: card.Artist || null, edhrecRank: card.Lore != null ? Number(card.Lore) : null, keywords: JSON.stringify(keywords), }; } export function buildUpsertQuery(batch) { const columns = [ 'scryfall_id', 'name', 'set_name', 'set_code', 'card_number', 'rarity', 'game', 'mana_cost', 'cmc', 'card_type', 'colors', 'color_identity', 'oracle_text', 'power', 'toughness', 'image_url', 'stock_image_url', 'keywords', 'flavor_text', 'artist', 'edhrec_rank', 'verified', ]; const placeholders = []; const values = []; let paramIdx = 1; for (const row of batch) { const rowPlaceholders = []; const rowValues = [ row.scryfallId, row.name, row.setName, row.setCode, row.cardNumber, row.rarity, 'Lorcana', row.manaCost, row.cmc, row.cardType, row.colors, row.colorIdentity, row.oracleText, row.power, row.toughness, row.imageUrl, row.stockImageUrl, row.keywords, row.flavorText, row.artist, row.edhrecRank, true, ]; for (let i = 0; i < rowValues.length; i += 1) { rowPlaceholders.push(`$${paramIdx}`); paramIdx += 1; } placeholders.push(`(${rowPlaceholders.join(', ')})`); values.push(...rowValues); } const updateCols = columns .filter((col) => col !== 'scryfall_id') .map((col) => `${col} = EXCLUDED.${col}`) .join(', '); const query = ` INSERT INTO cards (${columns.join(', ')}) VALUES ${placeholders.join(',\n')} ON CONFLICT (scryfall_id) DO UPDATE SET ${updateCols}, updated_at = CURRENT_TIMESTAMP `; return { query, values }; } async function upsertBatch(sql, batch) { if (batch.length === 0) return 0; const { query, values } = buildUpsertQuery(batch); await sql.query(query, values); return batch.length; } export async function fetchLorcanaBulkCards() { const response = await fetch(BULK_URL, { headers: { 'User-Agent': 'DeckHearth/1.0', Accept: 'application/json' }, }); if (!response.ok) { throw new Error(`Lorcana bulk API error: ${response.status}`); } return response.json(); } /** * Run a full Lorcana bulk sync. Returns summary compatible with catalog sync reporting. * * @param {{ sql?: { query: (q: string, v: unknown[]) => Promise }, dryRun?: boolean }} options */ export async function runBulkLorcanaSync(options = {}) { const dryRun = options.dryRun ?? false; const sql = options.sql ?? vercelSql; const allCards = await fetchLorcanaBulkCards(); const lorcanaCards = allCards.filter( (card) => !card.Gamemode || card.Gamemode === 'Lorcana' ); let upserted = 0; let errors = 0; let batch = []; for (const card of lorcanaCards) { if (!card.Unique_ID) { errors += 1; continue; } batch.push(mapLorcanaCard(card)); if (batch.length >= BATCH_SIZE) { try { if (!dryRun) { upserted += await upsertBatch(sql, batch); } else { upserted += batch.length; } } catch (err) { console.error('[lorcana-bulk] Batch error:', err.message); errors += batch.length; } batch = []; } } if (batch.length > 0) { try { if (!dryRun) { upserted += await upsertBatch(sql, batch); } else { upserted += batch.length; } } catch (err) { console.error('[lorcana-bulk] Final batch error:', err.message); errors += batch.length; } } const summary = { mode: 'bulk', game: 'lorcana', totalInFile: lorcanaCards.length, upserted, errors, }; console.log('[lorcana-bulk]', JSON.stringify(summary)); return summary; }