stremio/apps/master-api/src/openf1/news.ts
Jos Vooges | STH 84cc1e99f9 Add OpenF1 live timing in the iOS F1 hub, with credentials stored in admin.
Login lives encrypted in the database (Instellingen → OpenF1) so the phone can use live data without env secrets.
2026-10-01 01:22:46 +02:00

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(/&lt;/g, "<")
.replace(/&gt;/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(/&amp;/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);
}