import type { RichContent } from "../model/interfaces.ts"; import { youtubeProvider } from "./providers/youtube.ts"; import { bandcampProvider } from "./providers/bandcamp.ts"; import { soundcloudProvider } from "./providers/soundcloud.ts"; import { genericProvider } from "./providers/generic.ts"; export interface RichContentProvider { name: string; matches(url: string): boolean; fetch(url: string): Promise; } /** * Register providers in priority order. The first match wins. * `genericProvider` must stay last — it always matches. */ const providers: RichContentProvider[] = [ youtubeProvider, bandcampProvider, soundcloudProvider, genericProvider, ]; // Shared utilities exported for use by providers export async function fetchWithTimeout( url: string, timeoutMs = 5000, ): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(url, { signal: controller.signal, headers: { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", "Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7", }, }); } finally { clearTimeout(timer); } } 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)), ); } export function extractOgTag( html: string, tag: string, ): string | undefined { const patterns = [ new RegExp( `]+property=["']og:${tag}["'][^>]+content=["']([^"']+)["']`, "i", ), new RegExp( `]+content=["']([^"']+)["'][^>]+property=["']og:${tag}["']`, "i", ), ]; for (const pattern of patterns) { const match = html.match(pattern); if (match) return decodeHtmlEntities(match[1]); } return undefined; } export function isValidHttpUrl(raw: string): boolean { try { const u = new URL(raw); return u.protocol === "http:" || u.protocol === "https:"; } catch { return false; } } export async function fetchRichContent( url: string, ): Promise { try { const provider = providers.find((p) => p.matches(url))!; return await provider.fetch(url); } catch (err) { console.error(`[rich-content] Failed to fetch metadata for ${url}:`, err); return undefined; } }