/** * Scraping helpers: the metadata a link preview reads from a page's HTML — * meta and OG tags, the title, JSON-LD, images, icons and brand colors. * * @module */ /** Decodes the named and numeric HTML entities in `str`. */ function decodeHtmlEntities(str: string): string { return str .replace(/&/gi, "&") .replace(/</gi, "<") .replace(/>/gi, ">") .replace(/"/gi, '"') .replace(/'/gi, "'") .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(Number(dec))) .replace( /&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)), ); } /** * Returns the `content` of the first `` tag whose `attr` equals `value` * (either attribute ordering). Each tag is matched in isolation so * the `content` capture can never span across tag boundaries. */ function extractMetaContent( html: string, attr: string, value: string, ): string | undefined { const escaped = value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const keyRe = new RegExp(`\\b${attr}=["']${escaped}["']`, "i"); const contentRe = /\bcontent=(["'])([\s\S]*?)\1/i; const tagRe = /]*>/gi; let m: RegExpExecArray | null; while ((m = tagRe.exec(html)) !== null) { const tag = m[0]; if (!keyRe.test(tag)) continue; const content = contentRe.exec(tag); if (content) return decodeHtmlEntities(content[2]); } return undefined; } /** Returns the content of ``. */ export function extractOgTag( html: string, tag: string, ): string | undefined { return extractMetaContent(html, "property", `og:${tag}`); } /** * Returns the content of `` (both attribute * orderings). */ export function extractMetaName( html: string, name: string, ): string | undefined { return extractMetaContent(html, "name", name); } /** Returns the text content of the `` element. */ export function extractPageTitle(html: string): string | undefined { const match = html.match(/<title[^>]*>([^<]+)<\/title>/i); return match ? decodeHtmlEntities(match[1].trim()) : undefined; } // ── Brand color helpers ─────────────────────────────────────────────────────── /** * Returns the page's `theme-color`. * * Sites routinely ship the tag twice, scoped by `media`, and often list the * dark-scheme one first — so unlike `extractMetaName` this can't just take the * first match. Prefer the unscoped tag, then the light-scheme one, then any. */ export function extractThemeColor(html: string): string | undefined { const tagRe = /<meta\b[^>]*>/gi; const contentRe = /\bcontent=(["'])([\s\S]*?)\1/i; const mediaRe = /\bmedia=(["'])([\s\S]*?)\1/i; let unscoped: string | undefined; let light: string | undefined; let any: string | undefined; let m: RegExpExecArray | null; while ((m = tagRe.exec(html)) !== null) { const tag = m[0]; if (!/\bname=["']theme-color["']/i.test(tag)) continue; const content = contentRe.exec(tag)?.[2]; if (!content) continue; const media = mediaRe.exec(tag)?.[2]; if (!media) unscoped ??= content; else if (/light/i.test(media)) light ??= content; any ??= content; } return unscoped ?? light ?? any; } /** Returns the `color` of `<link rel="mask-icon">` (Safari pinned tabs). */ export function extractMaskIconColor(html: string): string | undefined { const linkRe = /<link[^>]+>/gi; let m: RegExpExecArray | null; while ((m = linkRe.exec(html)) !== null) { const tag = m[0]; if (!/\brel=["'][^"']*mask-icon[^"']*["']/i.test(tag)) continue; const color = /\bcolor=(["'])([\s\S]*?)\1/i.exec(tag)?.[2]; if (color) return color; } return undefined; } /** * The handful of named colors that actually show up in `theme-color`. The full * 148-name table isn't worth carrying for the long tail. */ const NAMED_COLORS: Record<string, string> = { black: "#000000", white: "#ffffff", red: "#ff0000", green: "#008000", blue: "#0000ff", yellow: "#ffff00", orange: "#ffa500", purple: "#800080", gray: "#808080", grey: "#808080", silver: "#c0c0c0", maroon: "#800000", navy: "#000080", teal: "#008080", olive: "#808000", lime: "#00ff00", aqua: "#00ffff", cyan: "#00ffff", fuchsia: "#ff00ff", magenta: "#ff00ff", }; function clampByte(n: number): number { return Math.max(0, Math.min(255, Math.round(n))); } /** * Normalizes a CSS color to canonical lowercase `#rrggbb`, or `undefined` when * it isn't one of the forms sites actually use. Alpha is dropped — the color is * only ever used as a tint over the app's own surface. */ export function normalizeCssColor(raw: string | undefined): string | undefined { if (!raw) return undefined; const value = raw.trim().toLowerCase(); const named = NAMED_COLORS[value]; if (named) return named; const hex = /^#([0-9a-f]{3,8})$/.exec(value)?.[1]; if (hex) { if (hex.length === 3 || hex.length === 4) { const [r, g, b] = [...hex.slice(0, 3)]; return `#${r}${r}${g}${g}${b}${b}`; } if (hex.length === 6 || hex.length === 8) return `#${hex.slice(0, 6)}`; return undefined; } const rgb = /^rgba?\(([^)]+)\)$/.exec(value)?.[1]; if (rgb) { const parts = rgb.split(/[\s,/]+/).filter(Boolean).slice(0, 3); if (parts.length !== 3) return undefined; const bytes = parts.map((p) => { const n = parseFloat(p); if (!Number.isFinite(n)) return NaN; return clampByte(p.endsWith("%") ? (n / 100) * 255 : n); }); if (bytes.some(Number.isNaN)) return undefined; return `#${bytes.map((b) => b.toString(16).padStart(2, "0")).join("")}`; } return undefined; } // ── JSON-LD helpers ─────────────────────────────────────────────────────────── /** What a page's JSON-LD says about it. */ export interface JsonLdResult { /** The node's `name` or `headline`. */ title?: string; /** The node's `description`. */ description?: string; /** * The node's `image`, `thumbnailUrl` or `thumbnail`, when it is an absolute * URL. */ thumbnailUrl?: string; } function ldString(v: unknown): string | undefined { if (typeof v === "string" && v.trim()) return v.trim(); if (Array.isArray(v) && typeof v[0] === "string" && v[0].trim()) { return v[0].trim(); } return undefined; } function ldImage(v: unknown): string | undefined { if ( typeof v === "string" && (v.startsWith("http://") || v.startsWith("https://")) ) return v; if (Array.isArray(v)) return ldImage(v[0]); if (v && typeof v === "object") { const o = v as Record<string, unknown>; return ldImage(o.url ?? o.contentUrl); } return undefined; } function ldExtractNode(data: unknown): JsonLdResult { if (Array.isArray(data)) { for (const item of data) { const r = ldExtractNode(item); if (r.title || r.thumbnailUrl) return r; } return {}; } if (!data || typeof data !== "object") return {}; const o = data as Record<string, unknown>; if (o["@graph"]) return ldExtractNode(o["@graph"]); return { title: ldString(o.name ?? o.headline), description: ldString(o.description), thumbnailUrl: ldImage(o.image ?? o.thumbnailUrl ?? o.thumbnail), }; } /** * Parses every `<script type="application/ld+json">` block and returns the first * node that yields a title or image. Handles `@graph`, arrays, and the common * `image` shapes (string, string[], ImageObject). */ export function extractJsonLd(html: string): JsonLdResult { const pattern = /<script[^>]+type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi; let match: RegExpExecArray | null; while ((match = pattern.exec(html)) !== null) { try { const result = ldExtractNode(JSON.parse(match[1])); if (result.title || result.thumbnailUrl) return result; } catch { /* invalid JSON — skip */ } } return {}; } /** * Returns the `src` of the first `<img>` whose declared width or height is at * least `minSize` pixels (default 200). Skips data URIs. Resolves relative URLs. */ export function extractLargeImage( html: string, baseUrl: string, minSize = 200, ): string | undefined { const imgPattern = /<img[^>]+>/gi; let match: RegExpExecArray | null; while ((match = imgPattern.exec(html)) !== null) { const tag = match[0]; const src = /\bsrc=["']([^"']+)["']/i.exec(tag)?.[1]; if (!src || src.startsWith("data:")) continue; const w = parseInt(/\bwidth=["']?(\d+)/i.exec(tag)?.[1] ?? "0"); const h = parseInt(/\bheight=["']?(\d+)/i.exec(tag)?.[1] ?? "0"); if (w >= minSize && h >= minSize) { try { return new URL(src, baseUrl).toString(); } catch { continue; } } } return undefined; } /** * Collects all `<link rel="icon">` / `<link rel="apple-touch-icon">` tags, ranks * them by declared size (largest wins), and returns the best resolved URL. * Falls back to the first match when no `sizes` attribute is present. * * SVG icons are ranked above everything: they carry no `sizes` attribute, so * they'd otherwise score 0 and lose to a 16×16 PNG, yet they scale cleanly to * whatever size the placeholder renders them at. */ export function extractBestIcon( html: string, baseUrl: string, ): string | undefined { const linkRe = /<link[^>]+>/gi; const relRe = /\brel=["']([^"']+)["']/i; const hrefRe = /\bhref=["']([^"']+)["']/i; const sizesRe = /\bsizes=["']([^"']+)["']/i; const candidates: { href: string; area: number }[] = []; let m: RegExpExecArray | null; while ((m = linkRe.exec(html)) !== null) { const tag = m[0]; const rel = relRe.exec(tag)?.[1] ?? ""; if (!/\bicon\b/i.test(rel) && !/apple-touch-icon/i.test(rel)) continue; const href = hrefRe.exec(tag)?.[1]; if (!href) continue; const sizesStr = sizesRe.exec(tag)?.[1] ?? ""; const sm = sizesStr.match(/(\d+)x(\d+)/i); const isSvg = /\.svg(\?|$)/i.test(href) || /\btype=["']image\/svg\+xml["']/i.test(tag); const area = isSvg ? Number.MAX_SAFE_INTEGER : sm ? parseInt(sm[1]) * parseInt(sm[2]) : 0; try { candidates.push({ href: new URL(href, baseUrl).toString(), area }); } catch { continue; } } if (candidates.length === 0) return undefined; candidates.sort((a, b) => b.area - a.area); return candidates[0].href; } /** Filename tokens that mark an image as UI chrome rather than content. */ const UI_IMAGE_KEYWORDS = new Set([ "logo", "icon", "sprite", "favicon", "avatar", "banner", "header", "nav", "menu", "cart", "search", "tracking", "pixel", "bg", "background", ]); /** * Checks whether `src` looks like UI chrome: an SVG, or a filename with one of * the {@link UI_IMAGE_KEYWORDS}. */ function isUiImage(src: string): boolean { if (/\.svg(\?|$)/i.test(src)) return true; const filename = src.split("?")[0].split("/").pop() ?? ""; const baseName = filename.replace(/\.[^.]+$/, ""); // strip extension // Split on common filename separators (-, _, .) and check each token return baseName.toLowerCase().split(/[-_.]/).some((t) => UI_IMAGE_KEYWORDS.has(t) ); } /** * Returns the `src` of the first `<img>` that looks like content rather than UI * chrome. Skips SVGs, data URIs, and images whose filename tokens match common * icon/logo/nav patterns (logo, icon, sprite, etc.). * Resolves relative and protocol-relative URLs. */ export function extractFirstContentImage( html: string, baseUrl: string, ): string | undefined { const imgPattern = /<img[^>]+>/gi; let match: RegExpExecArray | null; while ((match = imgPattern.exec(html)) !== null) { const tag = match[0]; const src = /\bsrc=["']([^"']+)["']/i.exec(tag)?.[1]; if (!src || src.startsWith("data:")) continue; if (isUiImage(src)) continue; try { return new URL(src, baseUrl).toString(); } catch { continue; } } return undefined; } /** * Returns the `href` of the first `<link rel="…">` whose rel contains * `relFragment`, resolved to an absolute URL using `baseUrl`. */ export function extractLinkHref( html: string, relFragment: string, baseUrl: string, ): string | undefined { const escaped = relFragment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const patterns = [ new RegExp( `<link[^>]+rel=["'][^"']*${escaped}[^"']*["'][^>]+href=["']([^"']+)["']`, "i", ), new RegExp( `<link[^>]+href=["']([^"']+)["'][^>]+rel=["'][^"']*${escaped}[^"']*["']`, "i", ), ]; for (const pattern of patterns) { const match = html.match(pattern); if (match) { try { return new URL(match[1], baseUrl).toString(); } catch { return undefined; } } } return undefined; }