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")
|
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)")
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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: "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");
|
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 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(/ /g, " ")
|
||||||
.replace(/"/g, '"')
|
.replace(/"/g, '"')
|
||||||
.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[] {
|
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(/&/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(/&/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);
|
||||||
|
|
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue