UNPKG

@kaminaduck/scryfall-mcp-server

Version:

A Model Context Protocol (MCP) server that provides access to the Scryfall API for Magic: The Gathering card data

365 lines 20.4 kB
/** * Download manager for Scryfall card images and art crops. */ import { writeFile, mkdir } from 'node:fs/promises'; import { join, extname } from 'node:path'; import fetch from 'node-fetch'; import { scryfallClient } from './scryfallClient.js'; import { createCardDatabase } from './database.js'; import { getCardImagesDirectory, getArtCropsDirectory } from './config.js'; import { logger } from './logger.js'; export class DownloadManager { db = null; async init() { if (!this.db) { this.db = await createCardDatabase(); } } /** * Extract image URLs from a card, handling both single-faced and multi-faced cards. */ extractImageUrls(card, imageType) { const results = []; // Check for single-faced card first if (card.image_uris?.[imageType]) { results.push({ url: card.image_uris[imageType], faceName: card.name, faceIndex: 0 }); return results; } // Check for multi-faced card if (card.card_faces && card.card_faces.length > 0) { card.card_faces.forEach((face, index) => { if (face.image_uris?.[imageType]) { results.push({ url: face.image_uris[imageType], faceName: face.name, faceIndex: index }); } }); } return results; } /** * Download card images for a list of card names. */ async downloadCardImages(cardNames, forceDownload = false, setCodes, collectorNumbers, baseDir) { await this.init(); const setCodesArray = setCodes || new Array(cardNames.length).fill(null); const collectorNumbersArray = collectorNumbers || new Array(cardNames.length).fill(null); const summary = { totalCards: cardNames.length, downloaded: 0, skipped: 0, errors: 0, downloadedFiles: [], totalImages: 0 }; logger.info(`Processing ${cardNames.length} cards for image download...`); // Use provided base_dir or get from config const outputFolder = baseDir ? join(baseDir, 'scryfall_card_images') : await getCardImagesDirectory(); await mkdir(outputFolder, { recursive: true }); for (let index = 0; index < cardNames.length; index++) { const cardName = cardNames[index]; const setCode = setCodesArray[index]; const collectorNumber = collectorNumbersArray[index]; const cardNameForFilename = (cardName ?? 'unknown').replace(/\s/g, '_').replace(/\/\//g, '_'); // Create a unique identifier for the card version let cardVersionId = cardName; if (setCode && collectorNumber) { cardVersionId = `${cardName}_${setCode}_${collectorNumber}`; } // Check if the card exists in the database if (cardVersionId && this.db.cardExists(cardVersionId) && !forceDownload) { logger.info(`[${index + 1}/${cardNames.length}] Image for '${cardName}' (${setCode} #${collectorNumber}) already exists, skipping...`); summary.skipped++; continue; } try { let card; // Use the specific set/collector number endpoint if provided if (setCode && collectorNumber) { logger.info(`[${index + 1}/${cardNames.length}] Fetching specific version: ${setCode} #${collectorNumber}`); card = await scryfallClient.getCardBySetAndNumber(setCode.toLowerCase(), collectorNumber); } else { card = await scryfallClient.getCardByName(cardName ?? 'unknown'); } logger.info(`[${index + 1}/${cardNames.length}] Fetching data for '${cardName}' from Scryfall...`); const imageUrls = this.extractImageUrls(card, 'large'); if (imageUrls.length > 0) { const isTransformCard = imageUrls.length > 1; logger.info(`[${index + 1}/${cardNames.length}] Found ${imageUrls.length} image(s) for '${cardName}'${isTransformCard ? ' (transform card)' : ''}`); let cardDownloaded = false; for (const imageInfo of imageUrls) { try { let imageExtension = extname(imageInfo.url ?? ''); if (imageInfo.url.includes('?')) { const urlBase = imageInfo.url.split('?')[0]; imageExtension = extname(urlBase ?? ''); } // Generate filename based on whether it's a transform card let imageFilename; let faceCardVersionId; if (isTransformCard) { const faceNameForFilename = imageInfo.faceName.replace(/\s/g, '_').replace(/\/\//g, '_'); if (setCode && collectorNumber) { imageFilename = `${cardNameForFilename}_face${imageInfo.faceIndex}_${faceNameForFilename}_${setCode}_${collectorNumber}${imageExtension}`; faceCardVersionId = `${cardName}_${setCode}_${collectorNumber}_face${imageInfo.faceIndex}`; } else { imageFilename = `${cardNameForFilename}_face${imageInfo.faceIndex}_${faceNameForFilename}${imageExtension}`; faceCardVersionId = `${cardName}_face${imageInfo.faceIndex}`; } } else { // Single-faced card - maintain existing pattern for backward compatibility if (setCode && collectorNumber) { imageFilename = `${cardNameForFilename}_${setCode}_${collectorNumber}${imageExtension}`; faceCardVersionId = cardVersionId ?? cardName ?? 'unknown'; } else { imageFilename = `${cardNameForFilename}${imageExtension}`; faceCardVersionId = cardVersionId ?? cardName ?? 'unknown'; } } // Check if this specific face already exists if (this.db.cardExists(faceCardVersionId) && !forceDownload) { logger.info(`Face ${imageInfo.faceIndex} (${imageInfo.faceName}) already exists, skipping...`); continue; } const imageFilepath = join(outputFolder, imageFilename); logger.info(`[${index + 1}/${cardNames.length}] Downloading face ${imageInfo.faceIndex} (${imageInfo.faceName})...`); const imageResponse = await fetch(imageInfo.url); if (!imageResponse.ok) { throw new Error(`HTTP ${imageResponse.status}: ${imageResponse.statusText}`); } const imageBuffer = await imageResponse.buffer(); await writeFile(imageFilepath, imageBuffer); logger.info(`Saved to ${imageFilepath}`); // Store face-specific database record const faceCardName = isTransformCard ? `${cardName} (Face ${imageInfo.faceIndex}: ${imageInfo.faceName})` : cardName; this.db.addCard(faceCardVersionId, imageFilepath, card.id, card.set, imageInfo.url); summary.totalImages++; summary.downloadedFiles.push({ cardName: faceCardName ?? 'unknown', filePath: imageFilepath, face_index: imageInfo.faceIndex, face_name: imageInfo.faceName, total_faces: imageUrls.length, is_transform_card: isTransformCard }); cardDownloaded = true; } catch (faceError) { logger.error(`[${index + 1}/${cardNames.length}] Error downloading face ${imageInfo.faceIndex} (${imageInfo.faceName}):`, faceError); // Continue with other faces } } if (cardDownloaded) { summary.downloaded++; } else { summary.errors++; } } else { logger.warn(`[${index + 1}/${cardNames.length}] No large image found for '${cardName}'.`); summary.errors++; } } catch (error) { logger.error(`[${index + 1}/${cardNames.length}] Error downloading '${cardName}':`, error); summary.errors++; } // Delay between requests (handled by scryfallClient) await new Promise(resolve => setTimeout(resolve, 200)); } this.logSummary('Image download', summary); return summary.downloadedFiles; } /** * Download art crop images for a list of card names. */ async downloadArtCrops(cardNames, forceDownload = false, setCodes, collectorNumbers, baseDir) { await this.init(); const setCodesArray = setCodes || new Array(cardNames.length).fill(null); const collectorNumbersArray = collectorNumbers || new Array(cardNames.length).fill(null); const summary = { totalCards: cardNames.length, downloaded: 0, skipped: 0, errors: 0, downloadedFiles: [], totalImages: 0 }; logger.info(`Processing ${cardNames.length} cards for art crop download...`); // Use provided base_dir or get from config const outputFolder = baseDir ? join(baseDir, 'scryfall_images') : await getArtCropsDirectory(); await mkdir(outputFolder, { recursive: true }); for (let index = 0; index < cardNames.length; index++) { const cardName = cardNames[index]; const setCode = setCodesArray[index]; const collectorNumber = collectorNumbersArray[index]; // Create a unique identifier for the card version let cardVersionId = `${cardName}_art_crop`; if (setCode && collectorNumber) { cardVersionId = `${cardName}_${setCode}_${collectorNumber}_art_crop`; } // Check if the card exists in the database if (cardVersionId && this.db.cardExists(cardVersionId) && !forceDownload) { logger.info(`[${index + 1}/${cardNames.length}] Art crop for '${cardName}' (${setCode} #${collectorNumber}) already exists, skipping...`); summary.skipped++; continue; } try { let card; // Use the specific set/collector number endpoint if provided if (setCode && collectorNumber) { logger.info(`[${index + 1}/${cardNames.length}] Fetching specific version: ${setCode} #${collectorNumber}`); card = await scryfallClient.getCardBySetAndNumber(setCode.toLowerCase(), collectorNumber); } else { card = await scryfallClient.getCardByName(cardName ?? 'unknown'); } logger.info(`[${index + 1}/${cardNames.length}] Fetching data for '${cardName}' from Scryfall...`); const imageUrls = this.extractImageUrls(card, 'art_crop'); if (imageUrls.length > 0) { const isTransformCard = imageUrls.length > 1; logger.info(`[${index + 1}/${cardNames.length}] Found ${imageUrls.length} art crop(s) for '${cardName}'${isTransformCard ? ' (transform card)' : ''}`); // Create a folder for the set const setName = (card.set_name || 'unknown_set') .replace(/\s/g, '_') .replace(/:/g, '_'); const setFolder = join(outputFolder, setName); await mkdir(setFolder, { recursive: true }); const cardNameForFilename = (cardName ?? 'unknown').replace(/\s/g, '_').replace(/\/\//g, '_'); let cardDownloaded = false; for (const imageInfo of imageUrls) { try { let imageExtension = extname(imageInfo.url ?? ''); if (imageInfo.url.includes('?')) { const urlBase = imageInfo.url.split('?')[0]; imageExtension = extname(urlBase ?? ''); } // Generate filename based on whether it's a transform card let imageFilename; let jsonFilename; let faceCardVersionId; if (isTransformCard) { const faceNameForFilename = imageInfo.faceName.replace(/\s/g, '_').replace(/\/\//g, '_'); if (setCode && collectorNumber) { imageFilename = `${cardNameForFilename}_face${imageInfo.faceIndex}_${faceNameForFilename}_${setCode}_${collectorNumber}${imageExtension}`; jsonFilename = `${cardNameForFilename}_face${imageInfo.faceIndex}_${faceNameForFilename}_${setCode}_${collectorNumber}.json`; faceCardVersionId = `${cardName}_${setCode}_${collectorNumber}_face${imageInfo.faceIndex}_art_crop`; } else { imageFilename = `${cardNameForFilename}_face${imageInfo.faceIndex}_${faceNameForFilename}${imageExtension}`; jsonFilename = `${cardNameForFilename}_face${imageInfo.faceIndex}_${faceNameForFilename}.json`; faceCardVersionId = `${cardName}_face${imageInfo.faceIndex}_art_crop`; } } else { // Single-faced card - maintain existing pattern for backward compatibility if (setCode && collectorNumber) { imageFilename = `${cardNameForFilename}_${setCode}_${collectorNumber}${imageExtension}`; jsonFilename = `${cardNameForFilename}_${setCode}_${collectorNumber}.json`; faceCardVersionId = cardVersionId ?? `${cardName}_art_crop`; } else { imageFilename = `${cardNameForFilename}${imageExtension}`; jsonFilename = `${cardNameForFilename}.json`; faceCardVersionId = cardVersionId ?? `${cardName}_art_crop`; } } // Check if this specific face already exists if (this.db.cardExists(faceCardVersionId) && !forceDownload) { logger.info(`Art crop face ${imageInfo.faceIndex} (${imageInfo.faceName}) already exists, skipping...`); continue; } const imageFilepath = join(setFolder, imageFilename); const jsonFilepath = join(setFolder, jsonFilename); logger.info(`[${index + 1}/${cardNames.length}] Downloading art crop face ${imageInfo.faceIndex} (${imageInfo.faceName})...`); const imageResponse = await fetch(imageInfo.url); if (!imageResponse.ok) { throw new Error(`HTTP ${imageResponse.status}: ${imageResponse.statusText}`); } const imageBuffer = await imageResponse.buffer(); await writeFile(imageFilepath, imageBuffer); logger.info(`Saved to ${imageFilepath}`); // Store face-specific database record const faceCardName = isTransformCard ? `${cardName} (Face ${imageInfo.faceIndex}: ${imageInfo.faceName})` : cardName; this.db.addCard(faceCardVersionId, imageFilepath, card.id, card.set, imageInfo.url); // Save card data to JSON file with face-specific information await writeFile(jsonFilepath, JSON.stringify(card, null, 4), 'utf-8'); summary.totalImages++; summary.downloadedFiles.push({ cardName: faceCardName ?? 'unknown', filePath: imageFilepath, jsonPath: jsonFilepath, face_index: imageInfo.faceIndex, face_name: imageInfo.faceName, total_faces: imageUrls.length, is_transform_card: isTransformCard }); cardDownloaded = true; } catch (faceError) { logger.error(`[${index + 1}/${cardNames.length}] Error downloading art crop face ${imageInfo.faceIndex} (${imageInfo.faceName}):`, faceError); // Continue with other faces } } if (cardDownloaded) { summary.downloaded++; } else { summary.errors++; } } else { logger.warn(`[${index + 1}/${cardNames.length}] No art crop found for '${cardName}'.`); summary.errors++; } } catch (error) { logger.error(`[${index + 1}/${cardNames.length}] Error downloading art crop for '${cardName}':`, error); summary.errors++; } // Delay between requests (handled by scryfallClient) await new Promise(resolve => setTimeout(resolve, 200)); } this.logSummary('Art crop download', summary); return summary.downloadedFiles; } /** * Log download summary statistics. */ logSummary(operation, summary) { logger.info(`\n${operation} process complete!`); logger.info(`Total cards processed: ${summary.totalCards}`); logger.info(`Downloaded: ${summary.downloaded}`); if (summary.totalImages !== undefined && summary.totalImages !== summary.downloaded) { logger.info(`Total images downloaded: ${summary.totalImages}`); } logger.info(`Skipped (already existed): ${summary.skipped}`); logger.info(`Errors encountered: ${summary.errors}`); } /** * Close the database connection. */ close() { if (this.db) { this.db.close(); } } } // Export a singleton instance export const downloadManager = new DownloadManager(); //# sourceMappingURL=downloadManager.js.map