/** * Fetches miniature paint data from the Arcturus5404/miniature-paints GitHub repo * and converts Markdown tables into a single JSON file for the catalog API. * * Usage: npx tsx scripts/fetch-paint-data.ts */ import { writeFileSync, mkdirSync } from "fs"; import { resolve } from "path"; const GITHUB_RAW = "https://raw.githubusercontent.com/Arcturus5404/miniature-paints/main/paints"; // Brands to fetch — file names from the repo (without .md) const BRANDS = [ "AK", "Army_Painter", "Citadel_Colour", "CoatDArmes", "Foundry", "GreenStuffWorld", "Humbrol", "KimeraKolors", "Mig", "MissionModels", "Monument", "MrHobby", "P3", "Reaper", "Revell", "Scale75", "Tamiya", "TurboDork", "Vallejo", "Warcolours", ]; // Display names for brands (file name → human-friendly) const BRAND_NAMES: Record = { AK: "AK Interactive", Army_Painter: "The Army Painter", Citadel_Colour: "Citadel", CoatDArmes: "Coat d'Armes", Foundry: "Foundry", GreenStuffWorld: "Green Stuff World", Humbrol: "Humbrol", KimeraKolors: "Kimera Kolors", Mig: "AMMO by MIG", MissionModels: "Mission Models", Monument: "Monument Hobbies", MrHobby: "Mr. Hobby", P3: "P3 (Privateer Press)", Reaper: "Reaper", Revell: "Revell", Scale75: "Scale75", Tamiya: "Tamiya", TurboDork: "TurboDork", Vallejo: "Vallejo", Warcolours: "Warcolours", }; // Map known range/set names to paint finish types const FINISH_MAP: Record = { // Citadel base: "Matte", layer: "Matte", air: "Matte", dry: "Matte", shade: "Wash", contrast: "Contrast", technical: "Other", "foundation (discontinued)": "Matte", "goblin green (discontinued)": "Matte", // Army Painter warpaints: "Matte", "warpaints fanatic": "Matte", "warpaints air": "Matte", speedpaint: "Contrast", "speedpaint set": "Contrast", "speedpaint set 2.0": "Contrast", "warpaints washes": "Wash", "warpaints effects": "Other", "warpaints metallics": "Metallic", "warpaints primer": "Primer", // Vallejo "model color": "Matte", "model air": "Matte", "game color": "Matte", "game air": "Matte", "game ink": "Ink", "game wash": "Wash", "metal color": "Metallic", "mecha color": "Matte", "mecha varnish": "Varnish", "surface primer": "Primer", "xpress color": "Contrast", panzer: "Matte", // Generic metallic: "Metallic", metallics: "Metallic", wash: "Wash", washes: "Wash", ink: "Ink", inks: "Ink", primer: "Primer", varnish: "Varnish", }; interface PaintEntry { id: string; name: string; brand: string; type: "paint"; color: string; colorHex: string; line: string; finish: string; productCode: string | null; } function extractHex(hexCell: string): string | null { // Format: ![#HEXHEX](url) `#HEXHEX` — extract from backtick code const backtickMatch = hexCell.match(/`(#[0-9A-Fa-f]{6})`/); if (backtickMatch) return backtickMatch[1]; // Fallback: raw hex const rawMatch = hexCell.match(/#[0-9A-Fa-f]{6}/); if (rawMatch) return rawMatch[0]; return null; } function guessFinish(setName: string): string { const lower = setName.toLowerCase().trim(); // Direct match if (FINISH_MAP[lower]) return FINISH_MAP[lower]; // Partial match for (const [key, value] of Object.entries(FINISH_MAP)) { if (lower.includes(key)) return value; } return "Matte"; // Default } function parseMarkdownTable(markdown: string, brandFile: string): PaintEntry[] { const brandName = BRAND_NAMES[brandFile] || brandFile.replace(/_/g, " "); const lines = markdown.split("\n").filter((l) => l.trim().startsWith("|")); if (lines.length < 2) return []; // Parse header to determine column layout const header = lines[0] .split("|") .map((c) => c.trim().toLowerCase()) .filter(Boolean); const hasCode = header.includes("code"); // Determine column indices const nameIdx = 0; const codeIdx = hasCode ? 1 : -1; const setIdx = hasCode ? 2 : 1; const rIdx = hasCode ? 3 : 2; const gIdx = hasCode ? 4 : 3; const bIdx = hasCode ? 5 : 4; const hexIdx = hasCode ? 6 : 5; const entries: PaintEntry[] = []; // Skip header and separator rows for (let i = 2; i < lines.length; i++) { const cells = lines[i] .split("|") .map((c) => c.trim()) .filter(Boolean); if (cells.length < (hasCode ? 7 : 6)) continue; const name = cells[nameIdx]; const code = codeIdx >= 0 ? cells[codeIdx] : null; const set = cells[setIdx] || ""; const hex = extractHex(cells[hexIdx]); if (!name || !hex) continue; const finish = guessFinish(set); const id = `paint-${brandFile}-${name}-${set}`.replace(/[^a-zA-Z0-9-]/g, "_").toLowerCase(); entries.push({ id, name, brand: brandName, type: "paint", color: name, // For paints, the name IS the color colorHex: hex, line: set, finish, productCode: code && code !== "null" ? code : null, }); } return entries; } async function fetchBrand(brandFile: string): Promise { const url = `${GITHUB_RAW}/${brandFile}.md`; try { const resp = await fetch(url); if (!resp.ok) { console.warn(` ⚠ Failed to fetch ${brandFile}: ${resp.status}`); return []; } const md = await resp.text(); const entries = parseMarkdownTable(md, brandFile); console.log(` ✓ ${BRAND_NAMES[brandFile] || brandFile}: ${entries.length} paints`); return entries; } catch (err) { console.warn(` ⚠ Error fetching ${brandFile}:`, err); return []; } } async function main() { console.log("Fetching paint data from GitHub...\n"); const allEntries: PaintEntry[] = []; // Fetch in batches of 5 to avoid rate limits for (let i = 0; i < BRANDS.length; i += 5) { const batch = BRANDS.slice(i, i + 5); const results = await Promise.all(batch.map(fetchBrand)); allEntries.push(...results.flat()); } console.log(`\nTotal: ${allEntries.length} paints from ${BRANDS.length} brands`); // Write JSON const outDir = resolve(__dirname, "../src/data/catalog"); mkdirSync(outDir, { recursive: true }); const outPath = resolve(outDir, "paints.json"); writeFileSync(outPath, JSON.stringify(allEntries, null, 2)); console.log(`\nWritten to: ${outPath}`); } main().catch(console.error);