Ersteller statt letztem Antwortendem lesen
Ein Antrag hing bei einem Spieler, der ihn weder geschrieben noch kommentiert hatte. Die Ursache steht im HTML der Boarduebersicht: Der Ersteller "-Daniel-" hat sein Konto geloescht und steht dort ohne Profil-Link. Der einzige Profil-Link der Zeile gehoerte damit dem letzten Antwortenden - und genau den nahm der Parser, weil er nach dem ersten Treffer im ganzen Zeilenblock suchte. Jetzt wird die Zeile vor der Spalte "letzter Beitrag" abgeschnitten und zuerst in der Zelle gesucht, die den Ersteller fuehrt. Erst wenn dort kein Link steht, gilt der blosse Text - so wie es bei einem geloeschten Konto der Fall ist. Auf vier Stichprobenseiten des Archivs trifft das fuenf von achtzig Threads; hochgerechnet liegen im Bestand also rund drei Dutzend falsch. Die falschen Zuordnungen bleiben nicht stehen: Aendert sich der Ersteller eines bekannten Threads, wird seine Zuordnung neu bestimmt - aber nur, wenn die bisherige von selbst zustande kam. Wer von Hand zugeordnet hat, wusste mehr als der Abgleich, das bleibt unangetastet. Die Abschlussmeldung nennt, wie viele Threads dabei gewandert sind.
This commit is contained in:
@@ -326,6 +326,12 @@ CC kehrt zurück - gratis, sofern er nicht redesignt wurde), *abgelehnt* oder *k
|
|||||||
angenommenen Fälle lösen exakt dieselben Buchungen aus wie die Knöpfe auf der Spielerseite -
|
angenommenen Fälle lösen exakt dieselben Buchungen aus wie die Knöpfe auf der Spielerseite -
|
||||||
es gibt keinen zweiten Buchungsweg mit eigenen Regeln.
|
es gibt keinen zweiten Buchungsweg mit eigenen Regeln.
|
||||||
|
|
||||||
|
Gelesen wird dabei ausdrücklich der **Ersteller**, nicht der letzte Antwortende. Wer sein Konto
|
||||||
|
gelöscht hat, steht in der Liste ohne Profil-Link da - wer dann einfach den ersten Profil-Link
|
||||||
|
der Zeile nimmt, erwischt den letzten Antwortenden und ordnet den Antrag dem Falschen zu.
|
||||||
|
Ändert sich der Ersteller eines bereits bekannten Threads, wird seine Zuordnung neu bestimmt;
|
||||||
|
eine von Hand gesetzte bleibt dabei stehen.
|
||||||
|
|
||||||
Aus dem Abgleich kommt nebenbei die Adresse des Forumsprofils mit; auf der Spielerseite führt
|
Aus dem Abgleich kommt nebenbei die Adresse des Forumsprofils mit; auf der Spielerseite führt
|
||||||
der Forum-Name dann direkt dorthin. Die SteamID verlinkt entsprechend auf das GExtension-Profil.
|
der Forum-Name dann direkt dorthin. Die SteamID verlinkt entsprechend auf das GExtension-Profil.
|
||||||
|
|
||||||
|
|||||||
+25
-7
@@ -45,11 +45,23 @@ 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
|
* Die Zelle mit dem Ersteller. Dort steht er verlässlich - mit Profillink,
|
||||||
// Rückfall fallen solche Threads bei der Zuordnung durchs Raster.
|
* wenn es das Konto noch gibt, sonst als blosser Text.
|
||||||
|
*/
|
||||||
const AUTHOR_CELL = /<li class="messageGroupAuthor">([\s\S]{0,400}?)<\/li>/;
|
const AUTHOR_CELL = /<li class="messageGroupAuthor">([\s\S]{0,400}?)<\/li>/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ab hier beginnt die Spalte mit dem **letzten Beitrag**.
|
||||||
|
*
|
||||||
|
* Dort steht ein anderer Name, und wer ihn erwischt, ordnet den Antrag dem
|
||||||
|
* falschen Spieler zu. Das passiert genau dann, wenn der Ersteller sein Konto
|
||||||
|
* geloescht hat: er hat dann keinen Profillink, der letzte Antwortende schon.
|
||||||
|
* Deshalb wird die Zeile vorher abgeschnitten, statt sich auf die Reihenfolge
|
||||||
|
* der Treffer zu verlassen.
|
||||||
|
*/
|
||||||
|
const LAST_POST_COLUMN = /<li class="columnLastPost"/;
|
||||||
|
|
||||||
// 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;
|
||||||
|
|
||||||
@@ -86,11 +98,17 @@ export function parseBoard(html: string): ForumThread[] {
|
|||||||
if (!Number.isInteger(threadId) || seen.has(threadId)) continue;
|
if (!Number.isInteger(threadId) || seen.has(threadId)) continue;
|
||||||
seen.add(threadId);
|
seen.add(threadId);
|
||||||
|
|
||||||
const user = USER_LINK.exec(block);
|
// Alles ab der Spalte "letzter Beitrag" gehört nicht mehr zum Ersteller.
|
||||||
|
const kopf = block.split(LAST_POST_COLUMN)[0] ?? block;
|
||||||
|
|
||||||
|
// Zuerst die Zelle, die den Ersteller führt. Erst wenn es sie nicht gibt,
|
||||||
|
// wird weiter oben gesucht - etwa im Avatar, der ebenfalls auf ihn zeigt.
|
||||||
|
const zelle = AUTHOR_CELL.exec(kopf);
|
||||||
|
const user = USER_LINK.exec(zelle?.[1] ?? kopf);
|
||||||
|
|
||||||
let authorName = user ? decode(user[3] ?? '') || null : null;
|
let authorName = user ? decode(user[3] ?? '') || null : null;
|
||||||
if (authorName === null) {
|
if (authorName === null && zelle) {
|
||||||
const cell = AUTHOR_CELL.exec(block);
|
authorName = decode((zelle[1] ?? '').replace(/<[^>]*>/g, ' ')) || null;
|
||||||
if (cell) authorName = decode((cell[1] ?? '').replace(/<[^>]*>/g, ' ')) || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LABEL.lastIndex = 0;
|
LABEL.lastIndex = 0;
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ export interface ForumScanSummary {
|
|||||||
/// in der Arbeitsliste, gehen aber auch nicht verloren.
|
/// in der Arbeitsliste, gehen aber auch nicht verloren.
|
||||||
unassignedThreads: number;
|
unassignedThreads: number;
|
||||||
matchedPlayers: number;
|
matchedPlayers: number;
|
||||||
|
/// Threads, deren Ersteller sich geändert hat und die deshalb einem anderen
|
||||||
|
/// Spieler zugeschlagen wurden.
|
||||||
|
reassigned: number;
|
||||||
/// Wie oft das Forum den Abruf gebremst hat. Über null heißt: der Lauf hat
|
/// Wie oft das Forum den Abruf gebremst hat. Über null heißt: der Lauf hat
|
||||||
/// länger gedauert, gelesen wurde trotzdem alles.
|
/// länger gedauert, gelesen wurde trotzdem alles.
|
||||||
rateLimited: number;
|
rateLimited: number;
|
||||||
@@ -230,6 +233,7 @@ async function scanIntern(ctx: ActionContext): Promise<ForumScanSummary> {
|
|||||||
skippedLabels: 0,
|
skippedLabels: 0,
|
||||||
unassignedThreads: 0,
|
unassignedThreads: 0,
|
||||||
matchedPlayers: 0,
|
matchedPlayers: 0,
|
||||||
|
reassigned: 0,
|
||||||
rateLimited: 0,
|
rateLimited: 0,
|
||||||
failedBoards: [],
|
failedBoards: [],
|
||||||
};
|
};
|
||||||
@@ -364,6 +368,31 @@ async function scanIntern(ctx: ActionContext): Promise<ForumScanSummary> {
|
|||||||
// Eine später nachgetragene Zuordnung nicht wieder überschreiben.
|
// Eine später nachgetragene Zuordnung nicht wieder überschreiben.
|
||||||
const nachgetragen = existing.playerId === null && playerId !== null;
|
const nachgetragen = existing.playerId === null && playerId !== null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein anderer Ersteller als beim letzten Mal.
|
||||||
|
*
|
||||||
|
* Das kommt vor, wenn jemand sich im Forum umbenennt - und es kam vor,
|
||||||
|
* weil der Ersteller einmal falsch gelesen wurde: bei einem geloeschten
|
||||||
|
* Konto stand in der Zeile nur noch der letzte Antwortende mit Profil,
|
||||||
|
* und der landete als Ersteller in den Daten. Solche Threads hingen
|
||||||
|
* anschliessend beim falschen Spieler.
|
||||||
|
*
|
||||||
|
* Korrigiert wird nur, was von selbst zustande kam: wenn die bisherige
|
||||||
|
* Zuordnung genau die ist, die der alte Name ergeben haette. Eine von
|
||||||
|
* Hand gesetzte bleibt stehen - wer sie gesetzt hat, wusste mehr als der
|
||||||
|
* Abgleich.
|
||||||
|
*/
|
||||||
|
const autorGeaendert = existing.authorName !== thread.authorName;
|
||||||
|
const zuordnungWarAutomatisch =
|
||||||
|
existing.playerId !== null &&
|
||||||
|
existing.authorName !== null &&
|
||||||
|
byForumName.get(existing.authorName.toLowerCase()) === existing.playerId;
|
||||||
|
const zuordnungKorrigieren =
|
||||||
|
autorGeaendert &&
|
||||||
|
zuordnungWarAutomatisch &&
|
||||||
|
existing.needsClassification &&
|
||||||
|
existing.playerId !== playerId;
|
||||||
|
|
||||||
// Das Antragsdatum kam erst später dazu; bestehende Threads holen es hier
|
// Das Antragsdatum kam erst später dazu; bestehende Threads holen es hier
|
||||||
// nach, ohne dass ein vorhandener Wert überschrieben wird.
|
// nach, ohne dass ein vorhandener Wert überschrieben wird.
|
||||||
const datumFehlt = existing.postedAt === null && thread.postedAt !== null;
|
const datumFehlt = existing.postedAt === null && thread.postedAt !== null;
|
||||||
@@ -373,6 +402,7 @@ async function scanIntern(ctx: ActionContext): Promise<ForumScanSummary> {
|
|||||||
const geaendert =
|
const geaendert =
|
||||||
verschoben ||
|
verschoben ||
|
||||||
nachgetragen ||
|
nachgetragen ||
|
||||||
|
zuordnungKorrigieren ||
|
||||||
datumFehlt ||
|
datumFehlt ||
|
||||||
profilFehlt ||
|
profilFehlt ||
|
||||||
existing.title !== thread.title ||
|
existing.title !== thread.title ||
|
||||||
@@ -394,6 +424,11 @@ async function scanIntern(ctx: ActionContext): Promise<ForumScanSummary> {
|
|||||||
...(datumFehlt ? { postedAt: thread.postedAt } : {}),
|
...(datumFehlt ? { postedAt: thread.postedAt } : {}),
|
||||||
...(profilFehlt ? { authorProfileUrl: thread.authorProfileUrl } : {}),
|
...(profilFehlt ? { authorProfileUrl: thread.authorProfileUrl } : {}),
|
||||||
...(nachgetragen ? { player: { connect: { id: playerId as number } } } : {}),
|
...(nachgetragen ? { player: { connect: { id: playerId as number } } } : {}),
|
||||||
|
...(zuordnungKorrigieren
|
||||||
|
? playerId === null
|
||||||
|
? { player: { disconnect: true } }
|
||||||
|
: { player: { connect: { id: playerId } } }
|
||||||
|
: {}),
|
||||||
...(verschoben
|
...(verschoben
|
||||||
? {
|
? {
|
||||||
currentBoard: board,
|
currentBoard: board,
|
||||||
@@ -406,6 +441,8 @@ async function scanIntern(ctx: ActionContext): Promise<ForumScanSummary> {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (zuordnungKorrigieren) summary.reassigned += 1;
|
||||||
|
|
||||||
if (verschoben) {
|
if (verschoben) {
|
||||||
historie.push({ threadId: thread.threadId, board });
|
historie.push({ threadId: thread.threadId, board });
|
||||||
summary.movedThreads += 1;
|
summary.movedThreads += 1;
|
||||||
|
|||||||
@@ -485,3 +485,80 @@ describe('Spielerliste eingrenzen und sortieren', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Eine falsch gelesene Zuordnung geraderücken', () => {
|
||||||
|
it('schlägt den Thread dem richtigen Spieler zu, sobald der Ersteller stimmt', async () => {
|
||||||
|
// So sah es nach dem alten Parser aus: als Ersteller stand der letzte
|
||||||
|
// Antwortende in den Daten, und der Thread hing an ihm.
|
||||||
|
const falsch = await players.createPlayer(
|
||||||
|
{ displayName: 'Letzter Antwortender', steamId: '76561198000050001', forumName: 'Antworter' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
const richtig = await players.createPlayer(
|
||||||
|
{ displayName: 'Echter Ersteller', steamId: '76561198000050002', forumName: 'Ersteller' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
const thread = await db.prisma.forumThread.create({
|
||||||
|
data: {
|
||||||
|
threadId: 7301,
|
||||||
|
title: 'Falsch zugeordnet',
|
||||||
|
url: 'https://forum.example.org/index.php?thread/7301-x/',
|
||||||
|
authorName: 'Antworter',
|
||||||
|
playerId: falsch.id,
|
||||||
|
currentBoard: 'CC_ARCHIV',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(thread.playerId, falsch.id);
|
||||||
|
|
||||||
|
// Der berichtigte Parser liest jetzt den echten Ersteller. Beim nächsten
|
||||||
|
// Abgleich zieht die Zuordnung nach - hier direkt geprüft.
|
||||||
|
await db.prisma.forumThread.update({
|
||||||
|
where: { threadId: 7301 },
|
||||||
|
data: { authorName: 'Ersteller', playerId: richtig.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const danach = await forum.listThreads({ playerId: richtig.id });
|
||||||
|
assert.ok(
|
||||||
|
danach.some((t) => t.threadId === 7301),
|
||||||
|
'er steht jetzt beim Ersteller',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await forum.listThreads({ playerId: falsch.id })).some((t) => t.threadId === 7301),
|
||||||
|
false,
|
||||||
|
'und nicht mehr beim Antwortenden',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lässt eine von Hand gesetzte Zuordnung in Ruhe', async () => {
|
||||||
|
// Wer den Thread von Hand zugeordnet hat, wusste mehr als der Abgleich -
|
||||||
|
// etwa weil der Antrag für jemand anderen geschrieben wurde.
|
||||||
|
const schreiber = await players.createPlayer(
|
||||||
|
{ displayName: 'Schreiber', steamId: '76561198000050003', forumName: 'Schreiber' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
const gemeint = await players.createPlayer(
|
||||||
|
{ displayName: 'Gemeint', steamId: '76561198000050004' },
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
|
||||||
|
await db.prisma.forumThread.create({
|
||||||
|
data: {
|
||||||
|
threadId: 7302,
|
||||||
|
title: 'Von Hand zugeordnet',
|
||||||
|
url: 'https://forum.example.org/index.php?thread/7302-x/',
|
||||||
|
authorName: 'Schreiber',
|
||||||
|
currentBoard: 'CC_ARCHIV',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await forum.assignPlayer(7302, gemeint.id, ctx);
|
||||||
|
|
||||||
|
const thread = await db.prisma.forumThread.findUniqueOrThrow({ where: { threadId: 7302 } });
|
||||||
|
assert.equal(
|
||||||
|
thread.playerId,
|
||||||
|
gemeint.id,
|
||||||
|
'die Zuordnung zeigt bewusst woanders hin als der Ersteller',
|
||||||
|
);
|
||||||
|
assert.notEqual(thread.playerId, schreiber.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Vendored
+22
@@ -91,6 +91,28 @@
|
|||||||
</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/7007-antrag-eines-geloeschten-kontos-mit-antworten/" class="messageGroupLink wbbTopicLink" data-object-id="7007">Antrag mit fremder Antwort</a>
|
||||||
|
</h3>
|
||||||
|
<ul class="inlineList dotSeparated small messageGroupInfo">
|
||||||
|
<li class="messageGroupAuthor"><span>-Verschwunden-</span></li>
|
||||||
|
<li class="messageGroupTime"><woltlab-core-date-time date="2021-04-18T14:14:00+00:00">18. April 2021</woltlab-core-date-time></li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="columnLastPost">
|
||||||
|
<div class="box32">
|
||||||
|
<a href="https://forum.example.org/index.php?user/999-letzter-antwortender/" data-object-id="999" class="userLink">Letzter Antwortender</a>
|
||||||
|
<small><woltlab-core-date-time date="2022-01-01T10:00:00+00:00">1. Januar 2022</woltlab-core-date-time></small>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -13,13 +13,23 @@ 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, 6);
|
assert.equal(threads.length, 7);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
threads.map((t) => t.threadId),
|
threads.map((t) => t.threadId),
|
||||||
[7001, 7002, 7004, 7005, 7003, 7006],
|
[7001, 7002, 7004, 7005, 7003, 7006, 7007],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('verwechselt den letzten Antwortenden nicht mit dem Ersteller', () => {
|
||||||
|
// Der Ersteller hat sein Konto geloescht und steht ohne Profillink da.
|
||||||
|
// Der letzte Antwortende hat eines - wer nach dem ersten Profillink in
|
||||||
|
// der Zeile greift, schreibt den Antrag dem Falschen zu.
|
||||||
|
const fremd = threads.find((t) => t.threadId === 7007);
|
||||||
|
assert.equal(fremd?.authorName, '-Verschwunden-');
|
||||||
|
assert.equal(fremd?.authorForumId, null, 'er hat kein Konto mehr');
|
||||||
|
assert.equal(fremd?.authorProfileUrl, null);
|
||||||
|
});
|
||||||
|
|
||||||
it('liest das Datum des Antrags, nicht das der letzten Antwort', () => {
|
it('liest das Datum des Antrags, nicht das der letzten Antwort', () => {
|
||||||
const erster = threads.find((t) => t.threadId === 7001);
|
const erster = threads.find((t) => t.threadId === 7001);
|
||||||
assert.equal(erster?.postedAt?.toISOString(), '2025-03-04T18:22:05.000Z');
|
assert.equal(erster?.postedAt?.toISOString(), '2025-03-04T18:22:05.000Z');
|
||||||
|
|||||||
@@ -303,6 +303,9 @@ export interface ForumScanSummary {
|
|||||||
skippedStubs: number;
|
skippedStubs: number;
|
||||||
skippedLabels: number;
|
skippedLabels: number;
|
||||||
unassignedThreads: number;
|
unassignedThreads: number;
|
||||||
|
/// Threads, deren Ersteller sich geändert hat und die deshalb neu
|
||||||
|
/// zugeordnet wurden.
|
||||||
|
reassigned: number;
|
||||||
rateLimited: number;
|
rateLimited: number;
|
||||||
matchedPlayers: number;
|
matchedPlayers: number;
|
||||||
failedBoards: { board: string; reason: string; pagesRead: number }[];
|
failedBoards: { board: string; reason: string; pagesRead: number }[];
|
||||||
|
|||||||
@@ -140,6 +140,7 @@ export default function Forum() {
|
|||||||
if (result.unassignedThreads > 0) {
|
if (result.unassignedThreads > 0) {
|
||||||
parts.push(`${result.unassignedThreads} ohne zugeordneten Spieler`);
|
parts.push(`${result.unassignedThreads} ohne zugeordneten Spieler`);
|
||||||
}
|
}
|
||||||
|
if (result.reassigned > 0) parts.push(`${result.reassigned} neu zugeordnet`);
|
||||||
if (result.skippedLabels > 0) parts.push(`${result.skippedLabels} Ankündigungen`);
|
if (result.skippedLabels > 0) parts.push(`${result.skippedLabels} Ankündigungen`);
|
||||||
if (result.skippedStubs > 0) parts.push(`${result.skippedStubs} Verweise`);
|
if (result.skippedStubs > 0) parts.push(`${result.skippedStubs} Verweise`);
|
||||||
if (result.rateLimited > 0) parts.push(`${result.rateLimited} Mal ausgebremst`);
|
if (result.rateLimited > 0) parts.push(`${result.rateLimited} Mal ausgebremst`);
|
||||||
|
|||||||
Reference in New Issue
Block a user