Antraege vollstaendig erfassen und zum Forum verlinken

Threads ohne zuordenbaren Spieler wurden bisher verworfen. Von 502 Antraegen
im Forum kamen dadurch nur die wenigen an, deren Ersteller schon als Spieler
mit Forum-Namen erfasst war - der Rest war schlicht unsichtbar. Sie werden
jetzt erfasst und stehen unter "Ohne zugeordneten Spieler", durchsuchbar nach
Titel und Ersteller, aber ausserhalb der Arbeitsliste: ohne Spieler laesst
sich nichts buchen.

Wird spaeter ein Spieler mit passendem Forum-Namen angelegt, der Name
nachgetragen oder ein frueheres Konto ergaenzt, wandern seine wartenden
Antraege von selbst hinueber. Ein erneuter Abgleich ist dafuer nicht noetig,
und eine bereits getroffene Zuordnung bleibt unberuehrt.

Der Name eines CCs fuehrt auf der Spielerseite jetzt zum Forumsbeitrag, sofern
einer verknuepft ist.

Weitere Korrekturen am Abgleich:

- Wer sein Forumskonto geloescht hat, stand ohne Profil-Link in der Zeile und
  hatte damit keinen Autor. Solche Antraege liessen sich niemandem zuordnen.
- Bei einer abgewiesenen Anfrage brach das ganze Board ab; die bis dahin
  gelesenen Seiten gingen mit verloren. Jetzt wird die genannte Wartezeit
  eingehalten, das Tempo fuer den Rest des Laufs gedrosselt und die Seite
  erneut versucht. Was gelesen wurde, bleibt in jedem Fall erhalten.
- Derselbe Schutz im GExtension-Abgleich, der sonst den Spieler uebersprungen
  haette.
- Der Board-Filter fragte nach einem Feld "board", das es nicht gibt.

Ein voller Lauf dauert rund eine halbe Minute und laenger, wenn das Forum
bremst. Er laeuft deshalb im Hintergrund weiter und meldet seinen Fortschritt,
statt an einer HTTP-Zeitgrenze zu sterben. Gelesen und geschrieben wird
gebuendelt statt Thread fuer Thread.
This commit is contained in:
2026-09-21 00:06:33 +02:00
parent e78a57e35e
commit 15d683c59e
16 changed files with 1034 additions and 193 deletions
+23 -4
View File
@@ -190,6 +190,8 @@ Spieler angezeigt, aber **nie** automatisch verrechnet - es ist eine Lesehilfe z
dem eigenen Ledger, mehr nicht. dem eigenen Ledger, mehr nicht.
Der Abgleich hält eine Sekunde Abstand zwischen den Anfragen und weist sich per User-Agent aus. Der Abgleich hält eine Sekunde Abstand zwischen den Anfragen und weist sich per User-Agent aus.
Weist GExtension eine Anfrage wegen zu vieler Zugriffe ab, wird gewartet und erneut versucht,
statt den Spieler zu überspringen; das Tempo bleibt für den Rest des Laufs gedrosselt.
Die Seite gehört dem Serverbetreiber, nicht dieser App - ein offizieller Datenzugang wäre der Die Seite gehört dem Serverbetreiber, nicht dieser App - ein offizieller Datenzugang wäre der
sauberere Weg, falls er zu haben ist. sauberere Weg, falls er zu haben ist.
@@ -215,10 +217,16 @@ es gibt keinen zweiten Buchungsweg mit eigenen Regeln.
Zugeordnet wird über den Forumsnamen des Erstellers gegen das Feld *Forum-Name* beim Spieler - Zugeordnet wird über den Forumsnamen des Erstellers gegen das Feld *Forum-Name* beim Spieler -
und über die **früheren Konten**, die auf der Spielerseite nachgetragen werden können. Wer seinen und über die **früheren Konten**, die auf der Spielerseite nachgetragen werden können. Wer seinen
Steam- oder Forumsaccount gewechselt hat, behält so seine Ansprüche. Steam- oder Forumsaccount gewechselt hat, behält so seine Ansprüche. Steht der Ersteller nirgends,
**Threads ohne zuordenbaren Spieler werden gar nicht erst erfasst** - das Archiv reicht Jahre landet der Antrag unter *Ohne zugeordneten Spieler*: erfasst und durchsuchbar, aber außerhalb der
zurück und enthält vieles, was kein CC-Antrag ist. Fehlt jemand, trag seinen Forumsnamen beim Arbeitsliste, denn ohne Spieler lässt sich nichts buchen. Von dort ordnest du ihn von Hand zu.
Spieler nach, dann nimmt ihn der nächste Abgleich auf.
Legst du später einen Spieler mit passendem Forum-Namen an - oder trägst den Namen nach, oder
ergänzt ein früheres Konto -, wandern seine wartenden Anträge **von selbst** in die Arbeitsliste.
Ein erneuter Abgleich ist dafür nicht nötig. Bereits zugeordnete Anträge bleiben dabei unberührt.
Ist ein Antrag entschieden, hängt sein Thread am entstandenen CC: auf der Spielerseite führt ein
Klick auf den CC-Namen direkt zum Forumsbeitrag.
Angepinnte Ankündigungen (Changelogs, Regelwerke) liegen mit in den Boards. Sie werden über ihre Angepinnte Ankündigungen (Changelogs, Regelwerke) liegen mit in den Boards. Sie werden über ihre
Labels *Wichtig* und *Information* aussortiert. Der Titel wird dagegen **nie** ausgewertet: CC-Anträge Labels *Wichtig* und *Information* aussortiert. Der Titel wird dagegen **nie** ausgewertet: CC-Anträge
@@ -233,6 +241,17 @@ Wiedererkennung. Wandert ein **bereits entschiedener** Antrag später in ein and
ins Archiv, erscheint das oben als Hinweis. Es ändert nie automatisch den CC-Status: dafür ist ins Archiv, erscheint das oben als Hinweis. Es ändert nie automatisch den CC-Status: dafür ist
das Signal zu unzuverlässig. das Signal zu unzuverlässig.
### Wie lange ein Abgleich dauert
Das Archiv allein umfasst über zwanzig Seiten. Zwischen den Abrufen liegt gut eine Sekunde, ein
voller Lauf braucht also rund eine halbe Minute. Bremst das Forum den Zugriff aus, wird die
angegebene Wartezeit eingehalten, der Abstand für den Rest des Laufs verdoppelt und die Seite
erneut versucht - der Lauf dauert dann länger, liest aber trotzdem alles.
Deshalb läuft der Abgleich **im Hintergrund**. Der Knopf zeigt den Fortschritt, und der Lauf
geht weiter, auch wenn du die Seite verlässt. Bricht ein Board doch ab, bleibt erhalten, was bis
dahin gelesen wurde; die Meldung nennt das Board und die Seite, bis zu der es gereicht hat.
## Rollen ## Rollen
Es gibt **keine öffentliche Registrierung**. Konten legt ausschließlich ein Administrator an. Es gibt **keine öffentliche Registrierung**. Konten legt ausschließlich ein Administrator an.
+11 -1
View File
@@ -39,6 +39,11 @@ const TOPIC_LINK =
const USER_LINK = const USER_LINK =
/<a\s+href="[^"]*user\/(\d+)-[^"]*"[^>]*class="userLink"[^>]*>(?:<i[^>]*><\/i>)?\s*(?:<span[^>]*>)?\s*([^<]{1,80})/; /<a\s+href="[^"]*user\/(\d+)-[^"]*"[^>]*class="userLink"[^>]*>(?:<i[^>]*><\/i>)?\s*(?:<span[^>]*>)?\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 = /<li class="messageGroupAuthor">([\s\S]{0,400}?)<\/li>/;
// Ein zurückgelassener Verweis ist kein eigener Thread. // Ein zurückgelassener Verweis ist kein eigener Thread.
const MOVED_MARKER = /wbbTopicLinkMoved|class="[^"]*movedTopic|>\s*Verschoben\s*</i; const MOVED_MARKER = /wbbTopicLinkMoved|class="[^"]*movedTopic|>\s*Verschoben\s*</i;
@@ -71,6 +76,11 @@ export function parseBoard(html: string): ForumThread[] {
seen.add(threadId); seen.add(threadId);
const user = USER_LINK.exec(block); const user = USER_LINK.exec(block);
let authorName = user ? decode(user[2] ?? '') || null : null;
if (authorName === null) {
const cell = AUTHOR_CELL.exec(block);
if (cell) authorName = decode((cell[1] ?? '').replace(/<[^>]*>/g, ' ')) || null;
}
LABEL.lastIndex = 0; LABEL.lastIndex = 0;
const labels = [...block.matchAll(LABEL)].map((m) => decode(m[1] ?? '')).filter(Boolean); const labels = [...block.matchAll(LABEL)].map((m) => decode(m[1] ?? '')).filter(Boolean);
@@ -79,7 +89,7 @@ export function parseBoard(html: string): ForumThread[] {
threadId, threadId,
title: decode(topic[3] ?? ''), title: decode(topic[3] ?? ''),
url: decode(topic[1] ?? ''), url: decode(topic[1] ?? ''),
authorName: user ? decode(user[2] ?? '') || null : null, authorName,
authorForumId: user ? Number(user[1]) : null, authorForumId: user ? Number(user[1]) : null,
isMovedStub: MOVED_MARKER.test(block), isMovedStub: MOVED_MARKER.test(block),
labels, labels,
+55 -25
View File
@@ -1,5 +1,6 @@
import { ProductTarget } from '@prisma/client'; import { ProductTarget } from '@prisma/client';
import { badRequest } from '../errors.js'; import { badRequest } from '../errors.js';
import { isRateLimited, retryAfterMs, sleep } from './ratelimit.js';
import { normalizeSteamId } from './steamid.js'; import { normalizeSteamId } from './steamid.js';
/** /**
@@ -138,14 +139,26 @@ export const DEFAULT_BASE_URL = 'https://gextension.senseigaming.de';
// Gemeinsam genutzte Community-Infrastruktur: höchstens eine Anfrage pro Sekunde. // Gemeinsam genutzte Community-Infrastruktur: höchstens eine Anfrage pro Sekunde.
const MIN_REQUEST_GAP_MS = 1000; const MIN_REQUEST_GAP_MS = 1000;
const MAX_REQUEST_GAP_MS = 10000;
const MAX_RETRIES = 2;
let lastRequestAt = 0; let lastRequestAt = 0;
/// Wächst, sobald die Gegenseite bremst. Einmal oben, bleibt es oben - bei
/// einem Abgleich über viele Spieler wäre jedes Zurücksetzen ein Rückfall in
/// dasselbe Tempo, das gerade abgewiesen wurde.
let requestGapMs = MIN_REQUEST_GAP_MS;
async function throttle(): Promise<void> { async function throttle(): Promise<void> {
const wait = lastRequestAt + MIN_REQUEST_GAP_MS - Date.now(); const wait = lastRequestAt + requestGapMs - Date.now();
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait)); if (wait > 0) await sleep(wait);
lastRequestAt = Date.now(); lastRequestAt = Date.now();
} }
/** Setzt das Tempo zurück, etwa zu Beginn eines neuen Abgleichs. */
export function resetRequestPace(): void {
requestGapMs = MIN_REQUEST_GAP_MS;
}
export interface FetchOptions { export interface FetchOptions {
baseUrl?: string; baseUrl?: string;
timeoutMs?: number; timeoutMs?: number;
@@ -161,32 +174,49 @@ export async function fetchProfile(
const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ''); const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
const timeoutMs = options.timeoutMs ?? 15000; const timeoutMs = options.timeoutMs ?? 15000;
await throttle(); for (let versuch = 0; ; versuch += 1) {
await throttle();
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs); const timer = setTimeout(() => controller.abort(), timeoutMs);
try { try {
const response = await fetch(`${baseUrl}/index.php?t=user&id=${steamId64}`, { const response = await fetch(`${baseUrl}/index.php?t=user&id=${steamId64}`, {
signal: controller.signal, signal: controller.signal,
headers: { headers: {
// Ehrliche Kennzeichnung statt Browser-Tarnung. // Ehrliche Kennzeichnung statt Browser-Tarnung.
'user-agent': 'CC-Manager/1.0 (Paket-Abgleich für das CC-Team)', 'user-agent': 'CC-Manager/1.0 (Paket-Abgleich für das CC-Team)',
accept: 'text/html', accept: 'text/html',
}, },
}); });
if (!response.ok) { // Bremst die Gegenseite, wird gewartet statt den Spieler zu verlieren.
throw badRequest('PROFILE_UNAVAILABLE', `Profil nicht abrufbar (HTTP ${response.status})`); if (isRateLimited(response.status)) {
requestGapMs = Math.min(requestGapMs * 2, MAX_REQUEST_GAP_MS);
if (versuch >= MAX_RETRIES) {
throw badRequest(
'PROFILE_RATE_LIMITED',
`GExtension bremst den Abruf aus (HTTP ${response.status})`,
);
}
await sleep(retryAfterMs(response.headers.get('retry-after'), versuch));
continue;
}
if (!response.ok) {
throw badRequest('PROFILE_UNAVAILABLE', `Profil nicht abrufbar (HTTP ${response.status})`);
}
return parseProfile(await response.text(), steamId64);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw badRequest('PROFILE_TIMEOUT', 'Zeitüberschreitung beim Abruf des Profils');
}
throw error;
} finally {
clearTimeout(timer);
} }
return parseProfile(await response.text(), steamId64);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw badRequest('PROFILE_TIMEOUT', 'Zeitüberschreitung beim Abruf des Profils');
}
throw error;
} finally {
clearTimeout(timer);
} }
} }
+30
View File
@@ -0,0 +1,30 @@
/**
* Rücksicht auf fremde Server.
*
* Sowohl das Forum als auch GExtension gehören der Community, nicht uns. Beide
* weisen zu viele Anfragen ab, statt sie zu beantworten. Wer darauf mit einem
* Abbruch reagiert, verliert den Rest des Laufs - deshalb wird gewartet und
* erneut versucht, und das Tempo bleibt für den Rest des Laufs gedrosselt.
*/
/// Antworten, die "später nochmal" bedeuten.
export function isRateLimited(status: number): boolean {
return status === 429 || status === 503;
}
/**
* Wie lange nach einer Abweisung gewartet wird. Nennt die Gegenseite selbst
* eine Zeit, gilt die; sonst wird mit jedem Versuch länger gewartet. Nach oben
* gedeckelt, damit ein Lauf nicht an einer absurden Angabe hängen bleibt.
*/
export function retryAfterMs(header: string | null, versuch: number): number {
const sekunden = Number(header);
if (Number.isFinite(sekunden) && sekunden > 0) {
return Math.min(sekunden * 1000, 60000);
}
return Math.min(5000 * (versuch + 1), 30000);
}
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+25 -2
View File
@@ -6,15 +6,23 @@ import {
assignPlayer, assignPlayer,
classifyThread, classifyThread,
dismissBoardChangeNotice, dismissBoardChangeNotice,
getScanState,
listBoardChangeNotices, listBoardChangeNotices,
listThreads, listThreads,
scanForum, listUnassignedThreads,
startScan,
} from '../services/forum.js'; } from '../services/forum.js';
import { contextOf, idParam, parseWith } from './helpers.js'; import { contextOf, idParam, parseWith } from './helpers.js';
export async function forumRoutes(app: FastifyInstance): Promise<void> { export async function forumRoutes(app: FastifyInstance): Promise<void> {
// Der Lauf braucht dutzende Abrufe und kann vom Forum ausgebremst werden.
// Deshalb wird er nur angestoßen; den Stand holt die Oberfläche ab.
app.post('/api/forum/scan', { preHandler: requireEditor }, async (request) => { app.post('/api/forum/scan', { preHandler: requireEditor }, async (request) => {
return scanForum(contextOf(request)); return startScan(contextOf(request));
});
app.get('/api/forum/scan/status', { preHandler: requireAuth }, async () => {
return getScanState();
}); });
app.get('/api/forum/threads', { preHandler: requireAuth }, async (request) => { app.get('/api/forum/threads', { preHandler: requireAuth }, async (request) => {
@@ -36,6 +44,21 @@ export async function forumRoutes(app: FastifyInstance): Promise<void> {
}; };
}); });
app.get('/api/forum/threads/unassigned', { preHandler: requireAuth }, async (request) => {
const query = parseWith(
z.object({
q: z.string().trim().max(120).optional(),
take: z.coerce.number().int().min(1).max(200).default(50),
}),
request.query,
);
return listUnassignedThreads({
take: query.take,
...(query.q ? { query: query.q } : {}),
});
});
app.get('/api/forum/notices', { preHandler: requireAuth }, async () => { app.get('/api/forum/notices', { preHandler: requireAuth }, async () => {
return { items: await listBoardChangeNotices() }; return { items: await listBoardChangeNotices() };
}); });
+5
View File
@@ -469,6 +469,11 @@ export async function listCharacters(playerId: number) {
include: { include: {
predecessor: { select: { id: true, name: true } }, predecessor: { select: { id: true, name: true } },
successors: { select: { id: true, name: true } }, successors: { select: { id: true, name: true } },
// Der verknüpfte Antrag macht den Namen zum Link ins Forum.
forumThreads: {
select: { threadId: true, title: true, url: true },
orderBy: { threadId: 'desc' },
},
members: { members: {
orderBy: [{ leftAt: 'asc' }, { joinedAt: 'asc' }], orderBy: [{ leftAt: 'asc' }, { joinedAt: 'asc' }],
include: { player: { select: { id: true, displayName: true } } }, include: { player: { select: { id: true, displayName: true } } },
+357 -130
View File
@@ -3,6 +3,7 @@ import { prisma } from '../db.js';
import { env } from '../env.js'; import { env } from '../env.js';
import { badRequest, conflict, notFound } from '../errors.js'; import { badRequest, conflict, notFound } from '../errors.js';
import { hasIgnoredLabel, parseBoard, parsePageCount, type ForumThread } from '../lib/forum.js'; import { hasIgnoredLabel, parseBoard, parsePageCount, type ForumThread } from '../lib/forum.js';
import { isRateLimited, retryAfterMs, sleep } from '../lib/ratelimit.js';
import { recordAudit, type ActionContext } from './audit.js'; import { recordAudit, type ActionContext } from './audit.js';
/// Die drei Boards, zwischen denen ein Antrag wandert. Board 66 enthält neben /// Die drei Boards, zwischen denen ein Antrag wandert. Board 66 enthält neben
@@ -13,49 +14,83 @@ export const BOARDS: { board: ForumBoard; path: string }[] = [
{ board: ForumBoard.CC_ARCHIV, path: 'board/113-cc-archiv' }, { board: ForumBoard.CC_ARCHIV, path: 'board/113-cc-archiv' },
]; ];
const MIN_REQUEST_GAP_MS = 1000; const MIN_REQUEST_GAP_MS = 1200;
// Das Archiv reicht Jahre zurück; bei einer Sekunde Abstand dauert ein voller /// Bremst das Forum, wird der Abstand verdoppelt, bis hierhin.
// Lauf entsprechend. Die Grenze ist eine Notbremse, keine Sparmaßnahme. const MAX_REQUEST_GAP_MS = 10000;
/// So oft wird eine gebremste Seite erneut versucht, bevor aufgegeben wird.
const MAX_RETRIES = 3;
/// Das Archiv reicht Jahre zurück; die Grenze ist eine Notbremse, keine
/// Sparmaßnahme.
const MAX_PAGES_PER_BOARD = 60; const MAX_PAGES_PER_BOARD = 60;
let lastRequestAt = 0; let lastRequestAt = 0;
/// Wächst, sobald das Forum bremst, und bleibt für den Rest des Laufs oben.
let requestGapMs = MIN_REQUEST_GAP_MS;
let rateLimitHits = 0;
async function throttle(): Promise<void> { async function throttle(): Promise<void> {
const wait = lastRequestAt + MIN_REQUEST_GAP_MS - Date.now(); const wait = lastRequestAt + requestGapMs - Date.now();
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait)); if (wait > 0) await sleep(wait);
lastRequestAt = Date.now(); lastRequestAt = Date.now();
} }
/**
* Holt eine Boardseite und nimmt Rücksicht darauf, dass das Forum bei zu
* vielen Anfragen bremst. Eine abgewiesene Seite wird nach der angegebenen
* Wartezeit erneut versucht, und der Abstand für alle weiteren Abrufe steigt.
* Das kostet Zeit, ist aber der einzige Weg, ein Board vollständig zu lesen -
* wer nach dem ersten Korb aufgibt, verliert den Rest des Boards.
*/
async function fetchPage(path: string, page: number): Promise<string> { async function fetchPage(path: string, page: number): Promise<string> {
await throttle();
const base = env.FORUM_BASE_URL.replace(/\/+$/, ''); const base = env.FORUM_BASE_URL.replace(/\/+$/, '');
const url = page > 1 ? `${base}/index.php?${path}/&pageNo=${page}` : `${base}/index.php?${path}/`; const url = page > 1 ? `${base}/index.php?${path}/&pageNo=${page}` : `${base}/index.php?${path}/`;
const controller = new AbortController(); for (let versuch = 0; ; versuch += 1) {
const timer = setTimeout(() => controller.abort(), 20000); await throttle();
try { const controller = new AbortController();
const response = await fetch(url, { const timer = setTimeout(() => controller.abort(), 20000);
signal: controller.signal,
headers: { try {
'user-agent': 'CC-Manager/1.0 (Antrags-Abgleich für das CC-Team)', const response = await fetch(url, {
accept: 'text/html', signal: controller.signal,
}, headers: {
}); 'user-agent': 'CC-Manager/1.0 (Antrags-Abgleich für das CC-Team)',
if (!response.ok) { accept: 'text/html',
throw badRequest('BOARD_UNAVAILABLE', `Board nicht abrufbar (HTTP ${response.status})`); },
});
if (isRateLimited(response.status)) {
rateLimitHits += 1;
requestGapMs = Math.min(requestGapMs * 2, MAX_REQUEST_GAP_MS);
if (versuch >= MAX_RETRIES) {
throw badRequest(
'BOARD_RATE_LIMITED',
`Das Forum bremst den Abruf aus (HTTP ${response.status})`,
);
}
await sleep(retryAfterMs(response.headers.get('retry-after'), versuch));
continue;
}
if (!response.ok) {
throw badRequest('BOARD_UNAVAILABLE', `Board nicht abrufbar (HTTP ${response.status})`);
}
return await response.text();
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw badRequest('BOARD_TIMEOUT', 'Zeitüberschreitung beim Abruf des Boards');
}
throw error;
} finally {
clearTimeout(timer);
} }
return await response.text();
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw badRequest('BOARD_TIMEOUT', 'Zeitüberschreitung beim Abruf des Boards');
}
throw error;
} finally {
clearTimeout(timer);
} }
} }
export interface ForumScanSummary { export interface ForumScanSummary {
scannedBoards: number; scannedBoards: number;
scannedPages: number; scannedPages: number;
@@ -64,10 +99,79 @@ export interface ForumScanSummary {
boardChangeNotices: number; boardChangeNotices: number;
skippedStubs: number; skippedStubs: number;
skippedLabels: number; skippedLabels: number;
/// Autor gehört zu keinem erfassten Spieler - das Archiv ist voll davon. /// Erfasst, aber noch keinem Spieler zugeordnet. Solche Threads landen nicht
skippedUnknownAuthor: number; /// in der Arbeitsliste, gehen aber auch nicht verloren.
unassignedThreads: number;
matchedPlayers: number; matchedPlayers: number;
failedBoards: { board: string; reason: string }[]; /// Wie oft das Forum den Abruf gebremst hat. Über null heißt: der Lauf hat
/// länger gedauert, gelesen wurde trotzdem alles.
rateLimited: number;
/// Boards, die nicht zu Ende gelesen werden konnten - mit der Zahl der
/// Seiten, die es bis dahin geschafft haben. Was gelesen wurde, ist erfasst.
failedBoards: { board: string; reason: string; pagesRead: number }[];
}
export interface ScanState {
running: boolean;
startedAt: string | null;
finishedAt: string | null;
/// Woran gerade gelesen wird, für die Anzeige während des Laufs.
currentBoard: string | null;
pagesRead: number;
summary: ForumScanSummary | null;
error: string | null;
}
/**
* Ein voller Lauf geht über dutzende Seiten, und wenn das Forum bremst, kommen
* Wartezeiten obendrauf. Das überlebt keine HTTP-Anfrage, deshalb läuft der
* Abgleich im Hintergrund und die Oberfläche fragt den Stand ab.
*
* Der Zustand liegt im Speicher: nach einem Neustart des Dienstes ist er weg,
* was nichts kostet - der nächste Lauf liest ohnehin alles erneut.
*/
const scanState: ScanState = {
running: false,
startedAt: null,
finishedAt: null,
currentBoard: null,
pagesRead: 0,
summary: null,
error: null,
};
export function getScanState(): ScanState {
return { ...scanState };
}
/** Stößt einen Lauf an und kehrt sofort zurück. */
export function startScan(ctx: ActionContext): ScanState {
if (scanState.running) {
throw conflict('SCAN_RUNNING', 'Es läuft bereits ein Abgleich');
}
scanState.running = true;
scanState.startedAt = new Date().toISOString();
scanState.finishedAt = null;
scanState.currentBoard = null;
scanState.pagesRead = 0;
scanState.summary = null;
scanState.error = null;
void scanForum(ctx)
.then((summary) => {
scanState.summary = summary;
})
.catch((error: unknown) => {
scanState.error = error instanceof Error ? error.message : 'Abgleich fehlgeschlagen';
})
.finally(() => {
scanState.running = false;
scanState.currentBoard = null;
scanState.finishedAt = new Date().toISOString();
});
return getScanState();
} }
/** /**
@@ -84,136 +188,171 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
boardChangeNotices: 0, boardChangeNotices: 0,
skippedStubs: 0, skippedStubs: 0,
skippedLabels: 0, skippedLabels: 0,
skippedUnknownAuthor: 0, unassignedThreads: 0,
matchedPlayers: 0, matchedPlayers: 0,
rateLimited: 0,
failedBoards: [], failedBoards: [],
}; };
// Forumsnamen einmal laden statt je Thread zu suchen - die aktuellen und // Jeder Lauf fängt mit dem normalen Abstand an; gebremst wird erst, wenn
// frühere Konten, damit ein Namenswechsel die Zuordnung nicht abreißen lässt. // das Forum es verlangt.
const [players, aliases] = await Promise.all([ requestGapMs = MIN_REQUEST_GAP_MS;
prisma.player.findMany({ rateLimitHits = 0;
where: { forumName: { not: null } },
select: { id: true, forumName: true },
}),
prisma.playerAlias.findMany({
where: { type: 'FORUM_NAME' },
select: { playerId: true, value: true },
}),
]);
const byForumName = new Map<string, number>(); const byForumName = await forumNameIndex();
for (const alias of aliases) byForumName.set(alias.value.toLowerCase(), alias.playerId);
// Die Hauptangabe zuletzt, damit sie einen gleichlautenden Alias übersticht. // Erst alles lesen, dann alles schreiben. Ein voller Lauf geht über mehrere
for (const player of players) { // hundert Threads; je Thread einzeln zu lesen und zu schreiben würde länger
byForumName.set((player.forumName as string).toLowerCase(), player.id); // dauern als der Abruf selbst und den Aufruf ins Zeitlimit treiben.
} const gefunden = new Map<number, { thread: ForumThread; board: ForumBoard }>();
for (const { board, path } of BOARDS) { for (const { board, path } of BOARDS) {
let threads: ForumThread[] = []; let gelesen = 0;
scanState.currentBoard = path;
try { try {
const first = await fetchPage(path, 1); const first = await fetchPage(path, 1);
gelesen += 1;
summary.scannedPages += 1; summary.scannedPages += 1;
threads = parseBoard(first); scanState.pagesRead = summary.scannedPages;
const pages = Math.min(parsePageCount(first), MAX_PAGES_PER_BOARD); const seiten = Math.min(parsePageCount(first), MAX_PAGES_PER_BOARD);
for (let page = 2; page <= pages; page += 1) { for (const thread of parseBoard(first)) gefunden.set(thread.threadId, { thread, board });
for (let page = 2; page <= seiten; page += 1) {
const html = await fetchPage(path, page); const html = await fetchPage(path, page);
gelesen += 1;
summary.scannedPages += 1; summary.scannedPages += 1;
threads.push(...parseBoard(html)); scanState.pagesRead = summary.scannedPages;
for (const thread of parseBoard(html)) gefunden.set(thread.threadId, { thread, board });
} }
summary.scannedBoards += 1; summary.scannedBoards += 1;
} catch (error) { } catch (error) {
// Was bis hierher gelesen wurde, bleibt erhalten und wird gleich
// verarbeitet - ein Abbruch auf Seite 17 darf nicht die ersten 16
// Seiten mit in den Papierkorb nehmen.
summary.failedBoards.push({ summary.failedBoards.push({
board: path, board: path,
reason: error instanceof Error ? error.message : 'Unbekannter Fehler', reason: error instanceof Error ? error.message : 'Unbekannter Fehler',
pagesRead: gelesen,
}); });
}
}
summary.rateLimited = rateLimitHits;
const relevant: { thread: ForumThread; board: ForumBoard }[] = [];
for (const eintrag of gefunden.values()) {
// Ein zurückgelassener Verweis ist kein eigener Antrag.
if (eintrag.thread.isMovedStub) {
summary.skippedStubs += 1;
continue;
}
// Angepinnte Ankündigungen liegen mit in den Boards.
if (hasIgnoredLabel(eintrag.thread)) {
summary.skippedLabels += 1;
continue;
}
relevant.push(eintrag);
}
const ids = relevant.map((e) => e.thread.threadId);
const bekannt = new Map(
(await prisma.forumThread.findMany({ where: { threadId: { in: ids } } })).map((t) => [
t.threadId,
t,
]),
);
const neue: Prisma.ForumThreadCreateManyInput[] = [];
const historie: Prisma.ForumThreadBoardCreateManyInput[] = [];
const aenderungen: { threadId: number; data: Prisma.ForumThreadUpdateInput }[] = [];
for (const { thread, board } of relevant) {
const playerId = thread.authorName
? byForumName.get(thread.authorName.toLowerCase()) ?? null
: null;
const labels = thread.labels.join(', ') || null;
const existing = bekannt.get(thread.threadId);
// Auch ohne zuordenbaren Spieler wird der Thread erfasst. Er bleibt dann
// aus der Arbeitsliste heraus, ist aber auffindbar und lässt sich von Hand
// einem Spieler zuschlagen - sonst wäre er schlicht unsichtbar.
if (!existing) {
neue.push({
threadId: thread.threadId,
title: thread.title,
url: thread.url,
authorName: thread.authorName,
authorForumId: thread.authorForumId,
labels,
playerId,
currentBoard: board,
});
historie.push({ threadId: thread.threadId, board });
summary.newThreads += 1;
if (playerId === null) summary.unassignedThreads += 1;
else summary.matchedPlayers += 1;
continue; continue;
} }
for (const thread of threads) { const verschoben = existing.currentBoard !== board;
// Ein zurückgelassener Verweis ist kein eigener Antrag. // Eine später nachgetragene Zuordnung nicht wieder überschreiben.
if (thread.isMovedStub) { const nachgetragen = existing.playerId === null && playerId !== null;
summary.skippedStubs += 1;
continue;
}
// Angepinnte Ankündigungen liegen mit in den Boards. const geaendert =
if (hasIgnoredLabel(thread)) { verschoben ||
summary.skippedLabels += 1; nachgetragen ||
continue; existing.title !== thread.title ||
} existing.url !== thread.url ||
existing.authorName !== thread.authorName ||
existing.authorForumId !== thread.authorForumId ||
existing.labels !== labels;
const playerId = thread.authorName if (!geaendert) continue;
? byForumName.get(thread.authorName.toLowerCase()) ?? null
: null;
const existing = await prisma.forumThread.findUnique({ aenderungen.push({
where: { threadId: thread.threadId }, threadId: thread.threadId,
}); data: {
title: thread.title,
url: thread.url,
authorName: thread.authorName,
authorForumId: thread.authorForumId,
labels,
...(nachgetragen ? { player: { connect: { id: playerId as number } } } : {}),
...(verschoben
? {
currentBoard: board,
lastBoardChangeAt: new Date(),
// Wandert ein bereits entschiedener Thread, ist das ein Hinweis
// für den Admin - nie eine automatische Statusänderung.
...(existing.classification !== null ? { boardChangeNotice: true } : {}),
}
: {}),
},
});
// Das Archiv umfasst Jahre und enthält vieles, was kein CC-Antrag ist. if (verschoben) {
// Ohne zuordenbaren Spieler wird ein Thread deshalb gar nicht erst historie.push({ threadId: thread.threadId, board });
// erfasst - sonst ertrinkt die Liste in Fremdem. Bereits bekannte summary.movedThreads += 1;
// Threads werden weiter gepflegt, auch wenn die Zuordnung fehlt. if (existing.classification !== null) summary.boardChangeNotices += 1;
if (!existing && playerId === null) {
summary.skippedUnknownAuthor += 1;
continue;
}
if (!existing) {
await prisma.forumThread.create({
data: {
threadId: thread.threadId,
title: thread.title,
url: thread.url,
authorName: thread.authorName,
authorForumId: thread.authorForumId,
labels: thread.labels.join(', ') || null,
playerId,
currentBoard: board,
history: { create: [{ board }] },
},
});
summary.newThreads += 1;
summary.matchedPlayers += 1;
continue;
}
const movedBoard = existing.currentBoard !== board;
await prisma.forumThread.update({
where: { threadId: thread.threadId },
data: {
title: thread.title,
url: thread.url,
authorName: thread.authorName,
authorForumId: thread.authorForumId,
labels: thread.labels.join(', ') || null,
lastSeenAt: new Date(),
// Eine später nachgetragene Zuordnung nicht wieder überschreiben.
...(existing.playerId === null && playerId !== null ? { playerId } : {}),
...(movedBoard
? {
currentBoard: board,
lastBoardChangeAt: new Date(),
// Wandert ein bereits entschiedener Thread, ist das ein Hinweis
// für den Admin - nie eine automatische Statusänderung.
...(existing.classification !== null ? { boardChangeNotice: true } : {}),
}
: {}),
},
});
if (movedBoard) {
await prisma.forumThreadBoard.create({ data: { threadId: thread.threadId, board } });
summary.movedThreads += 1;
if (existing.classification !== null) summary.boardChangeNotices += 1;
}
} }
} }
if (neue.length > 0) await prisma.forumThread.createMany({ data: neue });
for (const { threadId, data } of aenderungen) {
await prisma.forumThread.update({ where: { threadId }, data });
}
if (historie.length > 0) await prisma.forumThreadBoard.createMany({ data: historie });
// Gesehen wurde jeder gelesene Thread, auch der unveränderte.
if (ids.length > 0) {
await prisma.forumThread.updateMany({
where: { threadId: { in: ids } },
data: { lastSeenAt: new Date() },
});
}
await recordAudit(prisma, ctx, { await recordAudit(prisma, ctx, {
action: 'forum.scan', action: 'forum.scan',
targetType: 'forum', targetType: 'forum',
@@ -224,6 +363,31 @@ export async function scanForum(ctx: ActionContext): Promise<ForumScanSummary> {
return summary; return summary;
} }
/**
* Forumsnamen auf Spieler abbilden - die aktuellen Angaben und frühere Konten,
* damit ein Namenswechsel die Zuordnung nicht abreißen lässt.
*/
async function forumNameIndex(): Promise<Map<string, number>> {
const [players, aliases] = await Promise.all([
prisma.player.findMany({
where: { forumName: { not: null } },
select: { id: true, forumName: true },
}),
prisma.playerAlias.findMany({
where: { type: 'FORUM_NAME' },
select: { playerId: true, value: true },
}),
]);
const index = new Map<string, number>();
for (const alias of aliases) index.set(alias.value.toLowerCase(), alias.playerId);
// Die Hauptangabe zuletzt, damit sie einen gleichlautenden Alias übersticht.
for (const player of players) {
index.set((player.forumName as string).toLowerCase(), player.id);
}
return index;
}
export async function listThreads(options: { export async function listThreads(options: {
onlyOpen?: boolean; onlyOpen?: boolean;
playerId?: number; playerId?: number;
@@ -231,7 +395,10 @@ export async function listThreads(options: {
}) { }) {
return prisma.forumThread.findMany({ return prisma.forumThread.findMany({
where: { where: {
...(options.onlyOpen ? { needsClassification: true } : {}), // Die Arbeitsliste zeigt nur, was auch entschieden werden kann. Ein
// Thread ohne Spieler steht in der eigenen Liste und wartet dort auf
// die Zuordnung von Hand.
...(options.onlyOpen ? { needsClassification: true, playerId: { not: null } } : {}),
...(options.playerId !== undefined ? { playerId: options.playerId } : {}), ...(options.playerId !== undefined ? { playerId: options.playerId } : {}),
...(options.board ? { currentBoard: options.board } : {}), ...(options.board ? { currentBoard: options.board } : {}),
}, },
@@ -243,6 +410,66 @@ export async function listThreads(options: {
}); });
} }
/**
* Threads, zu denen kein Spieler gefunden wurde. Das Archiv reicht Jahre
* zurück und enthält hunderte Anträge von Leuten, die hier nie erfasst
* wurden - deshalb durchsuchbar und begrenzt statt als eine lange Liste.
*/
export async function listUnassignedThreads(options: { query?: string; take?: number }) {
const take = Math.min(Math.max(options.take ?? 50, 1), 200);
const suche = options.query?.trim();
const where: Prisma.ForumThreadWhereInput = {
playerId: null,
needsClassification: true,
...(suche
? {
OR: [
{ title: { contains: suche, mode: 'insensitive' } },
{ authorName: { contains: suche, mode: 'insensitive' } },
],
}
: {}),
};
const [items, total] = await Promise.all([
prisma.forumThread.findMany({
where,
orderBy: [{ threadId: 'desc' }],
take,
include: {
player: { select: { id: true, displayName: true } },
linkedCharacter: { select: { id: true, name: true } },
},
}),
prisma.forumThread.count({ where }),
]);
return { items, total };
}
/**
* Zieht wartende Threads an einen Spieler heran, sobald dessen Forumsname
* bekannt wird. Ohne das bliebe ein frisch angelegter Spieler bis zum
* nächsten vollen Abgleich ohne seine Anträge.
*/
export async function claimThreadsForPlayer(playerId: number, names: string[]): Promise<number> {
const gesucht = names.map((n) => n.trim()).filter((n) => n.length > 0);
if (gesucht.length === 0) return 0;
const kandidaten = await prisma.forumThread.findMany({
where: { playerId: null, authorName: { in: gesucht, mode: 'insensitive' } },
select: { threadId: true },
});
if (kandidaten.length === 0) return 0;
const result = await prisma.forumThread.updateMany({
where: { threadId: { in: kandidaten.map((t) => t.threadId) }, playerId: null },
data: { playerId },
});
return result.count;
}
/** Threads, bei denen nach der Entscheidung das Board gewechselt hat. */ /** Threads, bei denen nach der Entscheidung das Board gewechselt hat. */
export async function listBoardChangeNotices() { export async function listBoardChangeNotices() {
return prisma.forumThread.findMany({ return prisma.forumThread.findMany({
+23 -2
View File
@@ -124,7 +124,7 @@ export async function createPlayer(input: PlayerInput, ctx: ActionContext) {
const steamId = canonicalSteamId(input.steamId); const steamId = canonicalSteamId(input.steamId);
if (steamId) await assertSteamIdFree(steamId); if (steamId) await assertSteamIdFree(steamId);
return prisma.$transaction(async (tx) => { const created = await prisma.$transaction(async (tx) => {
const created = await tx.player.create({ const created = await tx.player.create({
data: { data: {
displayName: input.displayName.trim(), displayName: input.displayName.trim(),
@@ -143,6 +143,15 @@ export async function createPlayer(input: PlayerInput, ctx: ActionContext) {
return created; return created;
}); });
// Wartende Forums-Anträge unter diesem Namen gleich mit übernehmen, sonst
// blieben sie bis zum nächsten vollen Abgleich in der Warteliste liegen.
if (created.forumName) {
const { claimThreadsForPlayer } = await import('./forum.js');
await claimThreadsForPlayer(created.id, [created.forumName]);
}
return created;
} }
export async function updatePlayer(playerId: number, input: Partial<PlayerInput>, ctx: ActionContext) { export async function updatePlayer(playerId: number, input: Partial<PlayerInput>, ctx: ActionContext) {
@@ -152,7 +161,7 @@ export async function updatePlayer(playerId: number, input: Partial<PlayerInput>
const steamId = input.steamId === undefined ? undefined : canonicalSteamId(input.steamId); const steamId = input.steamId === undefined ? undefined : canonicalSteamId(input.steamId);
if (steamId) await assertSteamIdFree(steamId, playerId); if (steamId) await assertSteamIdFree(steamId, playerId);
return prisma.$transaction(async (tx) => { const updated = await prisma.$transaction(async (tx) => {
const updated = await tx.player.update({ const updated = await tx.player.update({
where: { id: playerId }, where: { id: playerId },
data: { data: {
@@ -172,6 +181,13 @@ export async function updatePlayer(playerId: number, input: Partial<PlayerInput>
return updated; return updated;
}); });
if (updated.forumName && updated.forumName !== existing.forumName) {
const { claimThreadsForPlayer } = await import('./forum.js');
await claimThreadsForPlayer(playerId, [updated.forumName]);
}
return updated;
} }
/** /**
@@ -282,6 +298,11 @@ export async function addAlias(
details: { type, value, note: note ?? null }, details: { type, value, note: note ?? null },
}); });
if (type === AliasType.FORUM_NAME) {
const { claimThreadsForPlayer } = await import('./forum.js');
await claimThreadsForPlayer(playerId, [value]);
}
return created; return created;
} }
+5
View File
@@ -5,6 +5,7 @@ import { badRequest } from '../errors.js';
import { import {
classifyPackage, classifyPackage,
fetchProfile, fetchProfile,
resetRequestPace,
type ScrapedProfile, type ScrapedProfile,
} from '../lib/gextension.js'; } from '../lib/gextension.js';
import { PLANS } from './imports.js'; import { PLANS } from './imports.js';
@@ -81,6 +82,10 @@ export async function scrapePlayers(
const rows: Prisma.ImportRowCreateManyImportInput[] = []; const rows: Prisma.ImportRowCreateManyImportInput[] = [];
let lineNo = 0; let lineNo = 0;
// Jeder Abgleich fängt mit dem normalen Tempo an; gedrosselt wird erst,
// wenn GExtension es verlangt.
resetRequestPace();
// Pakete, die bereits in einer noch nicht bestätigten Vorschau liegen. Ohne // Pakete, die bereits in einer noch nicht bestätigten Vorschau liegen. Ohne
// diese Prüfung landet dasselbe Paket bei jedem Abgleich in einem weiteren // diese Prüfung landet dasselbe Paket bei jedem Abgleich in einem weiteren
// Import, und beim Buchen kollidieren sie miteinander. // Import, und beim Buchen kollidieren sie miteinander.
+198
View File
@@ -0,0 +1,198 @@
import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import { startTestDatabase, type TestDatabase } from './harness.js';
type Forum = typeof import('../src/services/forum.js');
type Players = typeof import('../src/services/players.js');
type Characters = typeof import('../src/services/characters.js');
type Db = typeof import('../src/db.js');
let database: TestDatabase;
let forum: Forum;
let players: Players;
let characters: Characters;
let db: Db;
let ctx: { actorId: number; actorName: string };
before(async () => {
database = await startTestDatabase(54337);
process.env.DATABASE_URL = database.url;
process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000';
process.env.NODE_ENV = 'test';
db = await import('../src/db.js');
forum = await import('../src/services/forum.js');
players = await import('../src/services/players.js');
characters = await import('../src/services/characters.js');
const user = await db.prisma.user.create({
data: { username: 'pfleger', passwordHash: 'x', role: 'ADMIN' },
});
ctx = { actorId: user.id, actorName: user.username };
});
after(async () => {
await db.prisma.$disconnect();
await database.stop();
});
/** Legt einen Thread an, wie ihn der Abgleich ohne Spieler vorfinden würde. */
async function offenerThread(threadId: number, titel: string, autor: string) {
return db.prisma.forumThread.create({
data: {
threadId,
title: titel,
url: `https://forum.example.org/index.php?thread/${threadId}-antrag/`,
authorName: autor,
currentBoard: 'CC_ARCHIV',
},
});
}
describe('Anträge ohne zugeordneten Spieler', () => {
it('hält sie aus der Arbeitsliste heraus, aber nicht aus der Datenbank', async () => {
await offenerThread(9001, 'CC Antrag von einem Fremden', 'Unbekannter');
const arbeitsliste = await forum.listThreads({ onlyOpen: true });
assert.equal(
arbeitsliste.some((t) => t.threadId === 9001),
false,
'ohne Spieler ist nichts zu entscheiden',
);
const wartend = await forum.listUnassignedThreads({});
assert.equal(
wartend.items.some((t) => t.threadId === 9001),
true,
'auffindbar bleibt er trotzdem',
);
});
it('findet sie über Titel und Ersteller', async () => {
await offenerThread(9002, 'Feldagent Ersthelfer', 'Darkbrine44');
const nachTitel = await forum.listUnassignedThreads({ query: 'ersthelfer' });
assert.deepEqual(
nachTitel.items.map((t) => t.threadId),
[9002],
'Grossschreibung darf keine Rolle spielen',
);
const nachAutor = await forum.listUnassignedThreads({ query: 'darkbrine' });
assert.deepEqual(
nachAutor.items.map((t) => t.threadId),
[9002],
);
});
it('begrenzt die Ausgabe, nennt aber die volle Zahl', async () => {
for (const id of [9010, 9011, 9012]) await offenerThread(id, `Antrag ${id}`, 'Serienbeantrager');
const seite = await forum.listUnassignedThreads({ query: 'Serienbeantrager', take: 2 });
assert.equal(seite.items.length, 2);
assert.equal(seite.total, 3, 'sonst wüsste niemand, dass etwas fehlt');
});
});
describe('Nachträgliche Zuordnung', () => {
it('zieht wartende Anträge an einen neu angelegten Spieler', async () => {
await offenerThread(9101, 'CC Antrag von Monox', 'Monox');
await offenerThread(9102, 'Redesign von Monox', 'monox');
const spieler = await players.createPlayer(
{ displayName: 'Monox', steamId: '76561198000002222', forumName: 'Monox' },
ctx,
);
const zugeordnet = await forum.listThreads({ playerId: spieler.id });
assert.deepEqual(
zugeordnet.map((t) => t.threadId).sort(),
[9101, 9102],
'auch die abweichende Schreibweise gehört dazu',
);
});
it('zieht sie auch nach, wenn der Forum-Name erst später eingetragen wird', async () => {
await offenerThread(9201, 'Antrag eines Umbenannten', 'NeuerName');
const spieler = await players.createPlayer(
{ displayName: 'Ohne Forum', steamId: '76561198000003333' },
ctx,
);
assert.equal((await forum.listThreads({ playerId: spieler.id })).length, 0);
await players.updatePlayer(spieler.id, { forumName: 'NeuerName' }, ctx);
const zugeordnet = await forum.listThreads({ playerId: spieler.id });
assert.deepEqual(
zugeordnet.map((t) => t.threadId),
[9201],
);
});
it('holt über ein früheres Konto auch die alten Anträge', async () => {
await offenerThread(9301, 'Antrag unter dem alten Namen', 'AltesKonto');
const spieler = await players.createPlayer(
{ displayName: 'Umzügler', steamId: '76561198000004444', forumName: 'NeuesKonto' },
ctx,
);
await players.addAlias(spieler.id, 'FORUM_NAME', 'AltesKonto', 'Vor dem Wechsel', ctx);
const zugeordnet = await forum.listThreads({ playerId: spieler.id });
assert.deepEqual(
zugeordnet.map((t) => t.threadId),
[9301],
);
});
it('nimmt einem Spieler nie einen bereits zugeordneten Antrag weg', async () => {
const erster = await players.createPlayer(
{ displayName: 'Erster', steamId: '76561198000005555', forumName: 'Doppelgänger' },
ctx,
);
await offenerThread(9401, 'Strittiger Antrag', 'Doppelgänger');
await forum.assignPlayer(9401, erster.id, ctx);
// Ein zweiter Spieler trägt denselben Namen als früheres Konto nach.
const zweiter = await players.createPlayer(
{ displayName: 'Zweiter', steamId: '76561198000006666' },
ctx,
);
await players.updatePlayer(zweiter.id, { forumName: 'Doppelgänger' }, ctx);
const thread = await db.prisma.forumThread.findUniqueOrThrow({ where: { threadId: 9401 } });
assert.equal(thread.playerId, erster.id, 'eine getroffene Entscheidung bleibt stehen');
});
});
describe('Antrag am CC', () => {
it('hängt den Thread an den CC, der aus ihm hervorgeht', async () => {
const spieler = await players.createPlayer(
{ displayName: 'Antragsteller', steamId: '76561198000007777', forumName: 'Antragsteller' },
ctx,
);
await db.prisma.creditLedger.create({
data: { playerId: spieler.id, kind: 'CC', amount: 1, source: 'CC_PACKAGE' },
});
await offenerThread(9501, 'The Duke', 'Antragsteller');
await forum.claimThreadsForPlayer(spieler.id, ['Antragsteller']);
const { characterId } = await forum.classifyThread(
9501,
{ classification: 'NEW_CC', characterName: 'The Duke' },
ctx,
);
assert.ok(characterId);
const liste = await characters.listCharacters(spieler.id);
const cc = liste.find((c) => c.id === characterId);
assert.deepEqual(
cc?.forumThreads.map((t) => t.threadId),
[9501],
'ohne das bliebe der Name auf der Spielerseite ein toter Text',
);
assert.match(cc?.forumThreads[0]?.url ?? '', /thread\/9501/);
});
});
+19
View File
@@ -68,6 +68,25 @@
</li> </li>
</ul> </ul>
</li> </li>
<li class="tabularListRow">
<ul class="tabularListColumns">
<li class="columnIcon columnAvatar">
<p><img src="avatar-default.png" class="userAvatarImage" alt=""></p>
</li>
<li class="columnSubject">
<h3>
<a href="https://forum.example.org/index.php?thread/7006-antrag-eines-geloeschten-kontos/" class="messageGroupLink wbbTopicLink" data-object-id="7006">Antrag eines geloeschten Kontos</a>
</h3>
<ul class="inlineList dotSeparated small messageGroupInfo">
<li class="messageGroupAuthor"><span>Ehemaliger</span></li>
<li class="messageGroupTime"><woltlab-core-date-time date="2020-01-06T19:10:45+00:00">6. Januar 2020</woltlab-core-date-time></li>
</ul>
<ul class="messageGroupInfoMobile">
<li class="messageGroupAuthorMobile">Ehemaliger</li>
</ul>
</li>
</ul>
</li>
</ol> </ol>
<woltlab-core-pagination page="1" count="25" url="https://forum.example.org/index.php?board/66-custom-characters/"></woltlab-core-pagination> <woltlab-core-pagination page="1" count="25" url="https://forum.example.org/index.php?board/66-custom-characters/"></woltlab-core-pagination>
+41 -2
View File
@@ -4,6 +4,7 @@ import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { describe, it } from 'node:test'; import { describe, it } from 'node:test';
import { hasIgnoredLabel, parseBoard, parsePageCount, parseThreadId } from '../src/lib/forum.js'; import { hasIgnoredLabel, parseBoard, parsePageCount, parseThreadId } from '../src/lib/forum.js';
import { isRateLimited, retryAfterMs } from '../src/lib/ratelimit.js';
const here = dirname(fileURLToPath(import.meta.url)); const here = dirname(fileURLToPath(import.meta.url));
const BOARD = readFileSync(join(here, 'fixtures', 'board.html'), 'utf8'); const BOARD = readFileSync(join(here, 'fixtures', 'board.html'), 'utf8');
@@ -12,13 +13,22 @@ describe('Board-Übersicht lesen', () => {
const threads = parseBoard(BOARD); const threads = parseBoard(BOARD);
it('liest die Threads der Liste', () => { it('liest die Threads der Liste', () => {
assert.equal(threads.length, 5); assert.equal(threads.length, 6);
assert.deepEqual( assert.deepEqual(
threads.map((t) => t.threadId), threads.map((t) => t.threadId),
[7001, 7002, 7004, 7005, 7003], [7001, 7002, 7004, 7005, 7003, 7006],
); );
}); });
it('liest den Ersteller auch ohne Profil-Link', () => {
// Wer sein Konto geloescht hat, steht als blosser Text in der Zelle. Ohne
// Rueckfall haette der Thread keinen Autor und liesse sich niemandem
// zuordnen - er waere im Abgleich unsichtbar.
const ohneKonto = threads.find((t) => t.threadId === 7006);
assert.equal(ohneKonto?.authorName, 'Ehemaliger');
assert.equal(ohneKonto?.authorForumId, null);
});
it('nimmt den Ersteller aus der Übersicht, nicht den letzten Antwortenden', () => { it('nimmt den Ersteller aus der Übersicht, nicht den letzten Antwortenden', () => {
const first = threads.find((t) => t.threadId === 7001); const first = threads.find((t) => t.threadId === 7001);
assert.equal(first?.authorName, 'Antragsteller'); assert.equal(first?.authorName, 'Antragsteller');
@@ -99,3 +109,32 @@ describe('Thread-Verweis lesen', () => {
assert.equal(parseThreadId('kein link'), null); assert.equal(parseThreadId('kein link'), null);
}); });
}); });
describe('Ruecksicht auf einen bremsenden Server', () => {
it('erkennt die Abweisungen, die "spaeter nochmal" bedeuten', () => {
assert.equal(isRateLimited(429), true);
assert.equal(isRateLimited(503), true);
assert.equal(isRateLimited(404), false, 'eine fehlende Seite kommt auch spaeter nicht');
assert.equal(isRateLimited(200), false);
});
it('haelt sich an die vom Forum genannte Wartezeit', () => {
assert.equal(retryAfterMs('30', 0), 30000);
});
it('deckelt eine unverschaemt lange Angabe', () => {
assert.equal(retryAfterMs('86400', 0), 60000);
});
it('wartet ohne Angabe mit jedem Versuch laenger', () => {
assert.equal(retryAfterMs(null, 0), 5000);
assert.equal(retryAfterMs(null, 1), 10000);
assert.equal(retryAfterMs(null, 2), 15000);
});
it('ignoriert eine unbrauchbare Angabe', () => {
// WoltLab darf hier auch ein Datum schicken - dann greift die Staffelung.
assert.equal(retryAfterMs('Wed, 21 Oct 2026 07:28:00 GMT', 1), 10000);
assert.equal(retryAfterMs('-5', 0), 5000);
});
});
+3 -3
View File
@@ -68,9 +68,9 @@ export const BOARD_LABEL: Record<string, string> = {
}; };
export const CLASSIFICATION_LABEL: Record<string, string> = { export const CLASSIFICATION_LABEL: Record<string, string> = {
NEW_CC: 'Angenommen – neuer CC', NEW_CC: 'Angenommen - neuer CC',
REDESIGN: 'Angenommen – Redesign', REDESIGN: 'Angenommen - Redesign',
REACTIVATION: 'Angenommen – Reaktivierung', REACTIVATION: 'Angenommen - Reaktivierung',
REJECTED: 'Abgelehnt', REJECTED: 'Abgelehnt',
IGNORE: 'Ignoriert', IGNORE: 'Ignoriert',
}; };
+26 -2
View File
@@ -90,6 +90,13 @@ export interface CharacterRef {
name: string; name: string;
} }
/// Der Forums-Antrag, aus dem ein CC hervorgegangen ist.
export interface ThreadRef {
threadId: number;
title: string;
url: string;
}
export interface CharacterMember { export interface CharacterMember {
id: number; id: number;
characterId: number; characterId: number;
@@ -113,6 +120,7 @@ export interface Character {
predecessorId: number | null; predecessorId: number | null;
predecessor: CharacterRef | null; predecessor: CharacterRef | null;
successors: CharacterRef[]; successors: CharacterRef[];
forumThreads?: ThreadRef[];
members?: CharacterMember[]; members?: CharacterMember[];
createdAt: string; createdAt: string;
} }
@@ -257,7 +265,23 @@ export interface ForumScanSummary {
boardChangeNotices: number; boardChangeNotices: number;
skippedStubs: number; skippedStubs: number;
skippedLabels: number; skippedLabels: number;
skippedUnknownAuthor: number; unassignedThreads: number;
rateLimited: number;
matchedPlayers: number; matchedPlayers: number;
failedBoards: { board: string; reason: string }[]; failedBoards: { board: string; reason: string; pagesRead: number }[];
}
export interface UnassignedThreads {
items: ForumThread[];
total: number;
}
export interface ScanState {
running: boolean;
startedAt: string | null;
finishedAt: string | null;
currentBoard: string | null;
pagesRead: number;
summary: ForumScanSummary | null;
error: string | null;
} }
+190 -20
View File
@@ -12,8 +12,17 @@ import type {
ForumThread, ForumThread,
PlayerDetail, PlayerDetail,
PlayerSummary, PlayerSummary,
ScanState,
UnassignedThreads,
} from '../lib/types'; } from '../lib/types';
/// Die Board-Pfade des Abgleichs in lesbarer Form.
const BOARD_PFAD: Record<string, string> = {
'board/66-custom-characters': 'die Antragsübersicht',
'board/355-aktive-ccs': 'die aktiven CCs',
'board/113-cc-archiv': 'das Archiv',
};
export default function Forum() { export default function Forum() {
const { can } = useSession(); const { can } = useSession();
const darfEntscheiden = can('EDITOR'); const darfEntscheiden = can('EDITOR');
@@ -25,6 +34,21 @@ export default function Forum() {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [dialog, setDialog] = useState<ForumThread | null>(null); const [dialog, setDialog] = useState<ForumThread | null>(null);
const [assigning, setAssigning] = useState<ForumThread | null>(null); const [assigning, setAssigning] = useState<ForumThread | null>(null);
const [ohneSpieler, setOhneSpieler] = useState<UnassignedThreads | null>(null);
const [suche, setSuche] = useState('');
const [zeigeOhneSpieler, setZeigeOhneSpieler] = useState(false);
/// Hochzählen heißt: die Liste ohne Spieler neu holen.
const [stand, setStand] = useState(0);
const [scanZustand, setScan] = useState<ScanState | null>(null);
const ladeOhneSpieler = useCallback(async (query: string) => {
const param = query.trim() ? `?q=${encodeURIComponent(query.trim())}` : '';
try {
setOhneSpieler(await api<UnassignedThreads>(`/api/forum/threads/unassigned${param}`));
} catch (err) {
setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
}
}, []);
const load = useCallback(async () => { const load = useCallback(async () => {
try { try {
@@ -38,33 +62,88 @@ export default function Forum() {
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen'); setError(err instanceof ApiError ? err.message : 'Laden fehlgeschlagen');
} }
setStand((n) => n + 1);
}, []); }, []);
useEffect(() => { useEffect(() => {
void load(); void load();
}, [load]); }, [load]);
// Beim Tippen kurz abwarten, statt jede Taste an den Server zu schicken.
useEffect(() => {
const timer = window.setTimeout(() => void ladeOhneSpieler(suche), suche ? 250 : 0);
return () => window.clearTimeout(timer);
}, [suche, stand, ladeOhneSpieler]);
const bericht = (result: ForumScanSummary): string => {
const parts = [`${result.scannedPages} Seiten gelesen`];
if (result.newThreads > 0) parts.push(`${result.newThreads} neue Anträge`);
if (result.movedThreads > 0) parts.push(`${result.movedThreads} verschoben`);
if (result.unassignedThreads > 0) {
parts.push(`${result.unassignedThreads} ohne zugeordneten Spieler`);
}
if (result.skippedLabels > 0) parts.push(`${result.skippedLabels} Ankündigungen`);
if (result.skippedStubs > 0) parts.push(`${result.skippedStubs} Verweise`);
if (result.rateLimited > 0) parts.push(`${result.rateLimited} Mal ausgebremst`);
if (result.failedBoards.length > 0) {
parts.push(
result.failedBoards
.map((b) => `${b.board} nur bis Seite ${b.pagesRead} (${b.reason})`)
.join('; '),
);
}
return parts.join(', ') + '.';
};
// Der Lauf läuft im Hintergrund weiter, auch wenn die Seite neu geladen
// wird. Deshalb wird der Stand abgefragt, statt auf eine Antwort zu warten.
useEffect(() => {
if (!busy) return;
const timer = window.setInterval(() => {
void (async () => {
try {
const state = await api<ScanState>('/api/forum/scan/status');
setScan(state);
if (state.running) return;
setBusy(false);
if (state.error) setError(state.error);
else if (state.summary) {
setFlash(bericht(state.summary));
window.setTimeout(() => setFlash(null), 12000);
}
await load();
} catch (err) {
setBusy(false);
setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen');
}
})();
}, 2000);
return () => window.clearInterval(timer);
}, [busy, load]);
// Ein Lauf aus einer anderen Sitzung soll hier ebenfalls sichtbar sein.
useEffect(() => {
void (async () => {
try {
const state = await api<ScanState>('/api/forum/scan/status');
setScan(state);
if (state.running) setBusy(true);
} catch {
// Ohne Stand bleibt der Knopf einfach bedienbar.
}
})();
}, []);
const scan = async () => { const scan = async () => {
setBusy(true);
setError(null); setError(null);
try { try {
const result = await api<ForumScanSummary>('/api/forum/scan', { method: 'POST', body: {} }); setScan(await api<ScanState>('/api/forum/scan', { method: 'POST', body: {} }));
const parts = [`${result.scannedPages} Seiten gelesen`]; setBusy(true);
if (result.newThreads > 0) parts.push(`${result.newThreads} neue Anträge`);
if (result.movedThreads > 0) parts.push(`${result.movedThreads} verschoben`);
if (result.skippedUnknownAuthor > 0) {
parts.push(`${result.skippedUnknownAuthor} ohne bekannten Spieler übersprungen`);
}
if (result.skippedLabels > 0) parts.push(`${result.skippedLabels} Ankündigungen`);
if (result.skippedStubs > 0) parts.push(`${result.skippedStubs} Verweise`);
if (result.failedBoards.length > 0) parts.push(`${result.failedBoards.length} Board-Fehler`);
setFlash(parts.join(', ') + '.');
await load();
window.setTimeout(() => setFlash(null), 8000);
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen'); setError(err instanceof ApiError ? err.message : 'Abgleich fehlgeschlagen');
} finally {
setBusy(false);
} }
}; };
@@ -90,7 +169,9 @@ export default function Forum() {
</div> </div>
{darfEntscheiden && ( {darfEntscheiden && (
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void scan()}> <button type="button" className="btn btn-primary" disabled={busy} onClick={() => void scan()}>
{busy ? 'Lese Forum …' : 'Forum abgleichen'} {busy
? `Lese Forum … (${scanZustand?.pagesRead ?? 0} Seiten)`
: 'Forum abgleichen'}
</button> </button>
)} )}
</div> </div>
@@ -98,6 +179,15 @@ export default function Forum() {
{error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>} {error && <Notice kind="error" onDismiss={() => setError(null)}>{error}</Notice>}
{flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>} {flash && <Notice kind="success" onDismiss={() => setFlash(null)}>{flash}</Notice>}
{busy && (
<Notice kind="warn">
Der Abgleich liest gerade {BOARD_PFAD[scanZustand?.currentBoard ?? ''] ?? 'das Forum'} -
bisher {scanZustand?.pagesRead ?? 0} Seiten. Das Archiv umfasst über zwanzig Seiten, und
zwischen den Abrufen wird bewusst gewartet, damit das Forum den Zugriff nicht bremst. Der
Lauf geht im Hintergrund weiter, auch wenn diese Seite geschlossen wird.
</Notice>
)}
{notices.length > 0 && ( {notices.length > 0 && (
<section className="block block-flag"> <section className="block block-flag">
<h2>Verschoben nach der Entscheidung ({notices.length})</h2> <h2>Verschoben nach der Entscheidung ({notices.length})</h2>
@@ -191,6 +281,86 @@ export default function Forum() {
)} )}
</section> </section>
<section className="block">
<div className="page-head">
<div>
<h2>Ohne zugeordneten Spieler ({ohneSpieler?.total ?? 0})</h2>
<p className="muted small">
Anträge, deren Ersteller hier unter keinem Forum-Namen geführt wird. Sie bleiben
aus der Arbeitsliste heraus, gehen aber nicht verloren. Sobald ein Spieler mit
passendem Forum-Namen angelegt wird, wandern sie von selbst hinüber.
</p>
</div>
<button
type="button"
className="btn btn-small btn-ghost"
onClick={() => setZeigeOhneSpieler((v) => !v)}
>
{zeigeOhneSpieler ? 'Einklappen' : 'Anzeigen'}
</button>
</div>
{zeigeOhneSpieler && (
<>
<input
type="search"
className="input"
placeholder="Nach Titel oder Ersteller suchen …"
value={suche}
onChange={(event) => setSuche(event.target.value)}
/>
{ohneSpieler === null || ohneSpieler.items.length === 0 ? (
<p className="muted" style={{ marginTop: '0.75rem' }}>
{suche ? 'Nichts gefunden.' : 'Alle erfassten Anträge sind zugeordnet.'}
</p>
) : (
<>
<ul className="cc-list" style={{ marginTop: '0.75rem' }}>
{ohneSpieler.items.map((thread) => (
<li key={thread.threadId} className="cc-item">
<div>
<a
href={thread.url}
target="_blank"
rel="noreferrer"
className="link-strong"
title="Im Forum öffnen"
>
{thread.title}
</a>
<span className="badge">{BOARD_LABEL[thread.currentBoard]}</span>
<p className="muted small">
von {thread.authorName ?? 'unbekannt'}
{thread.labels && ` · ${thread.labels}`}
</p>
</div>
{darfEntscheiden && (
<div className="cc-actions">
<button
type="button"
className="btn btn-small"
onClick={() => setAssigning(thread)}
>
Spieler zuordnen
</button>
</div>
)}
</li>
))}
</ul>
{ohneSpieler.total > ohneSpieler.items.length && (
<p className="muted small">
Zeigt {ohneSpieler.items.length} von {ohneSpieler.total} - über die Suche
eingrenzen.
</p>
)}
</>
)}
</>
)}
</section>
{dialog && ( {dialog && (
<ClassifyModal <ClassifyModal
thread={dialog} thread={dialog}
@@ -296,9 +466,9 @@ function ClassifyModal({
<div className="radio-group"> <div className="radio-group">
{( {(
[ [
['NEW_CC', 'Angenommen – neuer CC', 'verbraucht 1 CC-Guthaben'], ['NEW_CC', 'Angenommen - neuer CC', 'verbraucht 1 CC-Guthaben'],
['REDESIGN', 'Angenommen – Redesign', 'verbraucht 1 Redesign-Guthaben'], ['REDESIGN', 'Angenommen - Redesign', 'verbraucht 1 Redesign-Guthaben'],
['REACTIVATION', 'Angenommen – Reaktivierung', 'ein inaktiver CC kehrt zurück'], ['REACTIVATION', 'Angenommen - Reaktivierung', 'ein inaktiver CC kehrt zurück'],
['REJECTED', 'Abgelehnt', 'keine Buchung'], ['REJECTED', 'Abgelehnt', 'keine Buchung'],
['IGNORE', 'Kein Antrag', 'keine Buchung'], ['IGNORE', 'Kein Antrag', 'keine Buchung'],
] as const ] as const
+23 -2
View File
@@ -26,6 +26,27 @@ import type {
type DeactivateReason = 'PLAYER_INACTIVITY' | 'OTHER' | 'NO_FREE_CLAIM' | 'DEAD'; type DeactivateReason = 'PLAYER_INACTIVITY' | 'OTHER' | 'NO_FREE_CLAIM' | 'DEAD';
/**
* Der Name eines CCs. Hängt ein Forums-Antrag daran, führt ein Klick direkt
* dorthin - den Thread sonst wiederzufinden kostet jedes Mal Sucherei.
*/
function CharacterName({ character }: { character: Character }) {
const thread = character.forumThreads?.[0];
if (!thread) return <strong>{character.name}</strong>;
return (
<a
href={thread.url}
target="_blank"
rel="noreferrer"
className="link-strong"
title={`Antrag im Forum öffnen: ${thread.title}`}
>
{character.name}
</a>
);
}
type DialogState = type DialogState =
| { type: 'none' } | { type: 'none' }
| { type: 'newCharacter' } | { type: 'newCharacter' }
@@ -247,7 +268,7 @@ export default function PlayerDetail() {
{active.map((character) => ( {active.map((character) => (
<li key={character.id} className="cc-item"> <li key={character.id} className="cc-item">
<div> <div>
<strong>{character.name}</strong> <CharacterName character={character} />
{character.members && character.members.filter((m) => !m.leftAt).length > 1 && ( {character.members && character.members.filter((m) => !m.leftAt).length > 1 && (
<span className="badge badge-new"> <span className="badge badge-new">
Gruppen-CC · {character.members.filter((m) => !m.leftAt).length} Gruppen-CC · {character.members.filter((m) => !m.leftAt).length}
@@ -316,7 +337,7 @@ export default function PlayerDetail() {
return ( return (
<li key={character.id} className="cc-item is-inactive"> <li key={character.id} className="cc-item is-inactive">
<div> <div>
<strong>{character.name}</strong> <CharacterName character={character} />
<span <span
className={`badge ${ className={`badge ${
art === 'gratis' art === 'gratis'