deckhearth/lib/card-import/lorcana-bulk.js

186 lines
5.1 KiB
JavaScript
Raw Normal View History

/**
* 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<unknown> }, 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;
}