/** * Clone Architect — Catalog Enrichment * * Reads catalog/index.json and enriches each entry with: * - category : brand category classification * - dark : dark-mode site flag * - hasFrontmatter: DESIGN.md has YAML ---…--- block * - hasScreenshots: screenshots/ folder has files * - font : primary font extracted * - completeness : 0-100 score from YAML frontmatter * - extractedAt : ISO date from YAML frontmatter * * Usage: npx tsx scripts/enrich-catalog.ts */ import { readFile, writeFile } from 'fs/promises'; import { existsSync, readdirSync, statSync } from 'fs'; import { join } from 'path'; const ROOT = join(process.cwd()); const EXTRACTIONS = join(ROOT, 'extractions'); const CATALOG_PATH = join(ROOT, 'catalog', 'index.json'); // ── Category classifier ───────────────────────────────────────────── const CATEGORY_RULES: Array<{ pattern: RegExp; label: string }> = [ { pattern: /binance|deribit|hyperliquid|coinbase|kraken|bybit|okx|ftx|bitfinex|gemini/i, label: 'Crypto / Trading' }, { pattern: /stripe|paypal|adyen|braintree|square|klarna|checkout|revolut|wise|mercury/i, label: 'Fintech / Payments' }, { pattern: /airbnb|booking|expedia|hotels|tripadvisor|vrbo|hostelworld/i, label: 'Travel & Hospitality' }, { pattern: /acnestudios|addicts|nike|adidas|zara|hm\.|farfetch|ssense|mrporter|matchesfashion|sneaks/i, label: 'Fashion & Retail' }, { pattern: /shopify|commerce|magento|woocommerce|dimension|myshopify/i, label: 'E-commerce' }, { pattern: /linear|notion|akiflow|clickup|basecamp|asana|monday|todoist|craft\.do|cron\./i, label: 'Productivity & PM' }, { pattern: /figma|framer|sketch|webflow|canva|pitch|miro|loom|comet/i, label: 'Design & Creative Tools' }, { pattern: /cursor|bolt\.new|replit|github|gitlab|vercel|netlify|railway|render|supabase|neon/i, label: 'Dev Tools & Cloud' }, { pattern: /arc\.net|browser|firefox|chrome/i, label: 'Browser / Platform' }, { pattern: /clerk|auth0|okta|jumpcloud|1password|lastpass/i, label: 'Auth & Security' }, { pattern: /fabric|attio|hubspot|salesforce|intercom|zendesk|pipedrive/i, label: 'CRM & Business' }, { pattern: /ps-apps|ps-tools|aurax|dimension\.ps|lola/i, label: 'PS-Tools Projects' }, { pattern: /example\.com/i, label: 'Test / Example' }, ]; function classifyBrand(domain: string): string { for (const { pattern, label } of CATEGORY_RULES) { if (pattern.test(domain)) return label; } return 'SaaS / Product'; } // ── Parse YAML frontmatter from DESIGN.md ─────────────────────────── interface FrontmatterData { hasFrontmatter: boolean; completeness: number | null; extractedAt: string | null; } function parseFrontmatter(content: string): FrontmatterData { if (!content.startsWith('---')) { return { hasFrontmatter: false, completeness: null, extractedAt: null }; } const end = content.indexOf('\n---\n', 4); if (end === -1) { return { hasFrontmatter: false, completeness: null, extractedAt: null }; } const block = content.slice(4, end); const completenessMatch = block.match(/^completeness:\s*(\d+)/m); const extractedAtMatch = block.match(/^extracted_at:\s*"?([^"\n]+)"?/m); return { hasFrontmatter: true, completeness: completenessMatch ? parseInt(completenessMatch[1]) : null, extractedAt: extractedAtMatch ? extractedAtMatch[1].trim() : null, }; } // ── Extract primary font from tokens.json ─────────────────────────── function extractFont(tokens: any): string | null { const fp = tokens?.typography?.fontFamily?.primary; if (fp) return fp; const bodyFont = tokens?.typography?.body?.fontFamily; if (bodyFont) return bodyFont; return null; } // ── Detect dark site from tokens.json ─────────────────────────────── function isDarkSite(tokens: any): boolean { const bg = tokens?.colors?.background?.primary || ''; // Parse rgb(r,g,b) or hex const hexMatch = bg.match(/^#([0-9a-f]{6})/i); if (hexMatch) { const r = parseInt(hexMatch[1].slice(0, 2), 16); const g = parseInt(hexMatch[1].slice(2, 4), 16); const b = parseInt(hexMatch[1].slice(4, 6), 16); const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255; return lum < 0.3; } const rgbMatch = bg.match(/rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); if (rgbMatch) { const [, r, g, b] = rgbMatch.map(Number); const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255; return lum < 0.3; } return false; } // ── Count screenshots ──────────────────────────────────────────────── function hasScreenshotsDir(domain: string): boolean { const screenshotsDir = join(EXTRACTIONS, domain, 'screenshots'); if (!existsSync(screenshotsDir)) return false; try { const files = readdirSync(screenshotsDir).filter(f => /\.(png|jpg|jpeg|webp)$/i.test(f)); return files.length > 0; } catch { return false; } } // ── Main ───────────────────────────────────────────────────────────── async function main() { console.log('🔧 Enriching catalog/index.json...\n'); const raw = await readFile(CATALOG_PATH, 'utf-8'); const catalog = JSON.parse(raw); let enriched = 0; let skipped = 0; let withScore = 0; let withFrontmatter = 0; for (const brand of catalog.brands) { const domain: string = brand.domain; const extractionDir = join(EXTRACTIONS, domain); const designMdPath = join(extractionDir, 'DESIGN.md'); const tokensPath = join(extractionDir, 'tokens.json'); // Category (always computable) brand.category = classifyBrand(domain); // Screenshots brand.hasScreenshots = hasScreenshotsDir(domain); // Tokens-derived fields if (existsSync(tokensPath)) { try { const tokens = JSON.parse(await readFile(tokensPath, 'utf-8')); brand.dark = isDarkSite(tokens); const font = extractFont(tokens); if (font) brand.font = font; } catch { // tokens.json malformed — skip token fields } } // DESIGN.md frontmatter fields if (existsSync(designMdPath)) { try { const content = await readFile(designMdPath, 'utf-8'); const fm = parseFrontmatter(content); brand.hasFrontmatter = fm.hasFrontmatter; if (fm.hasFrontmatter) withFrontmatter++; if (fm.completeness !== null) { brand.completeness = fm.completeness; withScore++; } if (fm.extractedAt) { brand.extractedAt = fm.extractedAt; } } catch { brand.hasFrontmatter = false; } enriched++; } else { skipped++; } } // Update metadata catalog.enrichedAt = new Date().toISOString(); catalog.version = bumpVersion(catalog.version || '1.0.0'); await writeFile(CATALOG_PATH, JSON.stringify(catalog, null, 2)); console.log(`✅ Catalog enriched and saved to catalog/index.json`); console.log(` ${enriched} brands processed | ${skipped} skipped (no DESIGN.md)`); console.log(` ${withFrontmatter} have YAML frontmatter | ${withScore} have completeness scores`); console.log(` Category distribution:`); const catCounts = new Map(); for (const b of catalog.brands) { catCounts.set(b.category, (catCounts.get(b.category) || 0) + 1); } for (const [cat, count] of [...catCounts.entries()].sort((a, b) => b[1] - a[1])) { console.log(` ${cat}: ${count}`); } } function bumpVersion(v: string): string { const parts = v.split('.').map(Number); parts[2] = (parts[2] || 0) + 1; return parts.join('.'); } main().catch(err => { console.error('Error enriching catalog:', err); process.exit(1); });