From 0153f223f2a07caa1a3382f334b492aaa2aae17e Mon Sep 17 00:00:00 2001 From: "Vaka.pro" Date: Mon, 27 Apr 2026 22:56:41 +0300 Subject: [PATCH] feat: add race cover image extraction --- backend/migrations/003_cover_image_url.sql | 2 + backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/db.ts | 14 +++ backend/src/mappers/race.ts | 4 + backend/src/raceCoverImage.ts | 103 +++++++++++++++++ backend/src/routes/races.ts | 11 +- backend/test/app.test.ts | 122 +++++++++++++++++++++ docs/backend-api-for-frontend.md | 5 +- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/api/races.ts | 2 + frontend/src/api/types.ts | 2 + frontend/src/lib/raceVisuals.ts | 9 ++ frontend/src/pages/RaceFormPage.tsx | 17 +++ 15 files changed, 295 insertions(+), 8 deletions(-) create mode 100644 backend/migrations/003_cover_image_url.sql create mode 100644 backend/src/raceCoverImage.ts diff --git a/backend/migrations/003_cover_image_url.sql b/backend/migrations/003_cover_image_url.sql new file mode 100644 index 0000000..3179eef --- /dev/null +++ b/backend/migrations/003_cover_image_url.sql @@ -0,0 +1,2 @@ +ALTER TABLE races + ADD COLUMN IF NOT EXISTS cover_image_url TEXT; diff --git a/backend/package-lock.json b/backend/package-lock.json index dcf2665..d90c03b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "calendar-run-backend", - "version": "1.2.2", + "version": "1.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "calendar-run-backend", - "version": "1.2.2", + "version": "1.3.0", "dependencies": { "cors": "^2.8.5", "csv-parse": "^5.6.0", diff --git a/backend/package.json b/backend/package.json index 09d4959..3859a4a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "calendar-run-backend", - "version": "1.2.2", + "version": "1.3.0", "private": true, "scripts": { "build": "tsc", diff --git a/backend/src/db.ts b/backend/src/db.ts index bfbfe69..53ba136 100644 --- a/backend/src/db.ts +++ b/backend/src/db.ts @@ -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)[col] = p[index] ?? null; + }); store.set(id, updated); return { rows: [updated as unknown as T], diff --git a/backend/src/mappers/race.ts b/backend/src/mappers/race.ts index 382de88..a8e47f2 100644 --- a/backend/src/mappers/race.ts +++ b/backend/src/mappers/race.ts @@ -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 = { distanceKm: "distance_km", status: "status", officialUrl: "official_url", + coverImageUrl: "cover_image_url", startTime: "start_time", clusterSchedule: "cluster_schedule", bibPickup: "bib_pickup", diff --git a/backend/src/raceCoverImage.ts b/backend/src/raceCoverImage.ts new file mode 100644 index 0000000..43019ee --- /dev/null +++ b/backend/src/raceCoverImage.ts @@ -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(/]*class=["'][^"']*\brun-intro__image\b[^"']*["'][^>]*>[\s\S]*?]*>/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(/]*>/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 { + 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); + } +} diff --git a/backend/src/routes/races.ts b/backend/src/routes/races.ts index f208537..cf305a4 100644 --- a/backend/src/routes/races.ts +++ b/backend/src/routes/races.ts @@ -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); diff --git a/backend/test/app.test.ts b/backend/test/app.test.ts index 6878936..e99ea01 100644 --- a/backend/test/app.test.ts +++ b/backend/test/app.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import request from "supertest"; import { createApp } from "../src/app"; +import { extractRaceCoverImageFromHtml } from "../src/raceCoverImage"; const app = createApp(); @@ -45,3 +46,124 @@ test("GET /api/races/:id returns not_found", async () => { assert.equal(res.body.error, "not_found"); assert.ok(Array.isArray(res.body.details)); }); + +test("extractRaceCoverImageFromHtml prefers runc.run intro image", () => { + const html = ` + +
+
+ +
+
+ `; + + assert.equal( + extractRaceCoverImageFromHtml(html, "https://aprilrun5km.runc.run/"), + "https://aprilrun5km.runc.run/uploads/race_landing_header_backgrounds/header.jpg", + ); +}); + +test("extractRaceCoverImageFromHtml reads Open Graph and Twitter images", () => { + assert.equal( + extractRaceCoverImageFromHtml( + '', + "https://example.com/race", + ), + "https://example.com/cover.png", + ); + assert.equal( + extractRaceCoverImageFromHtml( + '', + "https://example.com/race", + ), + "https://cdn.example.com/twitter.jpg", + ); +}); + +test("POST /api/races stores manual coverImageUrl", async () => { + const coverImageUrl = "https://example.com/manual.jpg"; + const res = await request(app) + .post("/api/races") + .send({ + id: "2026-06-01-manual-cover", + date: "2026-06-01", + title: "Manual Cover", + distanceKm: 10, + officialUrl: "https://example.com/race", + coverImageUrl, + }) + .expect(201); + + assert.equal(res.body.coverImageUrl, coverImageUrl); +}); + +test("POST /api/races auto extracts coverImageUrl from officialUrl", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => + new Response('', { + status: 200, + headers: { "content-type": "text/html" }, + }); + + try { + const res = await request(app) + .post("/api/races") + .send({ + id: "2026-06-02-auto-cover", + date: "2026-06-02", + title: "Auto Cover", + distanceKm: 21.1, + officialUrl: "https://example.com/race", + }) + .expect(201); + + assert.equal(res.body.coverImageUrl, "https://example.com/auto.jpg"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("POST /api/races succeeds when cover extraction fails", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => { + throw new Error("network down"); + }; + + try { + const res = await request(app) + .post("/api/races") + .send({ + id: "2026-06-03-cover-fail", + date: "2026-06-03", + title: "Cover Fail", + distanceKm: 5, + officialUrl: "https://example.com/race", + }) + .expect(201); + + assert.equal(res.body.coverImageUrl, null); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("PATCH /api/races/:id updates coverImageUrl explicitly", async () => { + const id = "2026-06-04-patch-cover"; + await request(app) + .post("/api/races") + .send({ + id, + date: "2026-06-04", + title: "Patch Cover", + distanceKm: 10, + }) + .expect(201); + + const coverImageUrl = "https://example.com/patched.jpg"; + const res = await request(app) + .patch(`/api/races/${id}`) + .send({ coverImageUrl }) + .expect(200); + + assert.equal(res.body.coverImageUrl, coverImageUrl); +}); diff --git a/docs/backend-api-for-frontend.md b/docs/backend-api-for-frontend.md index 7b1041b..7e177e2 100644 --- a/docs/backend-api-for-frontend.md +++ b/docs/backend-api-for-frontend.md @@ -81,6 +81,7 @@ GET /api/races?year=2026&month=5 "distanceKm": 42.195, "status": "planned", "officialUrl": null, + "coverImageUrl": null, "startTime": null, "clusterSchedule": null, "bibPickup": null, @@ -124,6 +125,7 @@ GET /api/races?year=2026&month=5 "distanceKm": 10, "status": "planned", "officialUrl": "https://example.com", + "coverImageUrl": "https://example.com/cover.jpg", "startTime": "09:30", "clusterSchedule": null, "bibPickup": null, @@ -167,7 +169,7 @@ GET /api/races?year=2026&month=5 } ``` -**Допустимые поля:** `date`, `title`, `distanceKm`, `status`, `officialUrl`, `startTime`, `clusterSchedule`, `bibPickup`, `bibNumber`, `finishTime`, `finishPlace`, `notes`. +**Допустимые поля:** `date`, `title`, `distanceKm`, `status`, `officialUrl`, `coverImageUrl`, `startTime`, `clusterSchedule`, `bibPickup`, `bibNumber`, `finishTime`, `finishPlace`, `notes`. **Ответ 200:** обновлённый объект `Race`. @@ -207,6 +209,7 @@ GET /api/races?year=2026&month=5 | `distanceKm` | number | да | да | Дистанция в км | | `status` | string \| null | нет | да | `"planned"` / `"registered"` / `"completed"` | | `officialUrl` | string \| null | нет | да | URL организатора | +| `coverImageUrl` | string \| null | нет | да | URL обложки забега. При `POST` может быть найден автоматически по `officialUrl`, если не передан вручную | | `startTime` | string \| null | нет | да | Время старта, напр. `"09:30"` или `"09:30:00"` (часы:минуты:секунды) | | `clusterSchedule` | string \| null | нет | да | Расписание кластеров | | `bibPickup` | string \| null | нет | да | Выдача номеров | diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 90781b2..27b671c 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "calendar-run-frontend", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "calendar-run-frontend", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/frontend/package.json b/frontend/package.json index 2217a16..57ad464 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "calendar-run-frontend", "private": true, - "version": "0.5.0", + "version": "0.6.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/api/races.ts b/frontend/src/api/races.ts index e2e6bcd..97ee124 100644 --- a/frontend/src/api/races.ts +++ b/frontend/src/api/races.ts @@ -23,6 +23,7 @@ function normalizeRace(input: unknown): Race { race?.status === "registered" || race?.status === "completed") && isNullableString(race?.officialUrl) && + isNullableString(race?.coverImageUrl) && isNullableString(race?.startTime) && isNullableString(race?.clusterSchedule) && isNullableString(race?.bibPickup) && @@ -48,6 +49,7 @@ function normalizeRace(input: unknown): Race { distanceKm: race.distanceKm, status: race.status, officialUrl: race.officialUrl, + coverImageUrl: race.coverImageUrl, startTime: race.startTime, clusterSchedule: race.clusterSchedule, bibPickup: race.bibPickup, diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 527905b..571bcb5 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -7,6 +7,7 @@ export interface Race { distanceKm: number; status: RaceStatus | null; officialUrl: string | null; + coverImageUrl: string | null; startTime: string | null; clusterSchedule: string | null; bibPickup: string | null; @@ -30,6 +31,7 @@ export interface CreateRacePayload { distanceKm: number; status?: RaceStatus | null; officialUrl?: string | null; + coverImageUrl?: string | null; startTime?: string | null; clusterSchedule?: string | null; bibPickup?: string | null; diff --git a/frontend/src/lib/raceVisuals.ts b/frontend/src/lib/raceVisuals.ts index 5b37475..032e14f 100644 --- a/frontend/src/lib/raceVisuals.ts +++ b/frontend/src/lib/raceVisuals.ts @@ -187,6 +187,15 @@ function getFallbackRaceVisual(race: Race): RaceVisual { export function getRaceVisual(race: Race): RaceVisual { const fallback = getFallbackRaceVisual(race); + + if (race.coverImageUrl) { + return { + ...fallback, + imageSrc: race.coverImageUrl, + fallbackSrc: fallback.imageSrc, + }; + } + const title = normalizeTitle(race.title); const official = OFFICIAL_VISUALS.find((visual) => visual.keywords.some((keyword) => title.includes(normalizeTitle(keyword))), diff --git a/frontend/src/pages/RaceFormPage.tsx b/frontend/src/pages/RaceFormPage.tsx index 8d4a91a..d994632 100644 --- a/frontend/src/pages/RaceFormPage.tsx +++ b/frontend/src/pages/RaceFormPage.tsx @@ -31,6 +31,7 @@ interface FormData { distanceKm: string; status: string; officialUrl: string; + coverImageUrl: string; startTime: string; clusterSchedule: string; bibPickup: string; @@ -46,6 +47,7 @@ const EMPTY_FORM: FormData = { distanceKm: "", status: "planned", officialUrl: "", + coverImageUrl: "", startTime: "", clusterSchedule: "", bibPickup: "", @@ -63,6 +65,7 @@ function raceToFormData(race: Race): FormData { distanceKm: String(race.distanceKm), status: race.status ?? "", officialUrl: race.officialUrl ?? "", + coverImageUrl: race.coverImageUrl ?? "", startTime: race.startTime ?? "", clusterSchedule: race.clusterSchedule ?? "", bibPickup: race.bibPickup ?? "", @@ -197,6 +200,7 @@ export function RaceFormPage(): JSX.Element { distanceKm: parseFloat(form.distanceKm), status: statusValue, officialUrl: emptyToNull(form.officialUrl), + coverImageUrl: emptyToNull(form.coverImageUrl), startTime: emptyToNull(form.startTime), clusterSchedule: emptyToNull(form.clusterSchedule), bibPickup: emptyToNull(form.bibPickup), @@ -217,6 +221,7 @@ export function RaceFormPage(): JSX.Element { distanceKm: parseFloat(form.distanceKm), status: statusValue, officialUrl: emptyToNull(form.officialUrl), + coverImageUrl: emptyToNull(form.coverImageUrl), startTime: emptyToNull(form.startTime), clusterSchedule: emptyToNull(form.clusterSchedule), bibPickup: emptyToNull(form.bibPickup), @@ -346,6 +351,18 @@ export function RaceFormPage(): JSX.Element { )} + + {hideOrgScheduleFields ? null : (
Время старта