feat: add race cover image extraction
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
Some checks failed
CI / build-and-test (pull_request) Has been cancelled
This commit is contained in:
103
backend/src/raceCoverImage.ts
Normal file
103
backend/src/raceCoverImage.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user