forked from admin/runners-calendar
feat: add race cover image extraction
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user