/** * Content extraction helpers for the website capture pipeline. * * Handles library detection, visible text extraction, Gemini captioning, * and asset description generation. * * All page.evaluate() calls use string expressions to avoid * tsx/esbuild __name injection (see esbuild issue #1031). */ import type { Page } from "puppeteer-core"; import { readdirSync, statSync, readFileSync } from "node:fs"; import { join } from "node:path"; import type { CatalogedAsset } from "./assetCataloger.js"; import type { DesignTokens } from "./types.js"; /** * Detect JS libraries via window globals, DOM fingerprints, script URLs, * and WebGL shader analysis. * * Returns a deduplicated list of detected library names. */ export async function detectLibraries( page: Page, capturedShaders?: Array<{ type: string; source: string }>, ): Promise { let detectedLibraries: string[] = []; try { detectedLibraries = (await page.evaluate(`(() => { var libs = []; function add(name) { if (libs.indexOf(name) === -1) libs.push(name); } // 1. Window globals (works for CDN-loaded / non-bundled libraries) if (typeof window.gsap !== 'undefined' || typeof window.TweenMax !== 'undefined') add('GSAP'); if (typeof window.ScrollTrigger !== 'undefined') add('GSAP ScrollTrigger'); if (typeof window.THREE !== 'undefined') add('Three.js'); if (typeof window.PIXI !== 'undefined') add('PixiJS'); if (typeof window.BABYLON !== 'undefined') add('Babylon.js'); if (typeof window.Lottie !== 'undefined' || typeof window.lottie !== 'undefined') add('Lottie'); if (typeof window.__NEXT_DATA__ !== 'undefined') add('Next.js'); if (typeof window.__NUXT__ !== 'undefined') add('Nuxt'); if (typeof window.Webflow !== 'undefined') add('Webflow'); // 2. DOM fingerprints (survive bundling — most reliable for modern sites) // Three.js sets data-engine on every canvas it creates var threeCanvas = document.querySelector('canvas[data-engine*="three"]'); if (threeCanvas) add('Three.js (' + (threeCanvas.getAttribute('data-engine') || '') + ')'); // Babylon.js also sets data-engine var babylonCanvas = document.querySelector('canvas[data-engine*="Babylon"]'); if (babylonCanvas) add('Babylon.js'); // Lottie web components if (document.querySelector('dotlottie-wc, lottie-player, dotlottie-player')) add('Lottie'); // Rive if (document.querySelector('canvas[class*="rive"], rive-canvas')) add('Rive'); // React/Next.js if (document.getElementById('__next')) add('Next.js'); if (document.getElementById('__nuxt')) add('Nuxt'); if (document.querySelector('[data-reactroot], [data-react-helmet]')) add('React'); // Svelte if (document.querySelector('[class*="svelte-"]')) add('Svelte'); // Tailwind (utility class detection) if (document.querySelector('[class*="flex "], [class*="grid "], [class*="px-"], [class*="py-"]')) add('Tailwind CSS'); // Framer Motion if (document.querySelector('[style*="--framer-"], [data-framer-component-type]')) add('Framer Motion'); // 3. Script URL patterns document.querySelectorAll('script[src]').forEach(function(s) { var src = s.src.toLowerCase(); if (src.includes('gsap') || src.includes('tweenmax') || src.includes('greensock')) add('GSAP'); if (src.includes('scrolltrigger')) add('GSAP ScrollTrigger'); if (src.includes('three.module') || src.includes('three.min')) add('Three.js'); if (src.includes('pixi')) add('PixiJS'); if (src.includes('lottie') || src.includes('bodymovin')) add('Lottie'); if (src.includes('framer-motion')) add('Framer Motion'); if (src.includes('anime.min') || src.includes('animejs')) add('Anime.js'); if (src.includes('matter.min') || src.includes('matter-js')) add('Matter.js'); if (src.includes('lenis')) add('Lenis (smooth scroll)'); }); return libs; })()`)) as string[]; } catch { // Non-blocking } // 4. Shader fingerprinting — infer WebGL framework from captured GLSL try { const shaders = capturedShaders || []; if (shaders.length > 0) { const allSource = shaders.map((s) => s.source).join("\n"); const add = (name: string) => { if (!detectedLibraries.includes(name)) detectedLibraries.push(name); }; add("WebGL"); // Three.js shader fingerprints (built-in uniforms that survive bundling) if (allSource.includes("modelViewMatrix") && allSource.includes("projectionMatrix")) add("Three.js (confirmed via shaders)"); // PixiJS shader fingerprints else if ( allSource.includes("vTextureCoord") && allSource.includes("uSampler") && !allSource.includes("modelViewMatrix") ) add("PixiJS (confirmed via shaders)"); // Babylon.js shader fingerprints else if (allSource.includes("viewProjection") && allSource.includes("world")) add("Babylon.js (confirmed via shaders)"); } } catch { /* non-blocking */ } return detectedLibraries; } /** * Extract all visible text from the page in DOM order using a TreeWalker. * Truncates to ~30K chars to avoid blowing up downstream prompts. */ export async function extractVisibleText(page: Page): Promise { let visibleTextContent = ""; try { visibleTextContent = (await page.evaluate(`(() => { var cookieRe = /^(accept|cookie|privacy|that's fine|got it|i agree|reject all|accept all|manage cookies|consent)$/i; var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null); var texts = []; var node; while (node = walker.nextNode()) { var text = (node.textContent || '').trim(); if (text.length < 3) continue; var el = node.parentElement; if (!el) continue; var style = getComputedStyle(el); if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0') continue; var tag = el.tagName.toLowerCase(); if (tag === 'script' || tag === 'style' || tag === 'noscript') continue; // Skip very short text inside nav/footer (catches single-word nav links) // Threshold is 8 chars to preserve footer copy like "© 2026 Stripe" (16 chars) var inNavOrFooter = el.closest('nav, footer, [role="navigation"]'); if (inNavOrFooter && text.length < 8) continue; // Skip common cookie/consent patterns if (cookieRe.test(text)) continue; texts.push('[' + tag + '] ' + text); } return texts.join('\\n'); })()`)) as string; // Truncate to ~30K chars to avoid blowing up the prompt if (visibleTextContent.length > 30000) { visibleTextContent = visibleTextContent.slice(0, 30000) + "\n[...truncated]"; } } catch { // Non-blocking } return visibleTextContent; } /** * Caption downloaded images using Gemini vision API. * * Batches requests to stay under free-tier rate limits. * Returns a map of filename -> caption string. */ export async function captionImagesWithGemini( outputDir: string, progress: (stage: string, detail?: string) => void, warnings: string[], ): Promise> { const geminiCaptions: Record = {}; const geminiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY; if (!geminiKey) return geminiCaptions; progress("design", "Captioning images with Gemini vision..."); try { const { GoogleGenAI } = await import("@google/genai"); const ai = new GoogleGenAI({ apiKey: geminiKey }); const imageFiles = readdirSync(join(outputDir, "assets")).filter((f: string) => /\.(png|jpg|jpeg|webp|gif)$/i.test(f), ); // Caption in parallel batches via Gemini vision API. // Free tier: 5 RPM → batch 5, 12s pause (~$0 but slow) // Paid tier: 2000 RPM → batch 20, 1s pause (~$0.001/image, fast) // We try a larger batch first; if rate-limited, fall back to smaller batches. // Default is a preview model — update when GA ships. // Benchmark (49 images, paid tier): 3.1-flash-lite-preview ~507ms/img 131ch avg, // 2.5-flash-lite ~230ms/img 117ch avg. Preview has richer captions but higher variance. // Override: HYPERFRAMES_GEMINI_MODEL=gemini-2.5-flash-lite const model = process.env.HYPERFRAMES_GEMINI_MODEL || "gemini-3.1-flash-lite-preview"; const BATCH_SIZE = 20; for (let i = 0; i < imageFiles.length; i += BATCH_SIZE) { const batch = imageFiles.slice(i, i + BATCH_SIZE); const results = await Promise.allSettled( batch.map(async (file: string) => { const filePath = join(outputDir, "assets", file); const stat = statSync(filePath); if (stat.size > 4_000_000) return { file, caption: "" }; // skip images > 4 MB (Gemini inline limit) const buffer = readFileSync(filePath); const base64 = buffer.toString("base64"); const ext = file.split(".").pop()?.toLowerCase() || "png"; const mimeType = ext === "jpg" ? "image/jpeg" : `image/${ext}`; const response = await ai.models.generateContent({ model, contents: [ { role: "user", parts: [ { inlineData: { mimeType, data: base64 } }, { text: "Describe this website image in ONE short sentence for a video storyboard. Focus on: what it shows, dominant colors, whether background is light or dark. Be factual, not creative.", }, ], }, ], config: { maxOutputTokens: 500 }, }); return { file, caption: response.text?.trim() || "" }; }), ); for (const result of results) { if (result.status === "fulfilled" && result.value.caption) { geminiCaptions[result.value.file] = result.value.caption; } } // Pace requests between batches (paid tier: 2000+ RPM, free tier: rate-limited) if (i + BATCH_SIZE < imageFiles.length) { await new Promise((r) => setTimeout(r, 2000)); // 2s pause between batches — paid tier handles 2000 RPM, free tier retries via Promise.allSettled } progress( "design", `Captioned ${Math.min(i + BATCH_SIZE, imageFiles.length)}/${imageFiles.length} images...`, ); } progress("design", `${Object.keys(geminiCaptions).length} images captioned with Gemini`); } catch (err) { warnings.push(`Gemini captioning failed: ${err}`); } return geminiCaptions; } /** * Generate asset-descriptions.md — one-line descriptions for each downloaded asset. * * Returns the description lines (without the markdown header). */ export function generateAssetDescriptions( outputDir: string, tokens: DesignTokens, catalogedAssets: CatalogedAsset[], geminiCaptions: Record, ): string[] { // Sort: Gemini-captioned images first (richest descriptions), then uncaptioned, then SVGs, then fonts const captionedLines: string[] = []; const uncaptionedLines: string[] = []; const svgLines: string[] = []; const fontLines: string[] = []; // Describe downloaded images const assetsPath = join(outputDir, "assets"); try { for (const file of readdirSync(assetsPath)) { if (file === "svgs" || file === "fonts" || file === "lottie" || file === "videos") continue; const filePath = join(assetsPath, file); const stat = statSync(filePath); if (!stat.isFile()) continue; const sizeKb = Math.round(stat.size / 1024); const catalogMatch = catalogedAssets.find( (a) => a.url && file.includes(a.url.split("/").pop()?.split("?")[0]?.slice(0, 20) || "___"), ); const desc = catalogMatch?.description || catalogMatch?.notes || ""; const heading = catalogMatch?.nearestHeading || ""; const section = catalogMatch?.sectionClasses || ""; const aboveFold = catalogMatch?.aboveFold ? "above fold" : ""; const geminiCaption = geminiCaptions[file]; const cleanName = file.replace(/\.[^.]+$/, "").replace(/[-_]/g, " "); const parts = [`${file} — ${sizeKb}KB`]; if (geminiCaption) { parts.push(geminiCaption); captionedLines.push(parts.join(", ")); } else { if (desc) parts.push(`"${desc.slice(0, 80)}"`); if (heading) parts.push(`section: "${heading.slice(0, 60)}"`); else if (section) parts.push(`in: ${section.split(" ").slice(0, 3).join(" ")}`); if (aboveFold) parts.push(aboveFold); if (!desc && !heading) parts.push(cleanName); uncaptionedLines.push(parts.join(", ")); } } } catch { /* no assets dir */ } // Describe SVGs try { const svgsPath = join(assetsPath, "svgs"); for (const file of readdirSync(svgsPath)) { if (!file.endsWith(".svg")) continue; const svgMatch = tokens.svgs.find( (s) => s.label && file.includes( s.label .toLowerCase() .replace(/[^a-z0-9]/g, "-") .slice(0, 15), ), ); const label = svgMatch?.label || file.replace(".svg", "").replace(/-/g, " "); const isLogo = svgMatch?.isLogo || file.includes("logo"); svgLines.push(`svgs/${file} — ${isLogo ? "logo: " : "icon: "}${label}`); } } catch { /* no svgs dir */ } // Describe fonts try { const fontsPath = join(assetsPath, "fonts"); for (const file of readdirSync(fontsPath)) { fontLines.push(`fonts/${file} — font file`); } } catch { /* no fonts dir */ } return [...captionedLines, ...uncaptionedLines, ...svgLines, ...fontLines]; }