feat: add race cover image extraction

This commit is contained in:
Vaka.pro
2026-04-27 22:56:41 +03:00
parent 0b7ad23252
commit 0153f223f2
15 changed files with 295 additions and 8 deletions

View File

@@ -24,6 +24,7 @@ function mockRowFromInsert(sql: string, params: unknown[]): RaceRow {
distance_km: "0",
status: null,
official_url: null,
cover_image_url: null,
start_time: null,
cluster_schedule: null,
bib_pickup: null,
@@ -47,6 +48,7 @@ function mockRowFromInsert(sql: string, params: unknown[]): RaceRow {
distance_km: String(row.distance_km ?? "0"),
status: row.status != null ? String(row.status) : null,
official_url: row.official_url != null ? String(row.official_url) : null,
cover_image_url: row.cover_image_url != null ? String(row.cover_image_url) : null,
start_time: row.start_time != null ? String(row.start_time) : null,
cluster_schedule: row.cluster_schedule != null ? String(row.cluster_schedule) : null,
bib_pickup: row.bib_pickup != null ? String(row.bib_pickup) : null,
@@ -108,7 +110,19 @@ function createMockPool(): Pool {
if (!existing) {
return emptyResult();
}
const setMatch = sql.match(/UPDATE races SET (.+) WHERE id =/);
const updated = { ...existing, updated_at: new Date() };
const setColumns =
setMatch?.[1]
.split(",")
.map((part) => part.trim())
.filter((part) => !part.startsWith("updated_at"))
.map((part) => part.split("=")[0]?.trim())
.filter((col): col is string => Boolean(col)) ?? [];
setColumns.forEach((col, index) => {
(updated as unknown as Record<string, unknown>)[col] = p[index] ?? null;
});
store.set(id, updated);
return {
rows: [updated as unknown as T],

View File

@@ -9,6 +9,7 @@ export interface RaceRow {
distance_km: string;
status: string | null;
official_url: string | null;
cover_image_url: string | null;
start_time: string | null;
cluster_schedule: string | null;
bib_pickup: string | null;
@@ -28,6 +29,7 @@ export interface RaceDto {
distanceKm: number;
status: string | null;
officialUrl: string | null;
coverImageUrl: string | null;
startTime: string | null;
clusterSchedule: string | null;
bibPickup: string | null;
@@ -64,6 +66,7 @@ export function rowToDto(row: RaceRow): RaceDto {
distanceKm: parseFloat(row.distance_km),
status: row.status,
officialUrl: row.official_url,
coverImageUrl: row.cover_image_url,
startTime: row.start_time,
clusterSchedule: row.cluster_schedule,
bibPickup: row.bib_pickup,
@@ -83,6 +86,7 @@ const FIELD_MAP: Record<string, string> = {
distanceKm: "distance_km",
status: "status",
officialUrl: "official_url",
coverImageUrl: "cover_image_url",
startTime: "start_time",
clusterSchedule: "cluster_schedule",
bibPickup: "bib_pickup",

View 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);
}
}

View File

@@ -1,6 +1,7 @@
import { Router, Request, Response } from "express";
import { pool } from "../db";
import { rowToDto, bodyToColumns, RaceRow } from "../mappers/race";
import { extractRaceCoverImage } from "../raceCoverImage";
const router = Router();
@@ -117,7 +118,15 @@ router.post("/races", async (req: Request, res: Response) => {
return;
}
const { columns, values } = bodyToColumns(body);
const payload = { ...body };
const hasManualCover = typeof payload.coverImageUrl === "string" && payload.coverImageUrl.trim() !== "";
const hasOfficialUrl = typeof payload.officialUrl === "string" && payload.officialUrl.trim() !== "";
if (!hasManualCover && hasOfficialUrl) {
payload.coverImageUrl = await extractRaceCoverImage(payload.officialUrl);
}
const { columns, values } = bodyToColumns(payload);
columns.unshift("id");
values.unshift(body.id);