/** * Liest Thread-Listen eines WoltLab-Forums (Burning Board). * * Gelesen wird ausschließlich die Übersichtsseite: sie enthält neben Titel und * Thread-ID auch den Ersteller, sodass die Threads nicht einzeln geöffnet * werden müssen. Das hält einen Lauf bei drei Anfragen statt einer je Thread. * * Wichtig: Eine Boardseite enthält neben der Thread-Liste auch Unterforen und * globale Kästen ("letzte Beiträge"). Beides trägt ebenfalls Thread-Links, ist * aber kein Thread dieses Boards - deshalb wird strikt entlang der * tabularListRow-Einträge gelesen und nicht über alle Links im Dokument. */ export interface ForumThread { threadId: number; title: string; url: string; authorName: string | null; /// Stabiler als der Name: Forumsnamen ändern sich, die ID nicht. authorForumId: number | null; /// WoltLab lässt beim Verschieben optional einen Verweis am alten Ort zurück. isMovedStub: boolean; /// Labels des Threads, etwa "Wichtig" oder "SCP-RP". labels: string[]; } /// Angepinnte Ankündigungen liegen mit in den CC-Boards - Changelogs, /// Regelwerke, Bewerbungsphasen. Sie tragen diese Labels und sind keine Anträge. export const IGNORED_LABELS = ['wichtig', 'information']; export function hasIgnoredLabel(thread: ForumThread): boolean { return thread.labels.some((label) => IGNORED_LABELS.includes(label.toLowerCase())); } const ROW_SPLIT = /
  • /; const TOPIC_LINK = /]*class="[^"]*wbbTopicLink[^"]*"[^>]*>\s*([^<]{1,200})/; const USER_LINK = /]*class="userLink"[^>]*>(?:]*><\/i>)?\s*(?:]*>)?\s*([^<]{1,80})/; // Ein zurückgelassener Verweis ist kein eigener Thread. const MOVED_MARKER = /wbbTopicLinkMoved|class="[^"]*movedTopic|>\s*Verschoben\s*]*class="[^"]*\bbadge label\b[^"]*"[^>]*>([^<]{1,40})<\/a>/g; function decode(value: string): string { return value .replace(/"/g, '"') .replace(/�?39;/g, "'") .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>') .replace(/ /g, ' ') .replace(/\s+/g, ' ') .trim(); } export function parseBoard(html: string): ForumThread[] { const threads: ForumThread[] = []; const seen = new Set(); for (const block of html.split(ROW_SPLIT).slice(1)) { // Nur bis zum Ende der Zeile schauen, sonst greift der nächste Eintrag rein. const topic = TOPIC_LINK.exec(block); if (!topic) continue; const threadId = Number(topic[2]); if (!Number.isInteger(threadId) || seen.has(threadId)) continue; seen.add(threadId); const user = USER_LINK.exec(block); LABEL.lastIndex = 0; const labels = [...block.matchAll(LABEL)].map((m) => decode(m[1] ?? '')).filter(Boolean); threads.push({ threadId, title: decode(topic[3] ?? ''), url: decode(topic[1] ?? ''), authorName: user ? decode(user[2] ?? '') || null : null, authorForumId: user ? Number(user[1]) : null, isMovedStub: MOVED_MARKER.test(block), labels, }); } return threads; } /** * Anzahl der Seiten eines Boards. * * WoltLab führt die Gesamtzahl in einem eigenen Element, nicht in den Links: * die Blätterleiste zeigt nur die Nachbarseiten. Wer allein die pageNo-Links * auswertet, hält ein Board mit 25 Seiten für zweiseitig. */ export function parsePageCount(html: string): number { const element = /]*\scount="(\d+)"/.exec(html); if (element) return Math.max(1, Number(element[1])); // Ältere Fassungen ohne das Element: wenigstens die Links auswerten. const numbers = [...html.matchAll(/pageNo=(\d+)/g)].map((m) => Number(m[1])); return numbers.length > 0 ? Math.max(1, ...numbers) : 1; } /** * Zieht die Thread-ID aus einer Forums-URL. Angenommen werden der volle Link, * der Pfad oder die blanke Nummer - beim Eintippen von Hand landet mal das * eine, mal das andere im Feld. */ export function parseThreadId(input: string): number | null { const value = input.trim(); if (value.length === 0) return null; const fromUrl = /thread\/(\d+)/.exec(value); if (fromUrl) return Number(fromUrl[1]); return /^\d+$/.test(value) ? Number(value) : null; }