vibe coded v1
This commit is contained in:
97
api/services/rich-content-service.ts
Normal file
97
api/services/rich-content-service.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user