diff --git a/apps/ios/VMC/Data/ApiClient.swift b/apps/ios/VMC/Data/ApiClient.swift index db6051f..ec00d06 100644 --- a/apps/ios/VMC/Data/ApiClient.swift +++ b/apps/ios/VMC/Data/ApiClient.swift @@ -358,6 +358,11 @@ actor ApiClient { try await get("/api/v1/client/f1/live") } + func f1NewsArticle(id: String) async throws -> F1NewsArticle { + let enc = Self.encodePathSegment(id) + return try await get("/api/v1/client/f1/news/\(enc)") + } + func f1Meeting(meetingKey: String) async throws -> F1MeetingDetail { let enc = Self.encodePathSegment(meetingKey) return try await get("/api/v1/client/f1/meetings/\(enc)") diff --git a/apps/ios/VMC/Data/Models.swift b/apps/ios/VMC/Data/Models.swift index 67ddf18..76c13b7 100644 --- a/apps/ios/VMC/Data/Models.swift +++ b/apps/ios/VMC/Data/Models.swift @@ -772,15 +772,45 @@ struct ViaplayHubPayload: Decodable { } } -// MARK: - F1 Live (OpenF1) +// MARK: - F1 Live struct F1NewsItem: Decodable, Identifiable, Hashable { let id: String let title: String - let url: String let imageUrl: String? let publishedAt: String? - let source: String? +} + +struct F1NewsBlock: Decodable, Identifiable, Hashable { + var id: String { "\(type)-\(text ?? "")-\(imageUrl ?? "")-\(attribution ?? "")" } + let type: String + let text: String? + let attribution: String? + let imageUrl: String? + let alt: String? + + enum CodingKeys: String, CodingKey { + case type, text, attribution, url, alt, imageUrl + } + + init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + type = (try? c.decode(String.self, forKey: .type)) ?? "p" + text = try c.decodeIfPresent(String.self, forKey: .text) + attribution = try c.decodeIfPresent(String.self, forKey: .attribution) + imageUrl = (try? c.decodeIfPresent(String.self, forKey: .url)) + ?? (try? c.decodeIfPresent(String.self, forKey: .imageUrl)) + alt = try c.decodeIfPresent(String.self, forKey: .alt) + } +} + +struct F1NewsArticle: Decodable, Hashable { + let id: String + let title: String + let dek: String? + let publishedAt: String? + let imageUrl: String? + let blocks: [F1NewsBlock] } struct F1LiveDriver: Decodable, Identifiable, Hashable { diff --git a/apps/ios/VMC/UI/F1LiveScreen.swift b/apps/ios/VMC/UI/F1LiveScreen.swift index 6dc76dc..2d4550f 100644 --- a/apps/ios/VMC/UI/F1LiveScreen.swift +++ b/apps/ios/VMC/UI/F1LiveScreen.swift @@ -25,7 +25,6 @@ struct F1LiveScreen: View { @EnvironmentObject private var app: AppModel @Environment(\.dismiss) private var dismiss - @Environment(\.openURL) private var openURL @State private var tab: Tab = .overview @State private var payload: F1LiveDashboard? @@ -127,17 +126,11 @@ struct F1LiveScreen: View { .background(F1LiveLook.live, in: Capsule()) } } - HStack { - Text("Aangedreven door OpenF1") + if let subtitle, !subtitle.isEmpty { + Text(subtitle) .font(.system(size: 11, weight: .medium)) - .foregroundStyle(.white.opacity(0.4)) - Spacer() - if let subtitle, !subtitle.isEmpty { - Text(subtitle) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(.white.opacity(0.45)) - .lineLimit(1) - } + .foregroundStyle(.white.opacity(0.45)) + .lineLimit(1) } } .padding(.horizontal, 16) @@ -331,11 +324,14 @@ struct F1LiveScreen: View { private func newsList(_ items: [F1NewsItem]) -> some View { VStack(alignment: .leading, spacing: 12) { - Text("RacingNews365") - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.white.opacity(0.45)) - ForEach(items) { item in - newsCard(item) + if items.isEmpty { + Text("Nog geen nieuws.") + .font(.system(size: 14)) + .foregroundStyle(.white.opacity(0.5)) + } else { + ForEach(items) { item in + newsCard(item) + } } } } @@ -469,22 +465,20 @@ struct F1LiveScreen: View { } private func newsCard(_ item: F1NewsItem) -> some View { - Button { - if let url = URL(string: item.url) { openURL(url) } + NavigationLink { + F1NewsArticleScreen(preview: item) } label: { HStack(alignment: .top, spacing: 12) { RemoteImage(url: item.imageUrl, contentMode: .fill) - .frame(width: 92, height: 64) + .frame(width: 104, height: 74) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 6) { Text(item.title) - .font(.system(size: 14, weight: .semibold)) + .font(.system(size: 15, weight: .semibold)) .foregroundStyle(.white) .multilineTextAlignment(.leading) .lineLimit(3) - Text(F1LiveFormat.newsDate(item.publishedAt) + " ยท RacingNews365") - .font(.system(size: 11)) - .foregroundStyle(.white.opacity(0.4)) + newsDateLabel(item.publishedAt) } Spacer(minLength: 0) } @@ -498,6 +492,16 @@ struct F1LiveScreen: View { .buttonStyle(.plain) } + @ViewBuilder + private func newsDateLabel(_ iso: String?) -> some View { + let date = F1LiveFormat.newsDate(iso) + if !date.isEmpty { + Text(date) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.white.opacity(0.4)) + } + } + private func shortcut(_ title: String, _ icon: String, action: @escaping () -> Void) -> some View { Button(action: action) { VStack(spacing: 8) { @@ -603,7 +607,7 @@ private struct F1TrackMapCanvas: View { p.addLine(to: point(pt, w: w, h: h, inset: inset)) } } - .stroke(Color.white.opacity(0.82), style: StrokeStyle(lineWidth: 5, lineJoin: .round, lineCap: .round)) + .stroke(Color.white.opacity(0.82), style: StrokeStyle(lineWidth: 5, lineCap: .round, lineJoin: .round)) } else { Text("Geen circuitkaart") .font(.system(size: 13)) @@ -695,3 +699,151 @@ private extension Comparable { min(max(self, range.lowerBound), range.upperBound) } } + +private struct F1NewsArticleScreen: View { + let preview: F1NewsItem + + @EnvironmentObject private var app: AppModel + @Environment(\.dismiss) private var dismiss + + @State private var article: F1NewsArticle? + @State private var error: String? + + var body: some View { + ZStack(alignment: .topLeading) { + F1LiveLook.bg.ignoresSafeArea() + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + hero + VStack(alignment: .leading, spacing: 18) { + Text(article?.title ?? preview.title) + .font(.system(size: 28, weight: .bold)) + .foregroundStyle(.white) + .fixedSize(horizontal: false, vertical: true) + metaRow + if let dek = article?.dek, !dek.isEmpty { + Text(dek) + .font(.system(size: 17, weight: .regular)) + .foregroundStyle(.white.opacity(0.72)) + .lineSpacing(5) + } + if let error { + Text(error) + .font(.system(size: 14)) + .foregroundStyle(.white.opacity(0.55)) + } else if article == nil { + ProgressView() + .tint(.white) + .frame(maxWidth: .infinity) + .padding(.vertical, 28) + } else { + articleBody + } + } + .padding(.horizontal, 20) + .padding(.top, 22) + .padding(.bottom, 56) + } + } + backButton + } + .environment(\.colorScheme, .dark) + .navigationBarBackButtonHidden(true) + .toolbar(.hidden, for: .navigationBar) + .task { await load() } + } + + private var hero: some View { + ZStack(alignment: .bottom) { + RemoteImage(url: article?.imageUrl ?? preview.imageUrl, contentMode: .fill) + .frame(height: 280) + .frame(maxWidth: .infinity) + .clipped() + LinearGradient( + colors: [.clear, F1LiveLook.bg.opacity(0.55), F1LiveLook.bg], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 140) + } + .frame(height: 280) + .clipped() + } + + private var metaRow: some View { + let date = F1LiveFormat.newsDate(article?.publishedAt ?? preview.publishedAt) + return Group { + if !date.isEmpty { + Text(date.uppercased()) + .font(.system(size: 11, weight: .semibold)) + .tracking(0.8) + .foregroundStyle(.white.opacity(0.42)) + } + } + } + + @ViewBuilder + private var articleBody: some View { + let blocks = article?.blocks ?? [] + ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in + switch block.type { + case "h": + Text(block.text ?? "") + .font(.system(size: 20, weight: .bold)) + .foregroundStyle(.white) + .padding(.top, 8) + case "quote": + VStack(alignment: .leading, spacing: 8) { + Text(block.text ?? "") + .font(.system(size: 18, weight: .medium).italic()) + .foregroundStyle(.white.opacity(0.92)) + .lineSpacing(4) + if let who = block.attribution, !who.isEmpty { + Text(who) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.white.opacity(0.45)) + } + } + .padding(.leading, 14) + .overlay(alignment: .leading) { + Capsule() + .fill(F1LiveLook.live) + .frame(width: 3) + } + .padding(.vertical, 6) + case "image": + RemoteImage(url: block.imageUrl, contentMode: .fill) + .frame(maxWidth: .infinity) + .frame(height: 200) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + default: + Text(block.text ?? "") + .font(.system(size: 17)) + .foregroundStyle(.white.opacity(0.88)) + .lineSpacing(6) + } + } + } + + private var backButton: some View { + Button { dismiss() } label: { + Image(systemName: "chevron.left") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 36, height: 36) + .background(.black.opacity(0.45), in: Circle()) + .overlay(Circle().strokeBorder(Color.white.opacity(0.12), lineWidth: 1)) + } + .padding(.leading, 16) + .padding(.top, 6) + } + + private func load() async { + do { + article = try await app.api.f1NewsArticle(id: preview.id) + error = nil + } catch { + self.error = "Artikel kon niet worden geladen." + } + } +} diff --git a/apps/master-api/src/openf1/live.ts b/apps/master-api/src/openf1/live.ts index 3bc4f79..f286d79 100644 --- a/apps/master-api/src/openf1/live.ts +++ b/apps/master-api/src/openf1/live.ts @@ -345,7 +345,7 @@ export async function getF1LiveDashboard(): Promise { const data: F1LiveDashboard = { live, - source: "OpenF1", + source: "live", session: { sessionKey, meetingKey, diff --git a/apps/master-api/src/openf1/news.test.ts b/apps/master-api/src/openf1/news.test.ts index 1f85f3b..2ab9786 100644 --- a/apps/master-api/src/openf1/news.test.ts +++ b/apps/master-api/src/openf1/news.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { parseRacingNews365 } from "./news"; +import { parseNewsArticle, parseRacingNews365 } from "./news"; const html = ` skip @@ -20,5 +20,44 @@ const items = parseRacingNews365(html); assert.equal(items.length, 1); assert.equal(items[0].title, 'Hadjar doet boekje open over polsbreuk: "Heel stom"'); assert.equal(items[0].id, "14443134"); +assert.equal((items[0] as { url?: string }).url, undefined); assert.ok(items[0].imageUrl?.includes("cdn.racingnews365.com")); + +const articleHtml = ` +

Hadjar doet boekje open over polsbreuk: "Heel stom"

+

Isack Hadjar was zes weken uit de running.

+ + +
+
+

Eerste alinea over de blessure.

+

Het laatste Formule 1 en Max Verstappen nieuws vind je op RacingNews365.nl

+
+
+

"Het was eigenlijk heel stom."

+

- Isack Hadjar

+
+
+

Slecht nieuws brengen

+

Tweede alinea over het team.

+
+
+
+`; + +const article = parseNewsArticle(articleHtml, { + id: "14443134", + slug: "hadjar-doet-boekje-open-over-polsbreuk-heel-stom", + title: "fallback", + url: "https://example.invalid/x", + imageUrl: null, + publishedAt: null, +}); +assert.equal(article.title.includes("Hadjar"), true); +assert.equal(article.dek, "Isack Hadjar was zes weken uit de running."); +assert.ok(article.blocks.some((b) => b.type === "p" && "text" in b && b.text.includes("Eerste alinea"))); +assert.ok(!article.blocks.some((b) => "text" in b && /racingnews/i.test(b.text))); +assert.ok(article.blocks.some((b) => b.type === "quote")); +assert.ok(article.blocks.some((b) => b.type === "h" && "text" in b && b.text === "Slecht nieuws brengen")); +assert.ok(!`${article.title}\n${article.dek}\n${article.blocks.map((b) => ("text" in b ? b.text : "")).join("\n")}`.toLowerCase().includes("racingnews")); console.log("openf1/news.test.ts ok"); diff --git a/apps/master-api/src/openf1/news.ts b/apps/master-api/src/openf1/news.ts index af9a809..1d5e900 100644 --- a/apps/master-api/src/openf1/news.ts +++ b/apps/master-api/src/openf1/news.ts @@ -1,4 +1,7 @@ +import { AppError } from "../security/errors"; + const LIST_URL = "https://racingnews365.nl/f1-nieuws"; +const ORIGIN = "https://racingnews365.nl"; const UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36"; @@ -21,14 +24,30 @@ const SKIP = new Set([ export type F1NewsItem = { id: string; title: string; - url: string; imageUrl: string | null; publishedAt: string | null; - source: string; }; +export type F1NewsBlock = + | { type: "p"; text: string } + | { type: "h"; text: string } + | { type: "quote"; text: string; attribution?: string } + | { type: "image"; url: string; alt?: string }; + +export type F1NewsArticle = { + id: string; + title: string; + dek: string | null; + publishedAt: string | null; + imageUrl: string | null; + blocks: F1NewsBlock[]; +}; + +type InternalNews = F1NewsItem & { slug: string; url: string }; + function decodeHtml(s: string): string { return s + .replace(/ /g, " ") .replace(/"/g, '"') .replace(/'/g, "'") .replace(/'/g, "'") @@ -46,8 +65,41 @@ function slugOf(url: string): string { } } -export function parseRacingNews365(html: string, limit = 20): F1NewsItem[] { - const items: F1NewsItem[] = []; +function stripTags(html: string): string { + return decodeHtml( + html + .replace(//gi, "\n") + .replace(/<\/(p|h\d|div|li)>/gi, "\n") + .replace(/<[^>]+>/g, "") + ) + .replace(/\s+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .replace(/[ \t]{2,}/g, " ") + .trim(); +} + +function isBrandLeak(text: string): boolean { + return /racingnews365|nieuwsoverzicht|voorkeursbron|het laatste formule 1/i.test(text); +} + +function biggerImage(url: string): string { + return url + .replace(/&/g, "&") + .replace(/width=\d+/i, "width=1200") + .replace(/height=\d+/i, "height=675"); +} + +export function toPublicNews(item: InternalNews): F1NewsItem { + return { + id: item.id, + title: item.title, + imageUrl: item.imageUrl, + publishedAt: item.publishedAt, + }; +} + +export function parseNewsList(html: string, limit = 20): InternalNews[] { + const items: InternalNews[] = []; const seen = new Set(); const openRe = /]+)>/gi; let m: RegExpExecArray | null; @@ -64,34 +116,102 @@ export function parseRacingNews365(html: string, limit = 20): F1NewsItem[] { chunk.match(/class="card__title"[^>]*>\s*]*>([^<]+)/i); if (!titleMatch) continue; const title = decodeHtml(titleMatch[1].trim()); - if (!title) continue; + if (!title || isBrandLeak(title)) continue; const timeMatch = chunk.match(/datetime="([^"]+)"/i); const imgMatch = chunk.match(/https:\/\/cdn\.racingnews365\.com\/[^"'>\s]+/); - let imageUrl = imgMatch ? imgMatch[0].replace(/&/g, "&") : null; - if (imageUrl) { - imageUrl = imageUrl.replace(/width=\d+/i, "width=720").replace(/height=\d+/i, "height=405"); - } + let imageUrl = imgMatch ? biggerImage(imgMatch[0]) : null; const idMatch = attrs.match(/data-id="(\d+)"/) || chunk.match(/data-id="(\d+)"/); seen.add(href); items.push({ id: idMatch?.[1] || slug, + slug, title, url: href, imageUrl, publishedAt: timeMatch?.[1] || null, - source: "RacingNews365", }); } return items; } -let newsCache: { at: number; items: F1NewsItem[] } | null = null; +export function parseNewsArticle(html: string, fallback: InternalNews): F1NewsArticle { + const title = + stripTags(html.match(/]*class="content__heading__h1[^"]*"[^>]*>([\s\S]*?)<\/h1>/i)?.[1] || "") || + fallback.title; + const dekRaw = stripTags(html.match(/

]*>([\s\S]*?)<\/p>/i)?.[1] || ""); + const dek = dekRaw && !isBrandLeak(dekRaw) ? dekRaw : null; + const publishedAt = + html.match(/]*datetime="([^"]+)"/i)?.[1] || fallback.publishedAt; + const ogImage = html.match(/property="og:image"\s+content="([^"]+)"/i)?.[1]; + const hero = html.match(/class="head-picture__image"[^>]*src="([^"]+)"/i)?.[1]; + const imageUrl = ogImage ? biggerImage(ogImage) : hero ? biggerImage(hero) : fallback.imageUrl; -export async function getF1News(limit = 16): Promise { - if (newsCache && Date.now() - newsCache.at < 8 * 60_000) { - return newsCache.items.slice(0, limit); + const start = html.search(/class="content-fields"/i); + const end = html.search(/class="category-wrapper"/i); + const bodyHtml = start >= 0 ? html.slice(start, end > start ? end : undefined) : ""; + const parts = bodyHtml.split(/(?=

(); + + const pushText = (type: "p" | "h", text: string) => { + if (!text || isBrandLeak(text) || seenText.has(text)) return; + if (dek && text === dek) return; + seenText.add(text); + blocks.push({ type, text }); + }; + + for (const part of parts) { + if (/class="content-field__quote"/i.test(part)) { + const text = stripTags(part.match(/class="content-field__quote__text"[^>]*>([\s\S]*?)<\/p>/i)?.[1] || ""); + const attribution = stripTags( + part.match(/class="content-field__quote__author"[^>]*>([\s\S]*?)<\/p>/i)?.[1] || "" + ); + if (text && !isBrandLeak(text)) { + blocks.push({ + type: "quote", + text, + ...(attribution && !isBrandLeak(attribution) ? { attribution } : {}), + }); + } + continue; + } + if (/class="content-field__images"/i.test(part)) { + const src = part.match(/]+src="([^"]+)"/i)?.[1]; + const alt = part.match(/]+alt="([^"]*)"/i)?.[1]; + if (src) { + blocks.push({ + type: "image", + url: biggerImage(src), + ...(alt ? { alt: decodeHtml(alt) } : {}), + }); + } + continue; + } + if (/class="content-field__redactor"/i.test(part)) { + const tagRe = /<(h2|p)\b[^>]*>([\s\S]*?)<\/\1>/gi; + let tm: RegExpExecArray | null; + while ((tm = tagRe.exec(part))) { + const text = stripTags(tm[2]); + pushText(tm[1].toLowerCase() === "h2" ? "h" : "p", text); + } + } } - const res = await fetch(LIST_URL, { + + return { + id: fallback.id, + title, + dek, + publishedAt, + imageUrl, + blocks, + }; +} + +let newsCache: { at: number; items: InternalNews[] } | null = null; +const articleCache = new Map(); + +async function fetchHtml(url: string): Promise { + const res = await fetch(url, { headers: { Accept: "text/html,application/xhtml+xml", "User-Agent": UA, @@ -99,9 +219,42 @@ export async function getF1News(limit = 16): Promise { }, signal: AbortSignal.timeout(18_000), }); - if (!res.ok) throw new Error(`RacingNews365 HTTP ${res.status}`); - const html = await res.text(); - const items = parseRacingNews365(html, 24); + if (!res.ok) throw new Error(`Nieuws HTTP ${res.status}`); + return res.text(); +} + +async function loadList(limit = 24): Promise { + if (newsCache && Date.now() - newsCache.at < 8 * 60_000) { + return newsCache.items.slice(0, limit); + } + const html = await fetchHtml(LIST_URL); + const items = parseNewsList(html, 24); newsCache = { at: Date.now(), items }; return items.slice(0, limit); } + +export async function getF1News(limit = 16): Promise { + const items = await loadList(limit); + return items.map(toPublicNews); +} + +export async function getF1NewsArticle(id: string): Promise { + const key = decodeURIComponent(id).trim(); + if (!key) throw new AppError("NOT_FOUND", "Artikel niet gevonden", 404); + const hit = articleCache.get(key); + if (hit && Date.now() - hit.at < 30 * 60_000) return hit.article; + + const items = await loadList(24); + const found = items.find((i) => i.id === key || i.slug === key); + if (!found) throw new AppError("NOT_FOUND", "Artikel niet gevonden", 404); + + const html = await fetchHtml(found.url.startsWith("http") ? found.url : `${ORIGIN}/${found.slug}`); + const article = parseNewsArticle(html, found); + articleCache.set(found.id, { at: Date.now(), article }); + articleCache.set(found.slug, { at: Date.now(), article }); + return article; +} + +/** @deprecated test alias */ +export const parseRacingNews365 = (html: string, limit = 20): F1NewsItem[] => + parseNewsList(html, limit).map(toPublicNews); diff --git a/apps/master-api/src/viewer/routes.ts b/apps/master-api/src/viewer/routes.ts index 7cd042a..de67fad 100644 --- a/apps/master-api/src/viewer/routes.ts +++ b/apps/master-api/src/viewer/routes.ts @@ -927,6 +927,20 @@ export function registerViewerRoutes( return { items: await getF1News(20) }; }); + app.get("/api/v1/client/f1/news/:id", async (request) => { + await viewers.authFromBearer(request.headers.authorization); + const { id } = request.params as { id: string }; + const { getF1NewsArticle } = await import("../openf1/news"); + return getF1NewsArticle(id); + }); + + app.get("/api/v1/client/f1/news/:id", async (request) => { + await viewers.authFromBearer(request.headers.authorization); + const { id } = request.params as { id: string }; + const { getF1NewsArticle } = await import("../openf1/news"); + return getF1NewsArticle(id); + }); + app.get("/api/v1/client/viaplay/home", async (request) => { const auth = await viewers.authFromBearer(request.headers.authorization); const { resolveViewerLiveEventsAccess } = await import("./schedule-events");