/** * 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; /// Sein Profil, so wie das Forum selbst darauf verweist. Übernommen statt /// zusammengebaut: die Profile liegen nicht unter dem Forums-Pfad, sondern /// eine Ebene darüber, und das lässt sich nicht verlässlich erraten. authorProfileUrl: string | 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[]; /// Wann der Antrag gestellt wurde, laut Forum. postedAt: Date | null; } /// 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})/; // Wer sein Konto gelöscht hat, steht ohne Profillink in der Zelle. Der Name // ist trotzdem da und bleibt die einzige Spur zum Spieler - ohne diesen // Rückfall fallen solche Threads bei der Zuordnung durchs Raster. const AUTHOR_CELL = /
  • ([\s\S]{0,400}?)<\/li>/; // Ein zurückgelassener Verweis ist kein eigener Thread. const MOVED_MARKER = /wbbTopicLinkMoved|class="[^"]*movedTopic|>\s*Verschoben\s*\s*]*\sdate="([^"]+)"/; // Labels hängen als eigene Links über dem Titel, nicht als span. const LABEL = /]*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); let authorName = user ? decode(user[3] ?? '') || null : null; if (authorName === null) { const cell = AUTHOR_CELL.exec(block); if (cell) authorName = decode((cell[1] ?? '').replace(/<[^>]*>/g, ' ')) || null; } LABEL.lastIndex = 0; const labels = [...block.matchAll(LABEL)].map((m) => decode(m[1] ?? '')).filter(Boolean); const zeit = POSTED_AT.exec(block); const postedAt = zeit ? new Date(zeit[1] as string) : null; threads.push({ threadId, title: decode(topic[3] ?? ''), url: decode(topic[1] ?? ''), authorName, authorForumId: user ? Number(user[2]) : null, authorProfileUrl: user ? decode(user[1] ?? '') || null : null, isMovedStub: MOVED_MARKER.test(block), labels, postedAt: postedAt && !Number.isNaN(postedAt.getTime()) ? postedAt : null, }); } 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; }