104 lines
2.6 KiB
TypeScript
104 lines
2.6 KiB
TypeScript
const IMAGE_META_KEYS = new Set([
|
|
"og:image",
|
|
"og:image:url",
|
|
"twitter:image",
|
|
"twitter:image:src",
|
|
]);
|
|
|
|
const FETCH_TIMEOUT_MS = 5_000;
|
|
|
|
function getAttribute(tag: string, name: string): string | null {
|
|
const pattern = new RegExp(`${name}\\s*=\\s*["']([^"']+)["']`, "i");
|
|
return tag.match(pattern)?.[1] ?? null;
|
|
}
|
|
|
|
function toHttpUrl(value: string, baseUrl: string): string | null {
|
|
try {
|
|
const url = new URL(value, baseUrl);
|
|
return url.protocol === "http:" || url.protocol === "https:" ? url.href : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isRuncRunUrl(value: string): boolean {
|
|
try {
|
|
const hostname = new URL(value).hostname.toLowerCase();
|
|
return hostname === "runc.run" || hostname.endsWith(".runc.run");
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function findRuncIntroImage(html: string, baseUrl: string): string | null {
|
|
const introMatch = html.match(/<div\b[^>]*class=["'][^"']*\brun-intro__image\b[^"']*["'][^>]*>[\s\S]*?<img\b[^>]*>/i);
|
|
if (!introMatch) {
|
|
return null;
|
|
}
|
|
|
|
const src = getAttribute(introMatch[0], "src");
|
|
return src ? toHttpUrl(src, baseUrl) : null;
|
|
}
|
|
|
|
function findMetaImage(html: string, baseUrl: string): string | null {
|
|
const tags = html.match(/<meta\b[^>]*>/gi) ?? [];
|
|
|
|
for (const tag of tags) {
|
|
const key = (getAttribute(tag, "property") || getAttribute(tag, "name") || "").toLowerCase();
|
|
if (!IMAGE_META_KEYS.has(key)) {
|
|
continue;
|
|
}
|
|
|
|
const content = getAttribute(tag, "content");
|
|
if (!content) {
|
|
continue;
|
|
}
|
|
|
|
const imageUrl = toHttpUrl(content, baseUrl);
|
|
if (imageUrl) {
|
|
return imageUrl;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function extractRaceCoverImageFromHtml(html: string, pageUrl: string): string | null {
|
|
if (isRuncRunUrl(pageUrl)) {
|
|
const runcImage = findRuncIntroImage(html, pageUrl);
|
|
if (runcImage) {
|
|
return runcImage;
|
|
}
|
|
}
|
|
|
|
return findMetaImage(html, pageUrl);
|
|
}
|
|
|
|
export async function extractRaceCoverImage(officialUrl: string): Promise<string | null> {
|
|
const normalizedUrl = toHttpUrl(officialUrl, officialUrl);
|
|
if (!normalizedUrl) {
|
|
return null;
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
|
|
try {
|
|
const response = await fetch(normalizedUrl, {
|
|
redirect: "follow",
|
|
signal: controller.signal,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
return null;
|
|
}
|
|
|
|
const html = await response.text();
|
|
return extractRaceCoverImageFromHtml(html, response.url || normalizedUrl);
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|