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(); const openRe = /]+)>/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*]*>([^<]+)/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 { 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); }