diff --git a/lib/card-import/bulk-sync.js b/lib/card-import/bulk-sync.js new file mode 100644 index 0000000..6374e6e --- /dev/null +++ b/lib/card-import/bulk-sync.js @@ -0,0 +1,181 @@ +/** + * Bulk catalog sync using Scryfall's bulk-data endpoint. + * + * Downloads the Oracle Cards file (~168 MB) and upserts all MTG cards in one pass. + * Much faster and more complete than the per-set discovery approach — catches all + * missing sets in one shot without rate-limit concerns. + * + * Also refreshes prices, legalities, and edhrec_rank on existing cards. + */ + +import { sql } from '@vercel/postgres'; + +const BATCH_SIZE = 100; +const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function fetchBulkDownloadUrl() { + const response = await fetch('https://api.scryfall.com/bulk-data/oracle_cards', { + headers: { 'User-Agent': 'DeckHearth/1.0', Accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error(`Scryfall bulk-data API error: ${response.status}`); + } + return response.json(); +} + +function mapCard(card) { + let currentPrice = null; + let marketPrice = null; + if (card.prices) { + currentPrice = card.prices.usd ? parseFloat(card.prices.usd) : null; + marketPrice = card.prices.usd_foil ? parseFloat(card.prices.usd_foil) : null; + } + + return { + scryfallId: card.id, + oracleId: card.oracle_id || null, + illustrationId: card.illustration_id || null, + name: card.name, + setName: card.set_name, + setCode: card.set, + cardNumber: card.collector_number, + rarity: card.rarity, + manaCost: card.mana_cost || null, + cmc: card.cmc != null ? Math.round(card.cmc) : null, + cardType: card.type_line || null, + colors: JSON.stringify(card.colors || []), + colorIdentity: JSON.stringify(card.color_identity || []), + oracleText: card.oracle_text || null, + power: card.power || null, + toughness: card.toughness || null, + imageUrl: card.image_uris?.normal || null, + stockImageUrl: card.image_uris?.art_crop || null, + currentPrice, + marketPrice, + keywords: JSON.stringify(card.keywords || []), + legalities: JSON.stringify(card.legalities || {}), + flavorText: card.flavor_text || null, + artist: card.artist || null, + releasedAt: card.released_at || null, + layout: card.layout || null, + edhrecRank: card.edhrec_rank || null, + reserved: card.reserved || false, + reprint: card.reprint || false, + finishes: JSON.stringify(card.finishes || []), + }; +} + +async function upsertBatch(cards) { + if (cards.length === 0) return 0; + + const columns = [ + 'scryfall_id', 'oracle_id', 'illustration_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', + 'current_price', 'market_price', 'keywords', 'legalities', 'flavor_text', 'artist', + 'released_at', 'layout', 'edhrec_rank', 'reserved', 'reprint', 'finishes', 'verified', + ]; + + const values = []; + const placeholders = []; + let paramIdx = 1; + + for (const row of cards) { + const rowValues = [ + row.scryfallId, row.oracleId, row.illustrationId, row.name, row.setName, row.setCode, + row.cardNumber, row.rarity, 'MTG', row.manaCost, row.cmc, row.cardType, row.colors, + row.colorIdentity, row.oracleText, row.power, row.toughness, row.imageUrl, row.stockImageUrl, + row.currentPrice, row.marketPrice, row.keywords, row.legalities, row.flavorText, row.artist, + row.releasedAt, row.layout, row.edhrecRank, row.reserved, row.reprint, row.finishes, true, + ]; + const rowP = rowValues.map(() => `$${paramIdx++}`); + placeholders.push(`(${rowP.join(',')})`); + values.push(...rowValues); + } + + const updateCols = columns + .filter((c) => c !== 'scryfall_id') + .map((c) => `${c} = EXCLUDED.${c}`) + .join(', '); + + await sql.query( + `INSERT INTO cards (${columns.join(', ')}) + VALUES ${placeholders.join(',\n')} + ON CONFLICT (scryfall_id) DO UPDATE SET ${updateCols}, updated_at = CURRENT_TIMESTAMP`, + values + ); + + return cards.length; +} + +/** + * Run a full bulk MTG sync. Returns a summary compatible with the catalog sync API shape. + */ +export async function runBulkMtgSync() { + const meta = await fetchBulkDownloadUrl(); + console.log(`[bulk-sync] Oracle Cards updated: ${meta.updated_at}, size: ${(meta.size / 1024 / 1024).toFixed(0)} MB`); + + const response = await fetch(meta.download_uri, { + headers: { 'User-Agent': 'DeckHearth/1.0' }, + }); + if (!response.ok) { + throw new Error(`Bulk download failed: ${response.status}`); + } + + const allCards = await response.json(); + const mtgCards = allCards.filter((c) => c.lang === 'en' && !c.digital); + + let upserted = 0; + let errors = 0; + let batch = []; + + for (const card of mtgCards) { + batch.push(mapCard(card)); + if (batch.length >= BATCH_SIZE) { + try { + upserted += await upsertBatch(batch); + } catch (err) { + console.error('[bulk-sync] Batch error:', err.message); + errors += batch.length; + } + batch = []; + } + } + + if (batch.length > 0) { + try { + upserted += await upsertBatch(batch); + } catch (err) { + console.error('[bulk-sync] Final batch error:', err.message); + errors += batch.length; + } + } + + // Backfill oracle_id on older per-set imported cards + const backfill = await sql` + UPDATE cards target + SET oracle_id = source.oracle_id, + color_identity = COALESCE(target.color_identity, source.color_identity), + keywords = COALESCE(target.keywords, source.keywords), + legalities = COALESCE(target.legalities, source.legalities), + layout = COALESCE(target.layout, source.layout), + edhrec_rank = COALESCE(target.edhrec_rank, source.edhrec_rank) + FROM ( + SELECT DISTINCT ON (name) name, oracle_id, color_identity, keywords, legalities, layout, edhrec_rank + FROM cards WHERE oracle_id IS NOT NULL AND game = 'MTG' + ) source + WHERE target.name = source.name AND target.game = 'MTG' AND target.oracle_id IS NULL + `; + + const summary = { + mode: 'bulk', + totalInFile: mtgCards.length, + upserted, + errors, + backfilledOracleId: backfill.rowCount || 0, + updatedAt: meta.updated_at, + }; + + console.log('[bulk-sync]', JSON.stringify(summary)); + return summary; +} diff --git a/migrations/1781440700404_add-scryfall-bulk-columns.js b/migrations/1781440700404_add-scryfall-bulk-columns.js new file mode 100644 index 0000000..45b6e2c --- /dev/null +++ b/migrations/1781440700404_add-scryfall-bulk-columns.js @@ -0,0 +1,51 @@ +/** + * Add Scryfall bulk-data columns to `cards` for richer MTG metadata. + * These enable Tagger tag joins (oracle_id, illustration_id), Commander + * deck validation (color_identity), format legality checks, keyword + * search, and better card detail pages. + */ +export const shorthands = undefined; + +export const up = (pgm) => { + pgm.addColumns('cards', { + oracle_id: { type: 'varchar(36)', comment: 'Stable across printings; joins oracle tags' }, + illustration_id: { type: 'varchar(36)', comment: 'Stable per artwork; joins art tags' }, + color_identity: { type: 'jsonb', comment: '["W","U","B","R","G"] — includes symbols in rules text' }, + keywords: { type: 'jsonb', comment: '["Flying","Trample",...] — searchable mechanics' }, + legalities: { type: 'jsonb', comment: '{standard:"legal", modern:"not_legal",...}' }, + flavor_text: { type: 'text' }, + artist: { type: 'varchar(255)' }, + released_at: { type: 'date' }, + layout: { type: 'varchar(50)', comment: 'normal, transform, split, mdfc, adventure, etc.' }, + edhrec_rank: { type: 'integer', comment: 'Commander popularity — lower is more popular' }, + reserved: { type: 'boolean', default: false }, + reprint: { type: 'boolean', default: false }, + finishes: { type: 'jsonb', comment: '["nonfoil","foil","etched"]' }, + }); + + pgm.createIndex('cards', 'oracle_id'); + pgm.createIndex('cards', 'illustration_id'); + pgm.createIndex('cards', 'color_identity', { method: 'gin' }); + pgm.createIndex('cards', 'keywords', { method: 'gin' }); + pgm.createIndex('cards', 'legalities', { method: 'gin' }); + pgm.createIndex('cards', 'artist'); + pgm.createIndex('cards', 'edhrec_rank'); +}; + +export const down = (pgm) => { + pgm.dropColumns('cards', [ + 'oracle_id', + 'illustration_id', + 'color_identity', + 'keywords', + 'legalities', + 'flavor_text', + 'artist', + 'released_at', + 'layout', + 'edhrec_rank', + 'reserved', + 'reprint', + 'finishes', + ]); +}; diff --git a/migrations/1781440721350_add-tagger-tables.js b/migrations/1781440721350_add-tagger-tables.js new file mode 100644 index 0000000..c2f2486 --- /dev/null +++ b/migrations/1781440721350_add-tagger-tables.js @@ -0,0 +1,53 @@ +/** + * Create tables for Scryfall Tagger community tags (oracle + art). + * Tags join to cards via oracle_id (oracle tags) or illustration_id (art tags). + */ +export const shorthands = undefined; + +export const up = (pgm) => { + pgm.createTable('tags', { + id: { type: 'uuid', primaryKey: true }, + slug: { type: 'varchar(255)', notNull: true }, + label: { type: 'varchar(255)', notNull: true }, + type: { type: 'varchar(20)', notNull: true, comment: 'oracle or illustration' }, + description: { type: 'text' }, + parent_ids: { type: 'jsonb' }, + child_ids: { type: 'jsonb' }, + aliases: { type: 'jsonb' }, + created_at: { type: 'timestamp', default: pgm.func('CURRENT_TIMESTAMP') }, + updated_at: { type: 'timestamp', default: pgm.func('CURRENT_TIMESTAMP') }, + }); + + pgm.createIndex('tags', 'type'); + pgm.createIndex('tags', 'slug'); + pgm.createIndex('tags', 'label'); + + pgm.createTable('card_tags', { + id: { type: 'serial', primaryKey: true }, + tag_id: { type: 'uuid', notNull: true, references: 'tags(id)', onDelete: 'CASCADE' }, + card_id: { type: 'integer', references: 'cards(id)', onDelete: 'CASCADE' }, + oracle_id: { type: 'varchar(36)', comment: 'For oracle tags — matches cards.oracle_id' }, + illustration_id: { type: 'varchar(36)', comment: 'For art tags — matches cards.illustration_id' }, + weight: { type: 'varchar(20)', default: 'median' }, + annotation: { type: 'text' }, + created_at: { type: 'timestamp', default: pgm.func('CURRENT_TIMESTAMP') }, + }); + + pgm.createIndex('card_tags', 'tag_id'); + pgm.createIndex('card_tags', 'card_id'); + pgm.createIndex('card_tags', 'oracle_id'); + pgm.createIndex('card_tags', 'illustration_id'); + pgm.addConstraint('card_tags', 'card_tags_unique_tag_oracle', { + unique: ['tag_id', 'oracle_id'], + where: 'oracle_id IS NOT NULL', + }); + pgm.addConstraint('card_tags', 'card_tags_unique_tag_illustration', { + unique: ['tag_id', 'illustration_id'], + where: 'illustration_id IS NOT NULL', + }); +}; + +export const down = (pgm) => { + pgm.dropTable('card_tags'); + pgm.dropTable('tags'); +}; diff --git a/package-lock.json b/package-lock.json index eb83de5..846be6d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "deck-hearth", "version": "0.1.0", "dependencies": { + "@neondatabase/serverless": "^1.1.0", "@upstash/ratelimit": "^2.0.8", "@upstash/redis": "^1.38.0", "@vercel/blob": "^1.1.1", @@ -1849,6 +1850,15 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@neondatabase/serverless": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@neondatabase/serverless/-/serverless-1.1.0.tgz", + "integrity": "sha512-r3ZZhRjEcfEdKIZnoB1RusNgvHuaBRqfCzV4Gi+5A9yUX0S4HTws/ASWqt13wL4y4I+0rqsWGdA2w7EQXHi3+Q==", + "license": "MIT", + "engines": { + "node": ">=19.0.0" + } + }, "node_modules/@next/env": { "version": "16.2.6", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.6.tgz", diff --git a/package.json b/package.json index aec3b1e..5ba8ecc 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "setup-db": "node scripts/setup-neon-db.js", "import-popular": "node scripts/import-popular-sets.js", "import-all": "node scripts/bulk-import-all.js", + "bulk-import": "node --env-file=.env.local scripts/bulk-import-scryfall.js", + "import-tags": "node --env-file=.env.local scripts/import-scryfall-tags.js", "test": "vitest", "test:run": "vitest run", "test:smoke": "playwright test --project=smoke", @@ -19,6 +21,7 @@ "test:visual:update": "playwright test --project=visual --update-snapshots" }, "dependencies": { + "@neondatabase/serverless": "^1.1.0", "@upstash/ratelimit": "^2.0.8", "@upstash/redis": "^1.38.0", "@vercel/blob": "^1.1.1", diff --git a/pages/admin/card-import.js b/pages/admin/card-import.js index 0de285c..d95a543 100644 --- a/pages/admin/card-import.js +++ b/pages/admin/card-import.js @@ -14,6 +14,8 @@ const CardImport = () => { const [isSyncing, setIsSyncing] = useState(false); const [syncResult, setSyncResult] = useState(null); + const [syncMode, setSyncMode] = useState('incremental'); + const handleCatalogSync = async () => { setIsSyncing(true); setSyncResult(null); @@ -25,6 +27,7 @@ const CardImport = () => { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('auth_token')}`, }, + body: JSON.stringify({ mode: syncMode }), }); const result = await response.json(); @@ -171,9 +174,32 @@ const CardImport = () => { Catalog sync
- Import up to three newest missing MTG and Pokémon sets — the same job the weekly Vercel cron runs. - Pending scan submissions for those sets are auto-linked to the catalog when a unique match exists. + {syncMode === 'bulk' + ? 'Download Scryfall\'s full Oracle Cards bulk file and upsert all 36k+ MTG cards with rich metadata (legalities, keywords, color identity, tags). Takes ~30s.' + : 'Import up to three newest missing MTG and Pokémon sets — the same job the weekly Vercel cron runs. Pending scan submissions are auto-linked when a unique match exists.'}
++ Upserted {syncResult.upserted} MTG cards from Scryfall bulk data. + {syncResult.backfilledOracleId > 0 && ` Backfilled oracle_id on ${syncResult.backfilledOracleId} existing rows.`} + {syncResult.errors > 0 && ` (${syncResult.errors} errors)`} +
+ ) : (Imported {syncResult.imported} cards ({syncResult.skipped} skipped).
+ )} {syncResult.submissionsReconciled > 0 && (
Linked {syncResult.submissionsReconciled} pending scan submission
diff --git a/pages/api/admin/sync-catalog.js b/pages/api/admin/sync-catalog.js
index d74b855..e982aa5 100644
--- a/pages/api/admin/sync-catalog.js
+++ b/pages/api/admin/sync-catalog.js
@@ -1,6 +1,7 @@
import { withAdmin } from '../../../lib/permission-middleware';
import { checkImportRateLimit } from '../../../lib/rate-limit.js';
import { runCatalogSync } from '../../../lib/card-import/sync-catalog.js';
+import { runBulkMtgSync } from '../../../lib/card-import/bulk-sync.js';
export default withAdmin(async function handler(req, res, user) {
if (req.method !== 'POST') {
@@ -13,7 +14,14 @@ export default withAdmin(async function handler(req, res, user) {
return res.status(429).json({ error: 'Too many attempts. Try again later.' });
}
+ const mode = req.body?.mode || 'incremental';
+
try {
+ if (mode === 'bulk') {
+ const summary = await runBulkMtgSync();
+ return res.status(200).json({ success: true, ...summary });
+ }
+
const summary = await runCatalogSync();
return res.status(200).json({ success: true, ...summary });
} catch (error) {
diff --git a/scripts/bulk-import-scryfall.js b/scripts/bulk-import-scryfall.js
new file mode 100644
index 0000000..12dd9a3
--- /dev/null
+++ b/scripts/bulk-import-scryfall.js
@@ -0,0 +1,217 @@
+/**
+ * Bulk import MTG cards from Scryfall's Oracle Cards bulk data file.
+ *
+ * Downloads the ~168 MB Oracle Cards JSON (one entry per unique card/oracle_id),
+ * then upserts every card into the `cards` table keyed on `scryfall_id`.
+ *
+ * Populates all new bulk-data columns (oracle_id, illustration_id, color_identity,
+ * keywords, legalities, flavor_text, artist, released_at, layout, edhrec_rank,
+ * reserved, reprint, finishes) in addition to the original columns.
+ *
+ * Usage:
+ * POSTGRES_URL=