/** * Pure brand-signal extraction from a website's HTML. * * Shared by the `analyze-brand-assets` action across templates. DB-agnostic and * framework-agnostic: it only fetches (through the SSRF-safe helper) and parses * HTML. The DB access (resolving an existing Brand Kit) stays in the template's * thin action wrapper. */ import { ssrfSafeFetch } from "../extensions/url-safety.js"; import type { BrandWebsiteSignals } from "./types.js"; /** * Normalize a user-supplied brand website URL: add an `https://` scheme when * missing and reject anything that isn't http(s). Throws on empty input or an * unsupported scheme. */ export function normalizeBrandWebsiteUrl(input: string): string { const trimmed = input.trim(); if (!trimmed) throw new Error("Website URL is required"); const hasScheme = /^[a-z][a-z\d+.-]*:/i.test(trimmed); const candidate = hasScheme ? trimmed : `https://${trimmed}`; const parsed = new URL(candidate); if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { throw new Error("Only http and https URLs are allowed"); } return parsed.href; } /** * Parse brand signals out of a page's HTML: meta theme-color, CSS custom * properties (capped), @font-face declarations (capped), title, and meta * description. Pure — no network. The provided `url` is echoed back. */ export function extractBrandSignalsFromHtml( html: string, url: string, ): BrandWebsiteSignals { const extracted: BrandWebsiteSignals = { url }; // Extract meta theme-color const themeColorMatch = html.match( /]*name=["']theme-color["'][^>]*content=["']([^"']+)["']/i, ); if (themeColorMatch) { extracted.themeColor = themeColorMatch[1]; } // Extract CSS custom properties (--var-name: value) const cssVarMatches = html.matchAll(/--([\w-]+)\s*:\s*([^;}\n]+)/g); const cssVars: Record = {}; for (const match of cssVarMatches) { cssVars[`--${match[1]}`] = match[2].trim(); } if (Object.keys(cssVars).length > 0) { // Limit to first 50 to avoid overwhelming output const entries = Object.entries(cssVars).slice(0, 50); extracted.cssCustomProperties = Object.fromEntries(entries); } // Extract @font-face declarations const fontFaceMatches = html.matchAll(/@font-face\s*\{([^}]+)\}/g); const fonts: { family?: string; src?: string }[] = []; for (const match of fontFaceMatches) { const block = match[1]; const familyMatch = block.match(/font-family\s*:\s*["']?([^"';]+)["']?/); const srcMatch = block.match(/src\s*:\s*([^;]+)/); fonts.push({ family: familyMatch?.[1]?.trim(), src: srcMatch?.[1]?.trim()?.slice(0, 200), }); } if (fonts.length > 0) { extracted.fontFaces = fonts.slice(0, 20); } // Extract title const titleMatch = html.match(/]*>([^<]+)<\/title>/i); if (titleMatch) { extracted.pageTitle = titleMatch[1].trim(); } // Extract meta description const descMatch = html.match( /]*name=["']description["'][^>]*content=["']([^"']+)["']/i, ); if (descMatch) { extracted.metaDescription = descMatch[1]; } return extracted; } /** * Fetch a brand website (SSRF-safe) and extract its brand signals. Returns the * parsed signals, or an `{ url, error }` shape if the fetch/parse fails — the * caller decides how to surface that to the agent. */ export async function fetchBrandWebsiteSignals( websiteUrl: string, ): Promise { try { const url = normalizeBrandWebsiteUrl(websiteUrl); const response = await ssrfSafeFetch( url, { headers: { "User-Agent": "Mozilla/5.0 (compatible; AgentNative/1.0; +https://agent-native.com)", }, signal: AbortSignal.timeout(10000), }, { maxRedirects: 3 }, ); const html = await response.text(); return extractBrandSignalsFromHtml(html, url); } catch (err) { return { url: websiteUrl, error: `Failed to fetch: ${err instanceof Error ? err.message : String(err)}`, }; } }