Show F1 news in-app without source branding, including a full article reader.

This commit is contained in:
Jos Vooges | STH 2026-10-01 01:42:58 +02:00
parent db79d4bdcb
commit 91e6259f3f
7 changed files with 441 additions and 48 deletions

View file

@ -358,6 +358,11 @@ actor ApiClient {
try await get("/api/v1/client/f1/live") 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 { func f1Meeting(meetingKey: String) async throws -> F1MeetingDetail {
let enc = Self.encodePathSegment(meetingKey) let enc = Self.encodePathSegment(meetingKey)
return try await get("/api/v1/client/f1/meetings/\(enc)") return try await get("/api/v1/client/f1/meetings/\(enc)")

View file

@ -772,15 +772,45 @@ struct ViaplayHubPayload: Decodable {
} }
} }
// MARK: - F1 Live (OpenF1) // MARK: - F1 Live
struct F1NewsItem: Decodable, Identifiable, Hashable { struct F1NewsItem: Decodable, Identifiable, Hashable {
let id: String let id: String
let title: String let title: String
let url: String
let imageUrl: String? let imageUrl: String?
let publishedAt: 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 { struct F1LiveDriver: Decodable, Identifiable, Hashable {

View file

@ -25,7 +25,6 @@ struct F1LiveScreen: View {
@EnvironmentObject private var app: AppModel @EnvironmentObject private var app: AppModel
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@Environment(\.openURL) private var openURL
@State private var tab: Tab = .overview @State private var tab: Tab = .overview
@State private var payload: F1LiveDashboard? @State private var payload: F1LiveDashboard?
@ -127,11 +126,6 @@ struct F1LiveScreen: View {
.background(F1LiveLook.live, in: Capsule()) .background(F1LiveLook.live, in: Capsule())
} }
} }
HStack {
Text("Aangedreven door OpenF1")
.font(.system(size: 11, weight: .medium))
.foregroundStyle(.white.opacity(0.4))
Spacer()
if let subtitle, !subtitle.isEmpty { if let subtitle, !subtitle.isEmpty {
Text(subtitle) Text(subtitle)
.font(.system(size: 11, weight: .medium)) .font(.system(size: 11, weight: .medium))
@ -139,7 +133,6 @@ struct F1LiveScreen: View {
.lineLimit(1) .lineLimit(1)
} }
} }
}
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.top, 6) .padding(.top, 6)
.padding(.bottom, 10) .padding(.bottom, 10)
@ -331,14 +324,17 @@ struct F1LiveScreen: View {
private func newsList(_ items: [F1NewsItem]) -> some View { private func newsList(_ items: [F1NewsItem]) -> some View {
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("RacingNews365") if items.isEmpty {
.font(.system(size: 13, weight: .semibold)) Text("Nog geen nieuws.")
.foregroundStyle(.white.opacity(0.45)) .font(.system(size: 14))
.foregroundStyle(.white.opacity(0.5))
} else {
ForEach(items) { item in ForEach(items) { item in
newsCard(item) newsCard(item)
} }
} }
} }
}
private func weatherCard(_ w: F1LiveWeather, circuit: String?) -> some View { private func weatherCard(_ w: F1LiveWeather, circuit: String?) -> some View {
card { card {
@ -469,22 +465,20 @@ struct F1LiveScreen: View {
} }
private func newsCard(_ item: F1NewsItem) -> some View { private func newsCard(_ item: F1NewsItem) -> some View {
Button { NavigationLink {
if let url = URL(string: item.url) { openURL(url) } F1NewsArticleScreen(preview: item)
} label: { } label: {
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
RemoteImage(url: item.imageUrl, contentMode: .fill) RemoteImage(url: item.imageUrl, contentMode: .fill)
.frame(width: 92, height: 64) .frame(width: 104, height: 74)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 6) {
Text(item.title) Text(item.title)
.font(.system(size: 14, weight: .semibold)) .font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white) .foregroundStyle(.white)
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
.lineLimit(3) .lineLimit(3)
Text(F1LiveFormat.newsDate(item.publishedAt) + " · RacingNews365") newsDateLabel(item.publishedAt)
.font(.system(size: 11))
.foregroundStyle(.white.opacity(0.4))
} }
Spacer(minLength: 0) Spacer(minLength: 0)
} }
@ -498,6 +492,16 @@ struct F1LiveScreen: View {
.buttonStyle(.plain) .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 { private func shortcut(_ title: String, _ icon: String, action: @escaping () -> Void) -> some View {
Button(action: action) { Button(action: action) {
VStack(spacing: 8) { VStack(spacing: 8) {
@ -603,7 +607,7 @@ private struct F1TrackMapCanvas: View {
p.addLine(to: point(pt, w: w, h: h, inset: inset)) 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 { } else {
Text("Geen circuitkaart") Text("Geen circuitkaart")
.font(.system(size: 13)) .font(.system(size: 13))
@ -695,3 +699,151 @@ private extension Comparable {
min(max(self, range.lowerBound), range.upperBound) 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."
}
}
}

View file

@ -345,7 +345,7 @@ export async function getF1LiveDashboard(): Promise<F1LiveDashboard> {
const data: F1LiveDashboard = { const data: F1LiveDashboard = {
live, live,
source: "OpenF1", source: "live",
session: { session: {
sessionKey, sessionKey,
meetingKey, meetingKey,

View file

@ -1,5 +1,5 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { parseRacingNews365 } from "./news"; import { parseNewsArticle, parseRacingNews365 } from "./news";
const html = ` const html = `
<a href="https://racingnews365.nl/f1-nieuws" class="card card--default">skip</a> <a href="https://racingnews365.nl/f1-nieuws" class="card card--default">skip</a>
@ -20,5 +20,44 @@ const items = parseRacingNews365(html);
assert.equal(items.length, 1); assert.equal(items.length, 1);
assert.equal(items[0].title, 'Hadjar doet boekje open over polsbreuk: "Heel stom"'); assert.equal(items[0].title, 'Hadjar doet boekje open over polsbreuk: "Heel stom"');
assert.equal(items[0].id, "14443134"); assert.equal(items[0].id, "14443134");
assert.equal((items[0] as { url?: string }).url, undefined);
assert.ok(items[0].imageUrl?.includes("cdn.racingnews365.com")); assert.ok(items[0].imageUrl?.includes("cdn.racingnews365.com"));
const articleHtml = `
<h1 class="content__heading__h1 content__heading__h1--xl " data-ab-title="14443134">Hadjar doet boekje open over polsbreuk: &quot;Heel stom&quot;</h1>
<p class="entry-intro__content">Isack Hadjar was zes weken uit de running.</p>
<time datetime="2026-09-30T20:30:00+02:00">30 september 20:30</time>
<meta property="og:image" content="https://cdn.racingnews365.com/hero.jpg?width=1800&amp;height=945">
<div class="content-fields">
<div class="content-field__redactor">
<p>Eerste alinea over de blessure.</p>
<p>Het laatste Formule 1 en Max Verstappen nieuws vind je op RacingNews365.nl</p>
</div>
<div class="content-field__quote">
<p class="content-field__quote__text">&quot;Het was eigenlijk heel stom.&quot;</p>
<p class="content-field__quote__author">- Isack Hadjar</p>
</div>
<div class="content-field__redactor">
<h2 class="">Slecht nieuws brengen</h2>
<p>Tweede alinea over het team.</p>
</div>
</div>
<div class="category-wrapper"></div>
`;
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"); console.log("openf1/news.test.ts ok");

View file

@ -1,4 +1,7 @@
import { AppError } from "../security/errors";
const LIST_URL = "https://racingnews365.nl/f1-nieuws"; const LIST_URL = "https://racingnews365.nl/f1-nieuws";
const ORIGIN = "https://racingnews365.nl";
const UA = 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"; "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 = { export type F1NewsItem = {
id: string; id: string;
title: string; title: string;
url: string;
imageUrl: string | null; imageUrl: string | null;
publishedAt: 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 { function decodeHtml(s: string): string {
return s return s
.replace(/&nbsp;/g, " ")
.replace(/&quot;/g, '"') .replace(/&quot;/g, '"')
.replace(/&#039;/g, "'") .replace(/&#039;/g, "'")
.replace(/&apos;/g, "'") .replace(/&apos;/g, "'")
@ -46,8 +65,41 @@ function slugOf(url: string): string {
} }
} }
export function parseRacingNews365(html: string, limit = 20): F1NewsItem[] { function stripTags(html: string): string {
const items: F1NewsItem[] = []; return decodeHtml(
html
.replace(/<br\s*\/?>/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(/&amp;/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<string>(); const seen = new Set<string>();
const openRe = /<a\b([^>]+)>/gi; const openRe = /<a\b([^>]+)>/gi;
let m: RegExpExecArray | null; let m: RegExpExecArray | null;
@ -64,34 +116,102 @@ export function parseRacingNews365(html: string, limit = 20): F1NewsItem[] {
chunk.match(/class="card__title"[^>]*>\s*<span[^>]*>([^<]+)/i); chunk.match(/class="card__title"[^>]*>\s*<span[^>]*>([^<]+)/i);
if (!titleMatch) continue; if (!titleMatch) continue;
const title = decodeHtml(titleMatch[1].trim()); const title = decodeHtml(titleMatch[1].trim());
if (!title) continue; if (!title || isBrandLeak(title)) continue;
const timeMatch = chunk.match(/datetime="([^"]+)"/i); const timeMatch = chunk.match(/datetime="([^"]+)"/i);
const imgMatch = chunk.match(/https:\/\/cdn\.racingnews365\.com\/[^"'>\s]+/); const imgMatch = chunk.match(/https:\/\/cdn\.racingnews365\.com\/[^"'>\s]+/);
let imageUrl = imgMatch ? imgMatch[0].replace(/&amp;/g, "&") : null; let imageUrl = imgMatch ? biggerImage(imgMatch[0]) : null;
if (imageUrl) {
imageUrl = imageUrl.replace(/width=\d+/i, "width=720").replace(/height=\d+/i, "height=405");
}
const idMatch = attrs.match(/data-id="(\d+)"/) || chunk.match(/data-id="(\d+)"/); const idMatch = attrs.match(/data-id="(\d+)"/) || chunk.match(/data-id="(\d+)"/);
seen.add(href); seen.add(href);
items.push({ items.push({
id: idMatch?.[1] || slug, id: idMatch?.[1] || slug,
slug,
title, title,
url: href, url: href,
imageUrl, imageUrl,
publishedAt: timeMatch?.[1] || null, publishedAt: timeMatch?.[1] || null,
source: "RacingNews365",
}); });
} }
return items; return items;
} }
let newsCache: { at: number; items: F1NewsItem[] } | null = null; export function parseNewsArticle(html: string, fallback: InternalNews): F1NewsArticle {
const title =
stripTags(html.match(/<h1[^>]*class="content__heading__h1[^"]*"[^>]*>([\s\S]*?)<\/h1>/i)?.[1] || "") ||
fallback.title;
const dekRaw = stripTags(html.match(/<p class="entry-intro__content"[^>]*>([\s\S]*?)<\/p>/i)?.[1] || "");
const dek = dekRaw && !isBrandLeak(dekRaw) ? dekRaw : null;
const publishedAt =
html.match(/<time[^>]*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<F1NewsItem[]> { const start = html.search(/class="content-fields"/i);
if (newsCache && Date.now() - newsCache.at < 8 * 60_000) { const end = html.search(/class="category-wrapper"/i);
return newsCache.items.slice(0, limit); const bodyHtml = start >= 0 ? html.slice(start, end > start ? end : undefined) : "";
const parts = bodyHtml.split(/(?=<div class="content-field__(?:redactor|quote|images)")/i);
const blocks: F1NewsBlock[] = [];
const seenText = new Set<string>();
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 } : {}),
});
} }
const res = await fetch(LIST_URL, { continue;
}
if (/class="content-field__images"/i.test(part)) {
const src = part.match(/<img[^>]+src="([^"]+)"/i)?.[1];
const alt = part.match(/<img[^>]+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);
}
}
}
return {
id: fallback.id,
title,
dek,
publishedAt,
imageUrl,
blocks,
};
}
let newsCache: { at: number; items: InternalNews[] } | null = null;
const articleCache = new Map<string, { at: number; article: F1NewsArticle }>();
async function fetchHtml(url: string): Promise<string> {
const res = await fetch(url, {
headers: { headers: {
Accept: "text/html,application/xhtml+xml", Accept: "text/html,application/xhtml+xml",
"User-Agent": UA, "User-Agent": UA,
@ -99,9 +219,42 @@ export async function getF1News(limit = 16): Promise<F1NewsItem[]> {
}, },
signal: AbortSignal.timeout(18_000), signal: AbortSignal.timeout(18_000),
}); });
if (!res.ok) throw new Error(`RacingNews365 HTTP ${res.status}`); if (!res.ok) throw new Error(`Nieuws HTTP ${res.status}`);
const html = await res.text(); return res.text();
const items = parseRacingNews365(html, 24); }
async function loadList(limit = 24): Promise<InternalNews[]> {
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 }; newsCache = { at: Date.now(), items };
return items.slice(0, limit); return items.slice(0, limit);
} }
export async function getF1News(limit = 16): Promise<F1NewsItem[]> {
const items = await loadList(limit);
return items.map(toPublicNews);
}
export async function getF1NewsArticle(id: string): Promise<F1NewsArticle> {
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);

View file

@ -927,6 +927,20 @@ export function registerViewerRoutes(
return { items: await getF1News(20) }; 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) => { app.get("/api/v1/client/viaplay/home", async (request) => {
const auth = await viewers.authFromBearer(request.headers.authorization); const auth = await viewers.authFromBearer(request.headers.authorization);
const { resolveViewerLiveEventsAccess } = await import("./schedule-events"); const { resolveViewerLiveEventsAccess } = await import("./schedule-events");