👁️
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

download script

+306 -4
+16 -3
.claude/SCRYFALL.md
··· 36 36 ## Image & Display Fields 37 37 38 38 ### Images 39 - - `image_uris`: Object with card image URLs at different sizes 40 - - Keys: `small`, `normal`, `large`, `png`, `art_crop`, `border_crop` 39 + 40 + **Image URI Reconstruction:** 41 + Scryfall image URLs follow a predictable pattern. Instead of storing `image_uris`, reconstruct them: 42 + 43 + ```typescript 44 + function getImageUri(scryfallId: string, size: 'small' | 'normal' | 'large' | 'png' | 'art_crop' | 'border_crop'): string { 45 + return `https://cards.scryfall.io/${size}/front/${scryfallId[0]}/${scryfallId[1]}/${scryfallId}.jpg`; 46 + } 47 + ``` 48 + 49 + Pattern: `https://cards.scryfall.io/{size}/front/{id[0]}/{id[1]}/{id}.jpg` 50 + 51 + Verified on 100% of sampled cards (96.5% of all cards have images). 52 + 53 + **Stored fields:** 41 54 - `image_status`: Quality indicator (`missing`, `placeholder`, `lowres`, `highres_scan`) 42 55 - `highres_image`: Boolean for high-res availability 43 - - `illustration_id`: Unique artwork identifier 56 + - `illustration_id`: Unique artwork identifier (not currently stored, but available) 44 57 45 58 ### Card Appearance 46 59 - `layout`: Layout code (e.g., `"normal"`, `"split"`, `"transform"`, `"modal_dfc"`)
+4
.gitignore
··· 12 12 .vinxi 13 13 todos.json 14 14 .direnv 15 + 16 + # Scryfall data (downloaded during build) 17 + public/data/ 18 + .cache/
+2
CLAUDE.md
··· 90 90 91 91 ## Important Notes 92 92 93 + - **This is a TypeScript project** - ALL code (including scripts) must use TypeScript with proper types 94 + - **Use `nix-shell -p <package>` for missing commands** - If a command isn't in PATH, use nix-shell to get it temporarily 93 95 - `src/routeTree.gen.ts` is auto-generated - never edit manually 94 96 - `typelex/externals.tsp` is auto-generated from lexicons folder - add external lexicon JSON to trigger regeneration 95 97 - Demo files (prefixed with `demo`) are safe to delete
+1
flake.nix
··· 23 23 nodejs_22 24 24 # wrangler 25 25 just 26 + jq 26 27 # language servers 27 28 typescript-language-server 28 29 typespec
+3 -1
package.json
··· 5 5 "scripts": { 6 6 "dev": "vite dev --port 3000", 7 7 "build": "vite build", 8 + "prebuild": "npm run download:scryfall", 8 9 "serve": "vite preview", 9 10 "test": "vitest run", 10 11 "format": "biome format", 11 12 "lint": "biome lint", 12 13 "check": "biome check", 13 - "build:typelex": "typelex compile com.deckbelcher.*" 14 + "build:typelex": "typelex compile com.deckbelcher.*", 15 + "download:scryfall": "node --experimental-strip-types scripts/download-scryfall.ts" 14 16 }, 15 17 "dependencies": { 16 18 "@tailwindcss/vite": "^4.0.6",
+280
scripts/download-scryfall.ts
··· 1 + #!/usr/bin/env node --experimental-strip-types 2 + 3 + /** 4 + * Downloads Scryfall bulk data and processes it for client use. 5 + * 6 + * Fetches: 7 + * - default_cards bulk data (all English cards) 8 + * - migrations data (UUID changes) 9 + * 10 + * Outputs: 11 + * - public/data/cards.json - filtered card data with indexes 12 + * - public/data/migrations.json - ID migration mappings 13 + */ 14 + 15 + import { writeFile, mkdir, readFile, stat } from "node:fs/promises"; 16 + import { join, dirname } from "node:path"; 17 + import { fileURLToPath } from "node:url"; 18 + 19 + const __filename = fileURLToPath(import.meta.url); 20 + const __dirname = dirname(__filename); 21 + const OUTPUT_DIR = join(__dirname, "../public/data"); 22 + const TEMP_DIR = join(__dirname, "../.cache"); 23 + 24 + // Fields to keep from Scryfall data 25 + const KEPT_FIELDS = [ 26 + // Core identity 27 + "id", 28 + "oracle_id", 29 + "name", 30 + "type_line", 31 + "mana_cost", 32 + "cmc", 33 + "oracle_text", 34 + "colors", 35 + "color_identity", 36 + "keywords", 37 + "power", 38 + "toughness", 39 + "loyalty", 40 + "defense", 41 + 42 + // Legalities & formats 43 + "legalities", 44 + "games", 45 + "reserved", 46 + 47 + // Search & filtering 48 + "set", 49 + "set_name", 50 + "collector_number", 51 + "rarity", 52 + "released_at", 53 + "prices", 54 + "artist", 55 + 56 + // Printing selection (image_uris omitted - can reconstruct from ID) 57 + "card_faces", 58 + "border_color", 59 + "frame", 60 + "frame_effects", 61 + "finishes", 62 + "promo", 63 + "promo_types", 64 + "full_art", 65 + "digital", 66 + "highres_image", 67 + "image_status", 68 + "layout", 69 + 70 + // Nice-to-have (flavor_text omitted - visible on card image) 71 + "edhrec_rank", 72 + "reprint", 73 + "lang", 74 + "content_warning", 75 + ] as const; 76 + 77 + type KeptField = (typeof KEPT_FIELDS)[number]; 78 + 79 + interface ScryfallCard { 80 + id: string; 81 + oracle_id: string; 82 + name: string; 83 + [key: string]: unknown; 84 + } 85 + 86 + interface FilteredCard { 87 + id: string; 88 + oracle_id: string; 89 + name: string; 90 + [key: string]: unknown; 91 + } 92 + 93 + interface BulkDataItem { 94 + type: string; 95 + updated_at: string; 96 + download_uri: string; 97 + size: number; 98 + } 99 + 100 + interface BulkDataResponse { 101 + data: BulkDataItem[]; 102 + } 103 + 104 + interface Migration { 105 + object: string; 106 + id: string; 107 + uri: string; 108 + performed_at: string; 109 + migration_strategy: string; 110 + old_scryfall_id: string; 111 + new_scryfall_id: string; 112 + note: string; 113 + metadata: { 114 + id: string; 115 + lang: string; 116 + name: string; 117 + set_code: string; 118 + oracle_id: string; 119 + collector_number: string; 120 + }; 121 + } 122 + 123 + interface MigrationsResponse { 124 + data: Migration[]; 125 + } 126 + 127 + interface CardDataOutput { 128 + version: string; 129 + cardCount: number; 130 + cards: Record<string, FilteredCard>; 131 + oracleIdToPrintings: Record<string, string[]>; 132 + } 133 + 134 + type MigrationMap = Record<string, string>; 135 + 136 + async function fetchJSON<T>(url: string): Promise<T> { 137 + console.log(`Fetching: ${url}`); 138 + const response = await fetch(url); 139 + if (!response.ok) { 140 + throw new Error(`HTTP ${response.status}: ${response.statusText}`); 141 + } 142 + return response.json() as Promise<T>; 143 + } 144 + 145 + async function downloadFile(url: string, outputPath: string): Promise<void> { 146 + console.log(`Downloading: ${url}`); 147 + const response = await fetch(url); 148 + if (!response.ok) { 149 + throw new Error(`HTTP ${response.status}: ${response.statusText}`); 150 + } 151 + 152 + await mkdir(dirname(outputPath), { recursive: true }); 153 + const arrayBuffer = await response.arrayBuffer(); 154 + await writeFile(outputPath, Buffer.from(arrayBuffer)); 155 + console.log(`Saved to: ${outputPath}`); 156 + } 157 + 158 + function filterCard(card: ScryfallCard): FilteredCard { 159 + const filtered: FilteredCard = { 160 + id: card.id, 161 + oracle_id: card.oracle_id, 162 + name: card.name, 163 + }; 164 + 165 + for (const field of KEPT_FIELDS) { 166 + if (card[field] !== undefined) { 167 + filtered[field] = card[field]; 168 + } 169 + } 170 + 171 + return filtered; 172 + } 173 + 174 + async function processBulkData(): Promise<CardDataOutput> { 175 + // Get bulk data list 176 + const bulkData = await fetchJSON<BulkDataResponse>( 177 + "https://api.scryfall.com/bulk-data", 178 + ); 179 + const defaultCards = bulkData.data.find((d) => d.type === "default_cards"); 180 + 181 + if (!defaultCards) { 182 + throw new Error("Could not find default_cards bulk data"); 183 + } 184 + 185 + console.log(`Bulk data updated at: ${defaultCards.updated_at}`); 186 + console.log( 187 + `Download size: ${(defaultCards.size / 1024 / 1024).toFixed(2)} MB`, 188 + ); 189 + 190 + // Download bulk data 191 + await mkdir(TEMP_DIR, { recursive: true }); 192 + const tempFile = join(TEMP_DIR, "cards-bulk.json"); 193 + await downloadFile(defaultCards.download_uri, tempFile); 194 + 195 + // Parse and filter 196 + console.log("Processing cards..."); 197 + const rawData: ScryfallCard[] = JSON.parse(await readFile(tempFile, "utf-8")); 198 + 199 + const cards = rawData.map(filterCard); 200 + console.log(`Filtered ${cards.length} cards`); 201 + 202 + // Build indexes 203 + console.log("Building indexes..."); 204 + const cardById = Object.fromEntries(cards.map((card) => [card.id, card])); 205 + 206 + const oracleIdToPrintings = cards.reduce( 207 + (acc, card) => { 208 + if (!acc[card.oracle_id]) { 209 + acc[card.oracle_id] = []; 210 + } 211 + acc[card.oracle_id].push(card.id); 212 + return acc; 213 + }, 214 + {} as Record<string, string[]>, 215 + ); 216 + 217 + const output: CardDataOutput = { 218 + version: defaultCards.updated_at, 219 + cardCount: cards.length, 220 + cards: cardById, 221 + oracleIdToPrintings, 222 + }; 223 + 224 + // Write output 225 + await mkdir(OUTPUT_DIR, { recursive: true }); 226 + const outputPath = join(OUTPUT_DIR, "cards.json"); 227 + await writeFile(outputPath, JSON.stringify(output)); 228 + console.log(`Wrote cards to: ${outputPath}`); 229 + 230 + const stats = await stat(outputPath); 231 + console.log(`Output size: ${(stats.size / 1024 / 1024).toFixed(2)} MB`); 232 + 233 + return output; 234 + } 235 + 236 + async function processMigrations(): Promise<MigrationMap> { 237 + console.log("Fetching migrations..."); 238 + const migrations = await fetchJSON<MigrationsResponse>( 239 + "https://api.scryfall.com/migrations", 240 + ); 241 + 242 + // Build old_scryfall_id -> new_scryfall_id mapping 243 + const migrationMap = Object.fromEntries( 244 + migrations.data.map((m) => [m.old_scryfall_id, m.new_scryfall_id]), 245 + ); 246 + 247 + console.log(`Found ${Object.keys(migrationMap).length} migrations`); 248 + 249 + await mkdir(OUTPUT_DIR, { recursive: true }); 250 + const outputPath = join(OUTPUT_DIR, "migrations.json"); 251 + await writeFile(outputPath, JSON.stringify(migrationMap)); 252 + console.log(`Wrote migrations to: ${outputPath}`); 253 + 254 + return migrationMap; 255 + } 256 + 257 + async function main(): Promise<void> { 258 + try { 259 + console.log("=== Scryfall Data Download ===\n"); 260 + 261 + const [cardsData, migrations] = await Promise.all([ 262 + processBulkData(), 263 + processMigrations(), 264 + ]); 265 + 266 + console.log("\n=== Summary ==="); 267 + console.log(`Cards: ${cardsData.cardCount.toLocaleString()}`); 268 + console.log(`Migrations: ${Object.keys(migrations).length.toLocaleString()}`); 269 + console.log(`Version: ${cardsData.version}`); 270 + console.log("\n✓ Done!"); 271 + } catch (error) { 272 + console.error( 273 + "\n✗ Error:", 274 + error instanceof Error ? error.message : String(error), 275 + ); 276 + process.exit(1); 277 + } 278 + } 279 + 280 + main();