Show F1 news in-app without source branding, including a full article reader.
This commit is contained in:
parent
db79d4bdcb
commit
91e6259f3f
7 changed files with 441 additions and 48 deletions
|
|
@ -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)")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -345,7 +345,7 @@ export async function getF1LiveDashboard(): Promise<F1LiveDashboard> {
|
|||
|
||||
const data: F1LiveDashboard = {
|
||||
live,
|
||||
source: "OpenF1",
|
||||
source: "live",
|
||||
session: {
|
||||
sessionKey,
|
||||
meetingKey,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { parseRacingNews365 } from "./news";
|
||||
import { parseNewsArticle, parseRacingNews365 } from "./news";
|
||||
|
||||
const html = `
|
||||
<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[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 = `
|
||||
<h1 class="content__heading__h1 content__heading__h1--xl " data-ab-title="14443134">Hadjar doet boekje open over polsbreuk: "Heel stom"</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&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">"Het was eigenlijk heel stom."</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");
|
||||
|
|
|
|||
|
|
@ -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(/<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(/&/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 openRe = /<a\b([^>]+)>/gi;
|
||||
let m: RegExpExecArray | null;
|
||||
|
|
@ -64,34 +116,102 @@ export function parseRacingNews365(html: string, limit = 20): F1NewsItem[] {
|
|||
chunk.match(/class="card__title"[^>]*>\s*<span[^>]*>([^<]+)/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(/<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[]> {
|
||||
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(/(?=<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 } : {}),
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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<string, { at: number; article: F1NewsArticle }>();
|
||||
|
||||
async function fetchHtml(url: string): Promise<string> {
|
||||
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<F1NewsItem[]> {
|
|||
},
|
||||
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<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 };
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue