diff --git a/server/bild-kampagne.mjs b/server/bild-kampagne.mjs
index 12500cd2..a481cbad 100644
--- a/server/bild-kampagne.mjs
+++ b/server/bild-kampagne.mjs
@@ -16,7 +16,13 @@
Aufrufen mit: node server/bild-kampagne.mjs
*/
-import { mkdtempSync, rmSync } from "node:fs";
+import { mkdtempSync, rmSync, readFileSync } from "node:fs";
+import { brotliDecompressSync } from "node:zlib";
+/* DER ECHTE LESER (07.10.2026) -- damit auf dem Bild eine Kampagne
+ steht, die wirklich so auf tiktok.com stand, und keine nachgebaute.
+ Ohne Netz: Die Seite liegt als Datei in server/pruefdaten und wird
+ von pruef-kampagne-lesen.mjs an ihrer Pruefsumme festgehalten. */
+import { leseKampagne, regelnText, aufgabenText } from "./kampagne-lesen.mjs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { notbremse } from "./helfer-notbremse.mjs";
@@ -90,24 +96,50 @@ const tagHin = (v) => {
const z = (n) => String(n).padStart(2, "0");
return `${x.getFullYear()}-${z(x.getMonth() + 1)}-${z(x.getDate())}`;
};
-function event(titel, text, von, bis, auf, reg, kampagneId) {
+function event(titel, text, von, bis, auf, reg, kampagneId, daten) {
d.prepare(`INSERT INTO eintraege
(bereich, art, titel, text, datum, event_ende, event_aufgaben, event_regeln,
- dringlichkeit, status, nur_leitung, erstellt, erstellt_von, haus, kampagne_id)
- VALUES ('agentur','kampagne',?,?,?,?,?,?,?,'offen',0,?,?,NULL,?)`)
+ dringlichkeit, status, nur_leitung, erstellt, erstellt_von, haus, kampagne_id,
+ kampagne_daten)
+ VALUES ('agentur','kampagne',?,?,?,?,?,?,?,'offen',0,?,?,NULL,?,?)`)
.run(titel, text, von, bis, auf, reg,
- bis < tagHin(7) ? "hoch" : "mittel", j, idDogi, kampagneId);
+ bis < tagHin(7) ? "hoch" : "mittel", j, idDogi, kampagneId,
+ daten === undefined || daten === null ? null : JSON.stringify(daten));
}
+
+/* ==== EINE ECHTE KAMPAGNE, NICHT EINE NACHGEBAUTE (07.10.2026) ==
+
+ Die uebrigen Kacheln hier sind von Hand getippt, und das ist fuer
+ ihren Zweck richtig: kurze, berechenbare Daten fuer Abstaende und
+ Toene. Fuer die Gliederung taugt das nicht. „KAMPAGNENZEITPLAN"
+ mit zwei Zeilen zeigt weder Geschenke mit Coinpreis noch
+ Unterabschnitte noch einen Steckbrief -- also waere auf dem Bild
+ nichts von dem zu sehen, worum es Filipe ging.
+
+ Deshalb geht EINE Kachel durch den echten Leser: dieselbe
+ gespeicherte Seite, die pruef-kampagne-lesen.mjs an ihrer
+ Pruefsumme festhaelt. Was auf dem Bild steht, hat damit wirklich
+ auf tiktok.com gestanden.
+
+ KEIN NETZ: Die Seite liegt als Datei in server/pruefdaten. */
+const echteSeite = brotliDecompressSync(
+ readFileSync(new URL("./pruefdaten/kampagne-gipfelstuermer.html.br", import.meta.url)))
+ .toString("utf8");
+const echt = leseKampagne(echteSeite);
event("LIVE Glow Up", "Eine vierwöchige Wachstums-Challenge für deine LIVEs.",
- tagHin(-2), tagHin(4),
+ tagHin(-9), tagHin(-1),
"Deine täglichen LIVE Missionen\nDeine wöchentliche Missionen",
"KAMPAGNENZEITPLAN\n· Woche 1: 5. - 11.10.\n· Woche 2: 12. - 18.10.",
"7690927903777262344");
-event("Gipfelstürmer", "Bereit für Alpen-Vibes in deinem LIVE?",
- tagHin(-9), tagHin(-1),
- "1 Diamant = 1 Punkt\nDreifache Punkte für Kampagnengeschenke",
- "BELOHNUNGEN\nSuperstars: Top 1 = 60.000 Diamanten",
- "7691493175936699143");
+/* LAEUFT (frueher: vorbei). So steht sie in der offenen Gruppe und
+ ist auf dem Bild zu sehen. „LIVE Glow Up" uebernimmt dafuer den
+ Zustand „vorbei" -- die drei Zustaende bleiben also vertreten,
+ und die Tonmessung weiter unten misst weiterhin drei
+ verschiedene. */
+event("Gipfelstürmer", echt.kuer.einleitung || "Bereit für Alpen-Vibes in deinem LIVE?",
+ tagHin(-3), tagHin(10),
+ aufgabenText(echt.kuer), regelnText(echt.kuer, echt.hinweise),
+ "7691493175936699143", echt.auszug);
event("Spicy Media Teamabend", "Unser eigener Abend — Rückblick und Planung.",
tagHin(3), tagHin(3),
"Pünktlich um 20 Uhr da sein\nEinen Punkt für die Runde mitbringen",
@@ -400,10 +432,24 @@ for (const [breite, name] of [[1400, 'Am Rechner'], [412, 'Auf dem Handy']]) {
zahl: s.querySelector(".kv-seite__zahl")?.textContent?.trim(),
...r(s),
ton: getComputedStyle(s).getPropertyValue("--kv-ton").trim(),
- karten: [...s.querySelectorAll(".eintrag-karte")].map((k) => ({
- titel: k.querySelector(".eintrag-karte__titel, h3, h2")?.textContent?.trim()?.slice(0, 28),
- ton: getComputedStyle(k).getPropertyValue("--ton").trim(),
- })),
+ karten: [...s.querySelectorAll(".eintrag-karte")].map((k) => {
+ const st = getComputedStyle(k);
+ return {
+ titel: k.querySelector(".eintrag-karte__titel, h3, h2")?.textContent?.trim()?.slice(0, 28),
+ ton: st.getPropertyValue("--ton").trim(),
+ /* ZWEI TOENE, ZWEI KANAELE (siehe die Begruendung bei der
+ Pruefung unten): `--ton` sagt den ZUSTAND, `--kv-ton`
+ die SEITE. Gemessen werden beide, sonst kann die
+ Pruefung sie nicht auseinanderhalten. */
+ kvTon: st.getPropertyValue("--kv-ton").trim(),
+ stand: k.dataset.event || null,
+ /* Und ob der Seitenton wirklich GEZEICHNET wird. Ein
+ Merkmal, das gesetzt ist, aber nichts malt, ist keine
+ Auskunft -- derselbe Fehler wie beim Farbpunkt am
+ 07.10.2026, der 10 statt 22 px breit war. */
+ bild: st.backgroundImage.slice(0, 200),
+ };
+ }),
}));
const kopfA = t.querySelector('.kv-seite[data-seite="tiktok"] .kv-seite__titel');
const zahlA = t.querySelector('.kv-seite[data-seite="tiktok"] .kv-seite__zahl');
@@ -429,8 +475,63 @@ for (const [breite, name] of [[1400, 'Am Rechner'], [412, 'Auf dem Handy']]) {
"links TikTok, rechts die Agentur");
ok(mass.seiten[0].ton !== mass.seiten[1].ton,
`jede Seite hat ihren eigenen Ton (${mass.seiten[0].ton} / ${mass.seiten[1].ton})`);
- ok(mass.seiten.every((s) => s.karten.every((k) => k.ton === s.ton)),
- "und jede Kachel traegt den Ton ihrer Seite");
+ /* ==== ZWEI TOENE, ZWEI KANAELE (berichtigt 07.10.2026) =======
+
+ HIER STAND `k.ton === s.ton` -- „jede Kachel traegt den Ton
+ ihrer Seite". Diese Zeile war ROT, und zwar schon vor dem
+ heutigen Umbau: Am 07.10.2026 wurde bewusst entschieden, dass
+ die Kachel den Ton ihres ZUSTANDS traegt (laeuft gruen, kommt
+ blau, vorbei stumpf) und die SEITE eine eigene Kante links
+ bekommt. Die Pruefung hier wurde dabei nicht mitgezogen und
+ widersprach seitdem dem eigenen Entwurf.
+
+ SIE IST NIEMANDEM AUFGEFALLEN, weil nach dem Lauf nur die
+ Zahl der Messungen notiert wurde („53 Messungen") und nicht
+ die Zahl der Fehler. Genau davor warnt CLAUDE.md seit dem
+ 28.08.2026 -- und diesmal hat es nicht die Zahl der Pruefungen
+ getroffen, sondern die der Fehler. Beides gehoert verglichen.
+
+ WARUM DER ZUSTAND GEWINNT: Ob etwas LAEUFT oder VORBEI ist,
+ entscheidet, ob man heute etwas tut. Woher es kommt, steht
+ ohnehin zweimal da -- in der Spaltenueberschrift und auf dem
+ Herkunftsschild der Kachel.
+
+ GEPRUEFT WIRD JETZT BEIDES, getrennt: der Zustandston am
+ `data-event` der Kachel, der Seitenton daran, dass er bis zur
+ Kachel durchkommt UND dort wirklich gezeichnet wird. */
+ const ZUSTAND_TON = {
+ laeuft: "#cace02", kommt: "#5b8fd4", vorbei: "#6b7686",
+ };
+ const alleKarten = mass.seiten.flatMap((s) =>
+ s.karten.map((k) => ({ ...k, seitenTon: s.ton })));
+ ok(alleKarten.length >= 2,
+ `${alleKarten.length} Kacheln in den Spalten (sonst misst das Folgende nichts)`);
+ const falscheToene = alleKarten.filter((k) =>
+ ZUSTAND_TON[k.stand] && k.ton.toLowerCase() !== ZUSTAND_TON[k.stand]);
+ ok(falscheToene.length === 0,
+ `jede Kachel traegt den Ton ihres ZUSTANDS `
+ + `(${alleKarten.map((k) => `${k.stand}=${k.ton}`).join(", ")})`);
+ /* GEGENPROBE: Die Toene sind nicht alle gleich -- sonst waere
+ „jeder Zustand hat einen eigenen" bei einer einzigen Farbe
+ auch gruen. */
+ ok(new Set(alleKarten.map((k) => k.ton)).size >= 2,
+ `und verschiedene Zustaende verschiedene Toene `
+ + `(${[...new Set(alleKarten.map((k) => k.ton))].join(" / ")})`);
+
+ /* DER SEITENTON KOMMT BIS ZUR KACHEL ... */
+ ok(alleKarten.every((k) => k.kvTon === k.seitenTon),
+ `der Seitenton erbt bis in jede Kachel `
+ + `(${alleKarten.map((k) => k.kvTon).join(", ")})`);
+ /* ... UND WIRD DORT GEZEICHNET. `--kv-ton` gesetzt zu haben,
+ heisst noch nicht, dass man etwas sieht: Der Verlauf fuer die
+ Kante links steht in `background-image`, und wenn die Regel
+ dafuer ausfaellt (etwa weil ein Direktkind-Selektor nach einem
+ Umbau nicht mehr greift), ist das Merkmal da und die Kante
+ weg. Geprueft wird deshalb der GEZEICHNETE Verlauf. */
+ const mitKante = alleKarten.filter((k) => /gradient/.test(k.bild || ""));
+ ok(mitKante.length === alleKarten.length,
+ `und jede Kachel zeichnet ihre Kante wirklich `
+ + `(${mitKante.length} von ${alleKarten.length})`);
if (breite >= 820) {
ok(mass.nebeneinander, "sie stehen nebeneinander");
ok(Math.abs(mass.seiten[0].b - mass.seiten[1].b) <= 2,
@@ -502,6 +603,49 @@ for (const [breite, name] of [[1400, 'Am Rechner'], [412, 'Auf dem Handy']]) {
const t2 = await seite.$(".kv-trennung");
await t2.screenshot({ path: `server/bild-kampagne-trennung-${breite}.png` });
+
+ /* ---- UND EINMAL AUFGEKLAPPT (07.10.2026) ---------------------------
+
+ Das Bild oben zeigt, dass die Kachel KURZ ist. Es zeigt nicht, was
+ dahinter steht -- und genau darum ging es Filipe zuerst: „man soll
+ die titel in den texten besser erkennen und sehen was zu was
+ passt." Also ein zweites Bild mit offenem Kasten.
+
+ NUR DIE EINE KACHEL, nicht das ganze Brett: Mit 13 Abschnitten
+ waere das Bild sonst mehrere tausend Pixel hoch und niemand sieht
+ etwas. */
+ const gipfel = await seite.evaluateHandle(() => {
+ const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
+ .find((x) => x.textContent.includes("Gipfelstürmer"));
+ k?.querySelector(".ev-mehr__knopf")?.click();
+ return k;
+ });
+ await seite.waitForTimeout(400);
+ const offen = await seite.evaluate(() => {
+ const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
+ .find((x) => x.textContent.includes("Gipfelstürmer"));
+ return {
+ auf: k?.querySelector(".ev-mehr__inhalt")?.hidden === false,
+ titel: [...(k?.querySelectorAll(".ev-abschnitt__titel") || [])]
+ .map((h) => h.textContent.trim()).slice(0, 5),
+ stufen: [...(k?.querySelectorAll(".ev-abschnitt") || [])]
+ .map((a) => a.dataset.stufe),
+ };
+ });
+ ok(offen.auf === true, `der Infokasten laesst sich aufklappen (${offen.auf})`);
+ /* DIE ZAHL GEHOERT IN DIE BEDINGUNG, nicht nur in den Meldetext:
+ Ein Bild von einem leeren Kasten waere sonst genauso gruen. */
+ ok(offen.titel.length >= 3,
+ `und zeigt die Ueberschriften der Kampagne: ${JSON.stringify(offen.titel)}`);
+ ok(offen.stufen.includes("2") && offen.stufen.includes("3"),
+ `auf zwei Stufen, nicht alle gleichrangig `
+ + `(${offen.stufen.filter((s) => s === "2").length}x Stufe 2, `
+ + `${offen.stufen.filter((s) => s === "3").length}x Stufe 3)`);
+ if (gipfel) {
+ await gipfel.asElement()?.screenshot({
+ path: `server/bild-kampagne-infos-${breite}.png`,
+ });
+ }
await ctx.close();
}
diff --git a/server/kampagne-lesen.mjs b/server/kampagne-lesen.mjs
index 99c1acfd..700458f7 100644
--- a/server/kampagne-lesen.mjs
+++ b/server/kampagne-lesen.mjs
@@ -136,6 +136,28 @@ export class KampagneUnlesbar extends Error {
/** Der Kalendertag zu einem Zeitpunkt, in einer bestimmten Zone.
* `sv-SE` liefert `JJJJ-MM-TT` ohne eigenes Zusammenbauen. */
+/** Ein Zeitstempel in Millisekunden -- oder nichts.
+ *
+ * DIESELBE SCHWELLE WIE BEI DEN PFLICHTFELDERN (1e12): Eine Zahl
+ * darunter ist eine Sekundenangabe, und die ergibt das Jahr 1970.
+ * Ein falsches Datum sieht aus wie ein Datum -- deshalb lieber
+ * nichts als etwas Falsches. */
+export function zeitOderNull(roh) {
+ const n = Number(roh);
+ return Number.isFinite(n) && n >= 1_000_000_000_000 ? n : null;
+}
+
+/** `2026-11-04` -> `04.11.2026`.
+ *
+ * `tagInZone` liefert absichtlich die sortierbare Form (sv-SE).
+ * In einem Text, den DogFather liest, steht aber das deutsche
+ * Datum. Rein umgestellt, nicht neu gerechnet -- es wird keine
+ * Zone und keine Uhr angefasst. */
+export function deutschesDatum(tag) {
+ const m = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.exec(String(tag || ""));
+ return m ? `${m[3]}.${m[2]}.${m[1]}` : null;
+}
+
export function tagInZone(ms, zone) {
const zahl = Number(ms);
if (!Number.isFinite(zahl)) return null;
@@ -310,6 +332,30 @@ function zeichenZurueck(text) {
* „1. Liga", „2. Liga", „3. Liga" wuerde eine unbrauchbare Liste
* gleicher Zeilen, und niemand koennte sagen, welche Liga gemeint
* ist. Deshalb werden Nummern gesetzt, nicht verworfen. */
+/* WAS AUF DIESER SEITE „LEERRAUM" HEISST (gemessen 07.10.2026).
+
+ Die fuenfte Liga bei „Gipfelstuermer" steht als
+
+
Liga
+
+ -- mit einem geschuetzten Leerzeichen HINTER der Auszeichnung.
+ Die ersten vier haben es nicht. Ein Muster, das dort `\s*`
+ erwartet, erkennt vier von fuenf Ligen als Ueberschrift und die
+ fuenfte nicht; gemessen hing „5. Liga" danach als Absatz unter
+ der vierten. Die Entitaeten werden erst viel spaeter aufgeloest
+ (`zeichenZurueck` am Ende), also muessen die Muster hier noch
+ mit ihnen rechnen.
+
+ Als Zeichenkette und `new RegExp`, nicht als Literal: Dieselbe
+ Gruppe steht in zwei Mustern, und zwei Abschriften derselben
+ Regel laufen auseinander. */
+const LEER = "(?:\\s| | | )*";
+const NUR_STARK = new RegExp(
+ `^${LEER}<(strong|b|em)\\b[^>]*>([^<]*)<\\/\\1>${LEER}$`, "i");
+const ABSATZ_STARK = new RegExp(
+ `<(p|div)\\b[^>]*>${LEER}<(strong|b|em)\\b[^>]*>([^<]{1,90})<\\/\\2>${LEER}<\\/\\1>`,
+ "gi");
+
export function textAusHtml(html) {
let text = String(html || "");
if (!text) return "";
@@ -321,10 +367,84 @@ export function textAusHtml(html) {
text = text.replace(/]*)>([\s\S]*?)<\/ol>/gi, (ganz, merkmale, inhalt) => {
const m = /\bstart\s*=\s*["']?(\d+)/i.exec(merkmale);
let n = m ? parseInt(m[1], 10) : 1;
- return inhalt.replace(/
]*>/gi, () => `\n${n++}. `);
+
+ /* EIN LISTENPUNKT, DER NUR AUS EINER HERVORHEBUNG BESTEHT, IST
+ EINE UEBERSCHRIFT MIT NUMMER (gemessen 07.10.2026).
+
+ So stehen die Ligen bei „Gipfelstuermer" auf der Seite:
+
+
Liga
+
Du kannst an dieser Liga teilnehmen, wenn ...
+
Liga
+
Du kannst an dieser Liga teilnehmen, wenn ...
+
+ Sechsmal „Liga" mit einer Nummer von aussen, und darunter
+ jeweils die Bedingung dafuer. Flach ausgegeben standen zwoelf
+ gleichrangige Zeilen da, und die Frage „ab wie vielen
+ Diamanten bin ich in der 2. Liga" war daran nicht mehr zu
+ beantworten -- obwohl die Antwort danebenstand.
+
+ DIE NUMMER BLEIBT DABEI. Sie kommt aus ``, nicht
+ aus dem Text; „### Liga" sechsmal hintereinander waere
+ schlimmer als vorher.
+
+ ZWEI DURCHGAENGE, UND DER ZWEITE IST DIE SICHERUNG: Das
+ erste Muster braucht ein schliessendes `
`. Fehlt es --
+ in HTML erlaubt --, faellt der Punkt durch, und ohne den
+ zweiten Durchgang waere er samt Inhalt verschwunden. Vorher
+ stand hier nur das oeffnende `
`; diese Nachsicht darf
+ nicht verloren gehen. */
+ let aus = inhalt.replace(/
]*>([\s\S]*?)<\/li\s*>/gi, (g2, drin) => {
+ const stark = NUR_STARK.exec(drin);
+ return stark ? `\n### ${n++}. ${stark[2].trim()}\n` : `\n${n++}. ${drin}`;
+ });
+ aus = aus.replace(/
]*>/gi, "\n· ");
+ /* UEBERSCHRIFTEN IM KLAPPTEXT (07.10.2026, zweite Runde).
+
+ Der laengste Block der Kachel sind die Teilnahmebedingungen
+ -- bei „Gipfelstuermer" rund 4000 Zeichen in einem Stueck.
+ Darin steht eine Gliederung, die beim Entfernen der
+ Auszeichnungen verloren ging. Im Quelltext der Seite:
+
+
Superstars
+
Du kannst an dieser Liga teilnehmen, wenn ...
+
+ „Superstars" ist die Ueberschrift zu dem Absatz darunter.
+ Flach ausgegeben standen beide als gleichwertige Zeilen da,
+ und bei sechs Ligen hintereinander war nicht mehr zu sehen,
+ welche Beschreibung zu welcher Liga gehoert. Genau das meint
+ „sehen was zu was passt".
+
+ ERKANNT WIRD NUR DER EINDEUTIGE FALL: ein Absatz, dessen
+ GANZER Inhalt eine einzige Auszeichnung ist. `
Um was geht
+ es bei Gipfelstuermer?
` ist dagegen eine
+ Frage MIT hervorgehobenem Wort -- auch das ist der Sache nach
+ eine Ueberschrift, aber das zu behaupten waere geraten. Sie
+ bleibt ein Absatz.
+
+ `###` UND NICHT `##`: Zwei Stufen. `regelnText` setzt `##`
+ fuer die Abschnitte, die es selbst bildet (GESCHENKE,
+ BELOHNUNGEN, TEILNAHME); was aus einem fremden Text kommt,
+ steht darunter. Die Kachel ruecke es dadurch sichtbar ein,
+ statt es auf eine Hoehe mit den eigenen Ueberschriften zu
+ stellen -- was eine Gliederung behaupten wuerde, die die
+ Seite so nicht hat.
+
+ NACH der `
`-Behandlung, nicht davor: Die Ligennummern
+ stehen auf der Seite als `
Liga`,
+ also in einer Liste. Liefe diese Ersetzung zuerst, wuerde
+ aus „1. Liga" eine Ueberschrift „Liga" ohne Nummer -- und
+ sechsmal dieselbe. */
+ text = text.replace(ABSATZ_STARK,
+ (ganz, huelle, marke, inhalt) => `\n### ${inhalt.trim()}\n`);
+ text = text.replace(
+ /]*>([\s\S]{1,160}?)<\/h[1-6]>/gi,
+ (ganz, inhalt) => `\n### ${inhalt.replace(/<[^>]+>/g, " ").trim()}\n`);
+
text = text
.replace(/ /gi, "\n")
/* `
` setzt KEINEN Umbruch -- den hat `
` schon gesetzt.
@@ -502,16 +622,49 @@ export function leseKampagne(html) {
abhaken kann. Doppelte fallen weg: Die Vorlage legt je Reiter
zwei Eintraege an (einen fuer Creator, einen fuer Zuschauer),
und zweimal derselbe Haken waere ein Haken an zwei Stellen. */
- if (!aufgaben.length) {
- const gruppe = erster("live_task_group");
+ /* ZU JEDER GRUPPE GEHOERT MEHR ALS IHR TITEL (07.10.2026, zweite
+ Runde).
+
+ Bisher wurde hier nur `displayInfo.title` genommen. Daneben
+ steht `descText` -- ein Platzhalter wie jeder andere, also im
+ Woerterbuch der Seite aufloesbar -- und in `config[0]` der
+ Zeitraum, fuer den die Gruppe gilt. Bei „LIVE Glow Up" sind das
+ vier Gruppen mit je einer eigenen Beschreibung; in der Kachel
+ stand davon nichts.
+
+ ZWEI LISTEN, ZWEI ZWECKE: `aufgaben` ist die HAKENLISTE -- dort
+ waere ein Doppler ein Haken, der an zwei Stellen gleichzeitig
+ erscheint. `aufgabenGruppen` ist die BESCHREIBUNG dazu und wird
+ ueber denselben Titel zugeordnet. Entdoppelt wird deshalb nach
+ dem Titel, und beide Listen bleiben dadurch deckungsgleich.
+
+ GELESEN WIRD IMMER, auch wenn die Aufgaben aus der ersten
+ Vorlage (`live_rule_introduction`) kamen: Eine Kampagne kann
+ beides haben, und dann ist die Beschreibung nicht weniger wert.
+ Nur die Hakenliste wird nicht mehr angefasst, wenn sie steht. */
+ const gruppenBaustein = erster("live_task_group");
+ const aufgabenGruppen = [];
+ {
const gesehen = new Set();
- for (const a of gruppe?.props?.taskItemList || []) {
+ for (const a of gruppenBaustein?.props?.taskItemList || []) {
const titel = auf(a?.displayInfo?.title);
if (!titel || gesehen.has(titel.trim())) continue;
gesehen.add(titel.trim());
- aufgaben.push(titel.trim());
+ const stelle = Array.isArray(a?.config) ? a.config[0] : null;
+ const von = zeitOderNull(stelle?.startTime ?? stelle?.start_time);
+ const bis = zeitOderNull(stelle?.endTime ?? stelle?.end_time);
+ aufgabenGruppen.push({
+ titel: titel.trim(),
+ beschreibung: (auf(a?.displayInfo?.descText) || "").trim() || null,
+ von, bis,
+ von_tag: von !== null && zone ? tagInZone(von, zone) : null,
+ bis_tag: bis !== null && zone ? tagInZone(bis, zone) : null,
+ });
}
- if (aufgaben.length) aufgabenTitel = aufgabenTitel || "Missionen";
+ }
+ if (!aufgaben.length && aufgabenGruppen.length) {
+ for (const g of aufgabenGruppen) aufgaben.push(g.titel);
+ aufgabenTitel = aufgabenTitel || "Missionen";
}
/* GIBT ES SIE NICHT, ODER KONNTEN WIR SIE NICHT LESEN?
@@ -519,7 +672,7 @@ export function leseKampagne(html) {
werden" schickt jemanden suchen; „hat keine" sagt ihm, dass
nichts fehlt. */
if (!aufgaben.length) {
- hinweise.push(regelBaustein || erster("live_task_group")
+ hinweise.push(regelBaustein || gruppenBaustein
? "Die Aufgaben konnten nicht gelesen werden — bitte nachtragen."
: "Diese Kampagne hat keine Aufgabenliste auf der Seite.");
}
@@ -636,6 +789,79 @@ export function leseKampagne(html) {
const freiBaustein = erster("live_normal_rich_text");
const freitext = freiBaustein ? textAusHtml(aufMit(freiBaustein.props || {}, "content")) : null;
+ /* ---- DIE RANGLISTE (07.10.2026, zweite Runde) -----------------
+
+ `live_single_rank_list` stand in allen vier gespeicherten
+ Seiten da und wurde nie gelesen -- ausgerechnet der Baustein,
+ der bei einem Wettbewerb die wichtigste Frage beantwortet:
+ nach WELCHEM Zeitraum wird gewertet.
+
+ WAS DIESER BAUSTEIN WIRKLICH BRINGT (nachgemessen 07.10.2026,
+ nachdem meine erste Begruendung hier falsch war).
+
+ Hier stand: „Der Wertungszeitraum ist NICHT der
+ Kampagnenzeitraum -- bei LIVE Glow Up laeuft die Wertung
+ anders." Das ist nicht wahr. Nachgemessen in allen vier
+ gespeicherten Seiten:
+
+ gipfelstuermer Kampagne 1791064800000..1791410399000
+ Wertung 1791064800000..1791410399000
+ ohne-preise Kampagne 1786399200000..1786658399000
+ Wertung 1786399200000..1786658399000
+ aufgabengruppe Kampagne 1791151200000..1791755999000
+ Wertung 1791151200000..1791755999000
+ beendet hat gar keine Rangliste
+
+ Dreimal auf die Millisekunde gleich. Ich hatte
+ „Gipfelstuermers" KAMPAGNENzeitraum mit „Glow Ups"
+ WERTUNGSzeitraum verglichen -- zwei verschiedene Kampagnen,
+ und die Zahlen sahen deshalb verschieden aus. Eine plausible
+ Herleitung ersetzt keine Messung (CLAUDE.md, 06.09.2026), und
+ ein Kommentar, der etwas Falsches behauptet, ist schlimmer
+ als keiner: Der naechste glaubt ihn.
+
+ GELESEN WIRD DER BAUSTEIN TROTZDEM, aus drei Gruenden, die
+ alle gemessen sind:
+
+ 1. DIE GRUPPEN. `tabInfo[].name` -- bei „Gipfelstuermer"
+ sechs Ligen („Superstars" bis „5. Liga"), bei „Glow Up"
+ zwei. Das ist die Antwort auf „gegen wen trete ich an"
+ und stand nirgends sonst auf der Kachel.
+ 2. OB DIE PUNKTE SICHTBAR SIND (`hide_ranklist_score`).
+ Eine Auskunft, nach der im Stream gefragt wird.
+ 3. Der Zeitraum IST ein eigenes Feld. Dass er in vier
+ Seiten gleich war, heisst nicht, dass er es bleibt --
+ und wenn er einmal abweicht, steht es dann da, statt
+ dass jemand den falschen Tag streamt.
+
+ Die Reiternamen (`tabInfo[].name`) sind die Gruppen, in die
+ eingeteilt wird. Auch sie sind Platzhalter und stehen im
+ Woerterbuch der Seite -- `auf()` loest sie auf.
+
+ `hide_ranklist_score` kommt mit, weil „man sieht die Punkte
+ nicht" eine Auskunft ist, nach der im Stream gefragt wird.
+ Beide Schreibweisen werden gelesen (`hide_ranklist_score` und
+ `hideRanklistScore`): TikTok liefert dieselbe Angabe zweimal,
+ und in einer kuenftigen Fassung vielleicht nur noch eine. */
+ const rangBaustein = erster("live_single_rank_list");
+ let rangliste = null;
+ if (rangBaustein) {
+ const basis = rangBaustein.props?.basicInfo || {};
+ const anzeige = rangBaustein.props?.displaySettings || {};
+ const rohText = auf(anzeige.desc);
+ const von = zeitOderNull(basis.start_time ?? basis.startTime);
+ const bis = zeitOderNull(basis.end_time ?? basis.endTime);
+ rangliste = {
+ beschreibung: rohText ? textAusHtml(rohText) : null,
+ von, bis,
+ von_tag: von !== null && zone ? tagInZone(von, zone) : null,
+ bis_tag: bis !== null && zone ? tagInZone(bis, zone) : null,
+ reiter: (rangBaustein.props?.tabInfo || [])
+ .map((r) => auf(r?.name)).filter(Boolean).map((r) => r.trim()),
+ punkteVerborgen: !!(basis.hide_ranklist_score ?? basis.hideRanklistScore),
+ };
+ }
+
/* Banner: `props.imageUrl[0].url`, NICHT `props.url` und nicht
`pageInfo.topImageInfo` -- die zeigen auf das Vorlagenbild. */
const bildBaustein = erster("live_top_image");
@@ -657,7 +883,29 @@ export function leseKampagne(html) {
const bannerUrl = (bildBaustein?.props?.imageUrl || [])
.map((b) => b?.url || b?.urlSG)
.find((u) => typeof u === "string" && /^https?:\/\//i.test(u)) || null;
- if (!bannerUrl) hinweise.push("Das Banner konnte nicht gelesen werden — bitte nachtragen.");
+
+ /* `live_banner` IST DER ZWEITE BILDBAUSTEIN DER VORLAGE
+ (07.10.2026, zweite Runde).
+
+ Bei „LIVE Glow Up" stehen dort zwei weitere Bilder
+ (Missionsuebersicht, Belohnungen) -- und eine Kampagne ganz
+ ohne `live_top_image` haette bisher gar kein Banner bekommen,
+ obwohl ein Bild dastand.
+
+ GEHOLT WIRD WEITERHIN NUR EINES. Die Kachel hat einen Kopf,
+ nicht drei; die uebrigen stehen im Auszug, damit sie nicht
+ verloren sind, falls sie spaeter gebraucht werden. */
+ const weitereBilder = [];
+ for (const b of teile.get("live_banner") || []) {
+ for (const bild of b?.props?.styleConfig?.imageConfig?.imageInfo || []) {
+ const u = bild?.url || bild?.urlSG;
+ if (typeof u === "string" && /^https?:\/\//i.test(u) && !weitereBilder.includes(u)) {
+ weitereBilder.push(u);
+ }
+ }
+ }
+ const bannerAdresse = bannerUrl || weitereBilder[0] || null;
+ if (!bannerAdresse) hinweise.push("Das Banner konnte nicht gelesen werden — bitte nachtragen.");
/* Laender */
let laender = [];
@@ -668,6 +916,50 @@ export function leseKampagne(html) {
} catch { /* unten als Hinweis */ }
if (!laender.length) hinweise.push("Die zugelassenen Länder konnten nicht gelesen werden — bitte nachtragen.");
+ /* ---- DER STECKBRIEF: die harten Tatsachen (07.10.2026) --------
+
+ Lauter Felder, die in `activityInfo` die ganze Zeit dastanden
+ und die niemand gesehen hat. Aufgenommen wird nur, was man
+ zeigen kann, OHNE es zu deuten:
+
+ short_id die Kurznummer, mit der TikTok die Kampagne in
+ Rueckfragen nennt (1140116740)
+ region der Markt, nicht das Land (EU-TTP2)
+ language die Sprache, in der die Seite angelegt wurde
+ union_name der Verbund -- steht in allen vier gemessenen
+ Seiten LEER und faellt dann weg, statt als
+ „—" zu erscheinen
+
+ ANMELDUNG UND ERGEBNIS NUR BEI ABWEICHUNG.
+ `warm_up_start_time` und `show_result_end_time` sind in allen
+ vier gemessenen Kampagnen GLEICH dem Kampagnenzeitraum. Sie
+ trotzdem hinzuschreiben hiesse, dieselbe Zahl dreimal zu
+ zeigen und so zu tun, als sei es eine zusaetzliche Auskunft --
+ und beim naechsten Mal, wenn sie wirklich abweicht, faellt es
+ niemandem auf. Also: nur wenn sie etwas Neues sagen.
+
+ ABSICHTLICH NICHT AUFGENOMMEN: `activity_type: 2`,
+ `activity_kind_flag: 1`, `platform_flag`, `priority`,
+ `sdlc_status`, `biz_id`. Das sind Zahlen ohne erklaerende
+ Tabelle -- wer sie anzeigt, zeigt eine Auskunft, die er selbst
+ nicht lesen kann. Sie stehen weiter im Rohauszug, und wenn
+ einmal bekannt ist, was `activity_type: 3` bedeutet, sind sie
+ in einer Zeile nachgetragen. */
+ const anmeldungMs = zeitOderNull(aktion.warm_up_start_time);
+ const ergebnisMs = zeitOderNull(aktion.show_result_end_time);
+ const steckbrief = {
+ nummer: id,
+ kurznummer: String(aktion.short_id ?? "").trim() || null,
+ region: String(aktion.region ?? "").trim() || null,
+ sprache: String(aktion.language ?? "").trim() || null,
+ verbund: String(aktion.union_name ?? "").trim() || null,
+ zeitzone: zone,
+ anmeldung_ab: anmeldungMs !== null && anmeldungMs !== startMs
+ ? (zone ? tagInZone(anmeldungMs, zone) : null) : null,
+ ergebnis_bis: ergebnisMs !== null && ergebnisMs !== endeMs
+ ? (zone ? tagInZone(ergebnisMs, zone) : null) : null,
+ };
+
if (ohneEintrag.length) {
hinweise.push(`${ohneEintrag.length} Textbaustein(e) hatten keinen Eintrag `
+ "im Wörterbuch der Seite — der Text fehlt an diesen Stellen.");
@@ -677,11 +969,12 @@ export function leseKampagne(html) {
pflicht: { titel, start, ende, id, zone },
kuer: {
einleitung, einleitungTitel,
- aufgaben, aufgabenTitel, weitereRegeln,
+ aufgaben, aufgabenTitel, aufgabenGruppen, weitereRegeln,
geschenke, geschenkTitel, geschenkHinweis,
preise, preisTitel,
klapptexte, freitext,
- laender, bannerUrl,
+ rangliste, steckbrief,
+ laender, bannerUrl: bannerAdresse,
},
hinweise,
/* ROH MITSPEICHERN (Teil 5 des Bauplans): Wenn in drei Monaten
@@ -715,8 +1008,13 @@ export function leseKampagne(html) {
nennt: Wenn in drei Monaten auffaellt, dass die Ligen auch
gebraucht werden, steht die Antwort schon da -- und eine
abgelaufene Kampagne antwortet dann vielleicht nicht mehr. */
- einleitung, aufgaben, geschenke, preise, klapptexte,
- banner_url: bannerUrl,
+ einleitung, einleitungTitel,
+ aufgaben, aufgabenTitel, aufgabenGruppen,
+ geschenke, geschenkTitel, geschenkHinweis,
+ preise, preisTitel, klapptexte,
+ rangliste, steckbrief,
+ banner_url: bannerAdresse,
+ weitere_bilder: weitereBilder,
woerterbuch_eintraege: Object.keys(woerterbuch).length,
platzhalter_ohne_eintrag: ohneEintrag.length,
},
@@ -748,7 +1046,23 @@ export function aufgabenText(kuer) {
export function regelnText(kuer, hinweise = []) {
const k = kuer || {};
const bloecke = [];
- const gross = (t) => String(t).toUpperCase();
+ /* `## ` DAVOR -- die Ueberschrift sagt selbst, dass sie eine ist
+ (07.10.2026).
+
+ Vorher stand hier nur `toUpperCase()`, und die Kachel musste
+ RATEN, ob eine Zeile eine Ueberschrift ist. Das ging bei
+ „TEILNAHME" gut und bei „WELCHE LIGA BIN ICH?" schief: Die
+ Erkennung verwirft Zeilen, die wie ein Satz enden, weil ein
+ geschrienes „SCHLIESSE DEINE MISSIONEN AB!" sonst eine
+ Ueberschrift waere. Ein Klapptext-Titel mit Fragezeichen fiel
+ damit durch und landete als Absatz IM Abschnitt darueber --
+ gemessen: die sechs Ligen hingen unter „RANGLISTE".
+
+ Wo wir den Text selbst schreiben, muss nichts geraten werden.
+ Die Erkennung an Grossschreibung bleibt daneben bestehen --
+ fuer von Hand getippte Texte und fuer die beiden Kampagnen,
+ die am 07.10.2026 schon in der Datenbank liegen. */
+ const gross = (t) => `## ${String(t).toUpperCase()}`;
if (k.geschenke?.length) {
const kopf = [gross(k.geschenkTitel || "Kampagnengeschenke")];
@@ -771,11 +1085,37 @@ export function regelnText(kuer, hinweise = []) {
if (k.preise?.length) {
bloecke.push([
gross(k.preisTitel || "Preise"),
- ...k.preise.map((p) => `${p.liga}: `
+ /* MIT AUFZAEHLUNGSZEICHEN (07.10.2026): Sechs Ligen
+ hintereinander sind eine Liste, und ohne Zeichen davor
+ sahen sie wie sechs Absaetze aus. */
+ ...k.preise.map((p) => `· ${p.liga}: `
+ p.plaetze.map((s) => `${s.platz} = ${s.preise.join(", ")}`).join(" · ")),
].join("\n"));
}
+ /* DIE RANGLISTE ALS EIGENER ABSCHNITT (07.10.2026).
+ Sie steht VOR den Klapptexten und nach den Preisen: „wo
+ lande ich" gehoert neben „was bekomme ich", und die
+ Klapptexte sind Beiwerk. */
+ if (k.rangliste) {
+ const r = k.rangliste;
+ const zeilen = [];
+ /* BESCHRIFTET, nicht nackt. `displaySettings.desc` ist bei
+ „Gipfelstuermer" das Wort „Punkte" -- allein in einer Zeile
+ sieht das aus, als sei beim Einlesen etwas abgebrochen. Was
+ es IST, steht im Feldnamen: die Beschreibung der Wertung. */
+ if (r.beschreibung) zeilen.push(`· Wertung: ${r.beschreibung.trim()}`);
+ if (r.von_tag && r.bis_tag) {
+ zeilen.push(`· Gewertet wird vom ${deutschesDatum(r.von_tag)} `
+ + `bis zum ${deutschesDatum(r.bis_tag)}`);
+ }
+ if (r.reiter?.length) zeilen.push(`· Gruppen: ${r.reiter.join(" · ")}`);
+ if (r.punkteVerborgen) {
+ zeilen.push("· Die Punktzahl wird in der Rangliste nicht angezeigt.");
+ }
+ if (zeilen.length) bloecke.push([gross("Rangliste"), ...zeilen].join("\n"));
+ }
+
for (const kl of k.klapptexte || []) {
bloecke.push(`${gross(kl.titel)}\n${kl.text}`);
}
@@ -783,7 +1123,12 @@ export function regelnText(kuer, hinweise = []) {
if (k.freitext) bloecke.push(k.freitext);
if (k.laender?.length) {
- bloecke.push(`TEILNAHME\nLänder: ${k.laender.join(", ")}`);
+ /* „Länder: ..." MIT WORT DAVOR, nicht nur die Codes.
+ `DE, CH, AT, LI, LU` allein ist gross geschrieben und waere
+ fuer die Ueberschriftenerkennung eine Ueberschrift -- die
+ Kleinbuchstaben in „Länder" sind die Sicherung dagegen.
+ Steht so auch als Gegenprobe in pruef-kampagne-lesen.mjs. */
+ bloecke.push(`${gross("Teilnahme")}\nLänder: ${k.laender.join(", ")}`);
}
/* Was nicht gelesen werden konnte, steht IN der Kachel -- nicht nur
@@ -805,7 +1150,345 @@ export function regelnText(kuer, hinweise = []) {
tun). Ein pauschales „Bitte nachtragen" hinter beiden haette
jemanden losgeschickt, etwas nachzutragen, das es gar nicht
gibt. Jeder Hinweis sagt jetzt selbst, ob etwas zu tun ist. */
- const vorweg = (hinweise || []).map((h) => `* ${h}`);
+ /* ALLE HINWEISE IN EINEM BLOCK, nicht je einer (07.10.2026).
+ Vorher trennte eine Leerzeile jeden Hinweis vom naechsten --
+ im Rohtext sah das aus wie drei Abschnitte ohne Ueberschrift.
+ `gliederung()` fasst sie ohnehin zusammen; so stimmt auch der
+ Text, den DogFather beim Bearbeiten vorfindet, mit dem
+ ueberein, was er auf der Kachel sieht. */
+ const vorweg = (hinweise || []).map((h) => `* ${h}`).join("\n");
- return [...vorweg, ...bloecke].join("\n\n").trim();
+ return [vorweg, ...bloecke].filter(Boolean).join("\n\n").trim();
+}
+
+/* ===================================================================
+ DER REGELTEXT, GEGLIEDERT (07.10.2026, zweite Runde)
+
+ Filipe, mit einem Bildschirmfoto des Regelblocks: „ich will das die
+ texte perfektionierter werden. man soll die titel in den texten
+ besser erkennen und sehen was zu was passt. ich will auch dass man
+ sieht welche geschenke bei dem event zaehlen und so."
+
+ Er hatte recht, und der Fehler lag nicht im Text, sondern in seiner
+ ANZEIGE. Im Feld stand die ganze Zeit eine Gliederung -- so sieht
+ der echte Inhalt der Kachel „LIVE Glow Up" aus:
+
+ * Diese Kampagne hat keine Preisliste auf der Seite.
+
+ SAMMLE DEIN FEUERWERK — Erhalte doppelte Punkte fuer die Rangliste
+ · Feuerwerk (Sammle Feuerwerk in deinem LIVE!)
+
+ INFOS ZUR KAMPAGNE
+ LIVE Glow Up ist eine exklusive 4-woechige Wachstum Challenge, ...
+
+ KAMPAGNENZEITPLAN
+ · Woche 1: 5. - 11.10.
+
+ Die Kachel hat daraus mit `punkteAusText()` EINE flache Liste
+ gemacht -- jede Zeile derselbe Strich, alle gleich gross.
+ „SAMMLE DEIN FEUERWERK" sah damit genauso aus wie „Feuerwerk",
+ „INFOS ZUR KAMPAGNE" wie der Absatz darunter, und die beiden
+ Hinweise standen zwischen den Regeln, als waeren sie welche.
+
+ DESHALB WIRD HIER GELESEN UND NICHT UMGESCHRIEBEN. Das Feld bleibt
+ Text: DogFather kann es bearbeiten, und was er tippt, bleibt
+ stehen. Die Gliederung entsteht beim Anzeigen aus dem, was schon
+ dasteht. Das hat einen zweiten, handfesten Vorteil -- die beiden
+ Kampagnen, die am 07.10.2026 bereits in der Datenbank liegen,
+ sehen sofort richtig aus. Ohne Umstellung, ohne erneutes Einlesen,
+ ohne ein Feld anzufassen, das jemandem gehoert.
+
+ ---------------------------------------------------------------------
+ WIE EINE UEBERSCHRIFT ERKANNT WIRD
+
+ Eine Zeile ist eine Ueberschrift, wenn sie
+ - kein Aufzaehlungszeichen und kein `*` vorne hat,
+ - mindestens einen Grossbuchstaben und KEINEN Kleinbuchstaben
+ enthaelt (`\p{Lu}` ja, `\p{Ll}` nein),
+ - hoechstens 80 Zeichen lang ist,
+ - nicht mit `.`, `!` oder `?` endet.
+ Dazu `## Titel` fuer den Fall, dass jemand von Hand gliedern will.
+
+ `\p{Ll}` UND NICHT `text === text.toUpperCase()`: Bei „GROSSE"
+ waere beides gleich, bei einem Wort mit „ß" aber nicht --
+ `"ß".toUpperCase()` ist `"SS"`, und eine Ueberschrift mit ß waere
+ damit nie eine. Die Frage ist ohnehin „stehen hier Kleinbuchstaben",
+ nicht „laesst sich das noch vergroessern".
+
+ DER UNTERTITEL steht hinter einem Gedankenstrich mit Leerzeichen
+ (`SAMMLE DEIN FEUERWERK — Erhalte doppelte Punkte`). Geprueft wird
+ nur der Teil DAVOR auf Grossschreibung -- sonst waere genau diese
+ Zeile, die der Leser selbst so schreibt, keine Ueberschrift.
+
+ DAS IST EINE HEURISTIK, UND SIE TRAEGT ABSICHTLICH NICHTS.
+ Greift sie nicht, erscheint die Zeile als gewoehnlicher Absatz --
+ also genau so wie bisher. Es gibt keinen Fall, in dem ein
+ Fehlurteil Inhalt verliert, und keinen, in dem etwas davon abhaengt.
+ Der eine bekannte Fehlgriff ist eine Zeile aus nichts als
+ Laendercodes (`DE, CH, AT`): Die ist gross geschrieben und wuerde
+ zur Ueberschrift. `regelnText` schreibt deshalb „Länder: DE, CH,
+ AT" -- die Kleinbuchstaben im Wort davor sind die Sicherung, und
+ die Gegenprobe in pruef-kampagne-lesen.mjs haelt sie fest.
+
+ ---------------------------------------------------------------------
+ WIE EIN GESCHENK ERKANNT WIRD
+
+ Nicht am Text, sondern an der LISTE. `gliederung(text, { geschenke })`
+ bekommt die Geschenke so, wie der Leser sie aus der Seite geholt
+ hat -- mit Name, Coinpreis und Hinweis. Eine Aufzaehlungszeile,
+ die mit einem dieser Namen beginnt, ist ein Geschenk, und der
+ Preis kommt aus der LISTE, nicht aus dem Text.
+
+ Warum nicht nach „Coins" im Text suchen? Weil „Feuerwerk (Sammle
+ Feuerwerk in deinem LIVE!)" gar keinen Preis hat -- es ist ein
+ Kampagnengeschenk, kein Coin-Geschenk -- und trotzdem das
+ Geschenk ist, das bei diesem Event zaehlt. Genau danach hat
+ Filipe gefragt. Die Textsuche nach „— N Coins" bleibt als
+ ZWEITER Weg daneben: Sie greift bei von Hand getippten Zeilen,
+ fuer die es keine Liste gibt.
+
+ DIE LAENGSTEN NAMEN ZUERST: Stuenden „Rose" und „Rose Gold" in
+ derselben Kampagne, wuerde ein Vergleich von vorne bei „Rose Gold"
+ den Eintrag „Rose" treffen und dessen Preis anschreiben.
+ =================================================================== */
+
+/* `*` fehlt hier ABSICHTLICH, obwohl `PUNKT_ZEICHEN` in
+ workspace-bereiche.js es als Aufzaehlungszeichen fuehrt: `* ` ist
+ die Hinweismarke, die `regelnText` setzt. Stuende es in beiden
+ Mustern, haengte die Entscheidung an der Reihenfolge der Abfragen
+ -- und die ist genau die Sorte Abhaengigkeit, die beim naechsten
+ Umbau still kippt. */
+const G_AUFZAEHLUNG = /^[ \t]*[-–—•·▪◦‣>+][ \t]*/u;
+const G_HINWEIS = /^[ \t]*\*[ \t]+/u;
+const G_RAUTE = /^[ \t]*#{1,6}[ \t]*/u;
+const G_TRENNER = /[ \t]+[—–][ \t]+/u;
+const G_COINS = /[ \t]+[—–][ \t]+([0-9]{1,9})[ \t]+Coins?$/u;
+const G_SATZENDE = /[.!?]$/u;
+const G_KLAMMER = /^\((.*)\)$/u;
+const G_VORSPANN = /^[—–-][ \t]*/u;
+const KOPF_MAX = 80;
+
+/** Titel und Untertitel einer Zeile, am Gedankenstrich getrennt. */
+function teileTitel(zeile) {
+ const t = String(zeile).trim();
+ const m = G_TRENNER.exec(t);
+ if (!m) return { titel: t, unter: null };
+ return {
+ titel: t.slice(0, m.index).trim(),
+ unter: t.slice(m.index + m[0].length).trim() || null,
+ };
+}
+
+/** Ist diese Zeile eine Ueberschrift? Dann Titel und Untertitel. */
+/* ZWEI STUFEN, FLACH GEFUEHRT.
+
+ `##` sind die Abschnitte, die `regelnText` selbst bildet,
+ `###` die Ueberschriften aus einem fremden Text (Klapptexte,
+ siehe textAusHtml). Die Liste bleibt trotzdem FLACH: Ein
+ Baum waere im Anzeigecode drei Verschachtelungen tief, und
+ gewonnen waere nichts, was eine Einrueckung nicht auch
+ zeigt. Wer `stufe` nicht beachtet, bekommt die alte,
+ gleichrangige Darstellung -- kein Inhalt haengt daran. */
+function kopfzeile(zeile) {
+ const t = String(zeile).trim();
+ if (!t) return null;
+ if (G_RAUTE.test(t)) {
+ const rauten = (/^[ \t]*(#{1,6})/u.exec(t) || ["", "##"])[1].length;
+ const ohne = t.replace(G_RAUTE, "").trim();
+ /* Gedeckelt auf 2 und 3: Mehr Stufen kann die Kachel nicht
+ zeigen, und eine vierte waere eine Auskunft, die niemand
+ sieht. */
+ return ohne ? { ...teileTitel(ohne), stufe: rauten <= 2 ? 2 : 3 } : null;
+ }
+ if (G_HINWEIS.test(t) || G_AUFZAEHLUNG.test(t)) return null;
+ const zerlegt = teileTitel(t);
+ const k = zerlegt.titel;
+ if (!k || k.length > KOPF_MAX) return null;
+ if (!/\p{Lu}/u.test(k)) return null;
+ if (/\p{Ll}/u.test(k)) return null;
+ if (G_SATZENDE.test(k)) return null;
+ return { ...zerlegt, stufe: 2 };
+}
+
+/* Die Art eines Abschnitts entscheidet ueber Zeichen und Farbe --
+ nicht ueber den Inhalt. Trifft kein Wort, bleibt sie leer und der
+ Abschnitt sieht schlicht aus. Deutsche Woerter in einer Tabelle
+ sind sonst genau die Sorte Vergleich, vor der CLAUDE.md warnt
+ („IDs wandern nicht, Klartext schon") -- hier traegt sie aber
+ nichts: Der Text IST deutsch, weil die Kachel deutsch ist, und
+ ein Fehlgriff kostet ein Symbol. */
+const ABSCHNITT_WORTE = [
+ [/rangliste|ranking/u, "rangliste"],
+ [/zeitplan|termin|phase|woche/u, "zeitplan"],
+ [/teilnahme|bedingung|land|länder|laender/u, "teilnahme"],
+ [/preis|belohnung|gewinn|reward/u, "preise"],
+ [/geschenk|gift/u, "geschenke"],
+ [/aufgabe|mission|regel/u, "regeln"],
+ [/info|ueber|über/u, "infos"],
+];
+
+function abschnittArt(a) {
+ if (a.zeilen.some((z) => z.art === "geschenk")) return "geschenke";
+ /* `every()` AUF EINEM LEEREN FELD IST `true` -- genau die Falle
+ aus CLAUDE.md (01.09.2026). Ein Abschnitt, der nur wegen
+ seiner Unterabschnitte stehen bleibt, hat keine Zeilen und
+ waere damit ein „Hinweisblock" -- also in der Kachel ein
+ stiller, grauer Kasten, obwohl es eine Ueberschrift ist. */
+ if (a.zeilen.length && a.zeilen.every((z) => z.art === "hinweis")) return "hinweise";
+ const t = (a.titel || "").toLocaleLowerCase("de-DE");
+ if (t) for (const [muster, art] of ABSCHNITT_WORTE) if (muster.test(t)) return art;
+ return null;
+}
+
+/** Zerlegt einen Regeltext in Abschnitte mit Ueberschrift.
+ *
+ * @param {string} text der Inhalt von `event_regeln`
+ * @param {{geschenke?: Array}} [zusatz] die gelesenen Geschenke
+ * @returns {Array<{titel: ?string, unter: ?string, art: ?string,
+ * zeilen: Array