103 lines
2.8 KiB
TypeScript
103 lines
2.8 KiB
TypeScript
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<RichContent>;
|
|
}
|
|
|
|
/**
|
|
* 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<Response> {
|
|
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(
|
|
`<meta[^>]+property=["']og:${tag}["'][^>]+content=["']([^"']+)["']`,
|
|
"i",
|
|
),
|
|
new RegExp(
|
|
`<meta[^>]+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<RichContent | undefined> {
|
|
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;
|
|
}
|
|
}
|