/**
* 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;
}