Login lives encrypted in the database (Instellingen → OpenF1) so the phone can use live data without env secrets.
107 lines
3.2 KiB
TypeScript
107 lines
3.2 KiB
TypeScript
const LIST_URL = "https://racingnews365.nl/f1-nieuws";
|
|
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";
|
|
|
|
const SKIP = new Set([
|
|
"f1-nieuws",
|
|
"liveblog-formule-1",
|
|
"coureurs",
|
|
"formule-1-op-tv",
|
|
"f1-podcast",
|
|
"uitslag-formule-1",
|
|
"indycar",
|
|
"abonnementen",
|
|
"premium",
|
|
"video",
|
|
"interview",
|
|
"podcast",
|
|
"f1",
|
|
]);
|
|
|
|
export type F1NewsItem = {
|
|
id: string;
|
|
title: string;
|
|
url: string;
|
|
imageUrl: string | null;
|
|
publishedAt: string | null;
|
|
source: string;
|
|
};
|
|
|
|
function decodeHtml(s: string): string {
|
|
return s
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, "'")
|
|
.replace(/'/g, "'")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)));
|
|
}
|
|
|
|
function slugOf(url: string): string {
|
|
try {
|
|
return new URL(url).pathname.replace(/^\/+|\/+$/g, "");
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
export function parseRacingNews365(html: string, limit = 20): F1NewsItem[] {
|
|
const items: F1NewsItem[] = [];
|
|
const seen = new Set<string>();
|
|
const openRe = /<a\b([^>]+)>/gi;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = openRe.exec(html)) && items.length < limit) {
|
|
const attrs = m[1] || "";
|
|
if (!/\bcard\b/i.test(attrs)) continue;
|
|
const href = attrs.match(/href="(https:\/\/racingnews365\.nl\/[^"]+)"/i)?.[1];
|
|
if (!href) continue;
|
|
const slug = slugOf(href);
|
|
if (!slug || SKIP.has(slug) || slug.includes("/") || seen.has(href) || slug.length < 12) continue;
|
|
const chunk = html.slice(m.index, m.index + 4500);
|
|
const titleMatch =
|
|
chunk.match(/data-ab-title="\d+">([^<]+)/i) ||
|
|
chunk.match(/class="card__title"[^>]*>\s*<span[^>]*>([^<]+)/i);
|
|
if (!titleMatch) continue;
|
|
const title = decodeHtml(titleMatch[1].trim());
|
|
if (!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");
|
|
}
|
|
const idMatch = attrs.match(/data-id="(\d+)"/) || chunk.match(/data-id="(\d+)"/);
|
|
seen.add(href);
|
|
items.push({
|
|
id: idMatch?.[1] || slug,
|
|
title,
|
|
url: href,
|
|
imageUrl,
|
|
publishedAt: timeMatch?.[1] || null,
|
|
source: "RacingNews365",
|
|
});
|
|
}
|
|
return items;
|
|
}
|
|
|
|
let newsCache: { at: number; items: F1NewsItem[] } | null = null;
|
|
|
|
export async function getF1News(limit = 16): Promise<F1NewsItem[]> {
|
|
if (newsCache && Date.now() - newsCache.at < 8 * 60_000) {
|
|
return newsCache.items.slice(0, limit);
|
|
}
|
|
const res = await fetch(LIST_URL, {
|
|
headers: {
|
|
Accept: "text/html,application/xhtml+xml",
|
|
"User-Agent": UA,
|
|
"Accept-Language": "nl-NL,nl;q=0.9",
|
|
},
|
|
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);
|
|
newsCache = { at: Date.now(), items };
|
|
return items.slice(0, limit);
|
|
}
|