Kampagnen-Kacheln: gegliederte Texte, Geschenke mit Preis, aufklappbar

Filipe, mit einem Bildschirmfoto des Regelblocks: "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.
ich will alle informationen die moeglich sind wenn man den link
hochlaedt. ... auch wenn man teilt soll es einfach perfekt sein. man
soll alle infos sehen die man braucht. die kacheln von den events
soll auch nicht so lang sein, die soll man aufklappen koennen. genau
wie eine trennung fuer heute, letzte woche letzten monat und so."

DER FEHLER LAG NICHT IM TEXT, SONDERN IN SEINER ANZEIGE. Im Feld
stand die ganze Zeit eine Gliederung -- Hinweise mit "* ", eine
Ueberschrift mit Untertitel hinter einem Gedankenstrich, Absaetze
ohne Zeichen, Aufzaehlungen mit "- ". Die Kachel hat daraus mit
punkteAusText() EINE flache Liste gemacht: jede Zeile derselbe
Strich, alle gleich gross.

  gliederung()      liest die Gliederung, die schon dasteht, statt
                    den Text umzuschreiben. Die beiden Kampagnen, die
                    heute in der Datenbank liegen, sehen dadurch
                    sofort richtig aus -- ohne Umstellung.
  Geschenke         kommen aus der gelesenen LISTE, nicht aus dem
                    Text, und stehen OHNE Aufklappen auf der Kachel.
  Aufklappen        alles Uebrige hinter einem Druck: gemessen 555 px
                    weniger je Kachel bei Gipfelstuermer.
  Gruppen           je Spalte laeuft / kommt / vorbei, Vergangenes
                    nach diese Woche, dieser Monat, aelter.

NEU GELESEN (stand in allen vier gespeicherten Seiten und wurde nie
angesehen): die Rangliste mit ihren Gruppen ("Superstars" bis
"5. Liga", bei Glow Up "Team Glow"/"Team Shine"), die Beschreibung je
Missionsgruppe, der Steckbrief (Kurznummer, Markt, Sprache, Zone),
der zweite Bildbaustein, und Ueberschriften INNERHALB der Klapptexte
-- "WELCHE LIGA BIN ICH?" hat jetzt sechs eingerueckte Unterpunkte
statt zwoelf gleichrangiger Zeilen.

EVENT_TEXT_MAX 6000 -> 20000. Der Regeltext von Gipfelstuermer ist
7679 Zeichen lang; es fielen bisher jedes Mal rund 1600 Zeichen
echter Inhalt weg. Die Zahl steht jetzt an EINER Stelle.

TEILEN: Was die KAMPAGNE sagt, geht hinaus (Aufgaben, Geschenke,
Regeln) -- wer WIR sind, nicht (Name, Rolle, Haus, Kampagnennummer,
Steckbrief). Und keine Hinweiszeile: "Die Preise konnten nicht
gelesen werden -- bitte nachtragen" ist eine Notiz an DogFather und
hat auf einer Seite, die er in Discord postet, nichts zu suchen.

BERICHTIGT, WEIL NACHGEMESSEN:
  - Number(null) ist 0 und 0 ist endlich -- "Feuerwerk" bekam an drei
    Stellen "0 Coins" angeschrieben, also eine falsche Preisangabe.
  - Ein Kommentar im Leser behauptete, der Wertungszeitraum weiche
    vom Kampagnenzeitraum ab. Nachgemessen ist er in allen drei
    Seiten mit Rangliste auf die Millisekunde gleich; ich hatte zwei
    verschiedene Kampagnen verglichen.
  - bild-kampagne.mjs verlangte "jede Kachel traegt den Ton ihrer
    Seite" und war damit seit dem 07.10. rot, weil an dem Tag bewusst
    das Gegenteil entschieden wurde (Zustandston, Seitenkante). Nicht
    aufgefallen, weil nach dem Lauf nur die Zahl der MESSUNGEN notiert
    wurde, nicht die der FEHLER.
  - .kv-seite > .eintrag-karte wird .kv-seite .eintrag-karte: Die
    Karten haengen durch die Gruppen eine Ebene tiefer. Das waere der
    fuenfte Direktkind-Selektor gewesen, der nach einem Umbau still
    ausfaellt -- diesmal vorher gesehen.

GEPRUEFT: 186 (lesen, +58), 148 (Route/Teilen, +25), 158 (Kachel,
+64), 67 (Agentur, +5), 67 Messungen (Bild, +14), dazu struktur,
css-klassen, zeichen, deutsche-texte, tippziele, lesbarkeit und
haus-trennung (107, unveraendert). Sechs Gegenproben: alle sechs
Sabotagen werden bemerkt, alle Dateien byte-gleich wiederhergestellt.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-10-07 22:39:03 +02:00
co-authored by Claude Opus 5
parent d7720a3687
commit cadab24a5f
58 changed files with 3950 additions and 798 deletions
+161 -17
View File
@@ -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();
}
+700 -17
View File
@@ -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
<li ...><strong>Liga&nbsp;</strong>&nbsp;</li>
-- 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|&nbsp;|&#160;|&#xa0;)*";
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(/<ol\b([^>]*)>([\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(/<li\b[^>]*>/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:
<ol start="1"><li ...><strong>Liga&nbsp;</strong></li></ol>
<p>Du kannst an dieser Liga teilnehmen, wenn ...</p>
<ol start="2"><li ...><strong>Liga&nbsp;</strong></li></ol>
<p>Du kannst an dieser Liga teilnehmen, wenn ...</p>
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 `<ol start>`, 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 `</li>`. 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 `<li>`; diese Nachsicht darf
nicht verloren gehen. */
let aus = inhalt.replace(/<li\b[^>]*>([\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(/<li\b[^>]*>/gi, () => `\n${n++}. `);
return aus;
});
text = text.replace(/<li\b[^>]*>/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:
<p style="text-align: center;"><strong>Superstars</strong></p>
<p>Du kannst an dieser Liga teilnehmen, wenn ...</p>
„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. `<p>Um was geht
es bei <strong>Gipfelstuermer?</strong></p>` 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 `<li>`-Behandlung, nicht davor: Die Ligennummern
stehen auf der Seite als `<ol start=N><li><strong>Liga</strong>`,
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(
/<h[1-6]\b[^>]*>([\s\S]{1,160}?)<\/h[1-6]>/gi,
(ganz, inhalt) => `\n### ${inhalt.replace(/<[^>]+>/g, " ").trim()}\n`);
text = text
.replace(/<br\s*\/?>/gi, "\n")
/* `</li>` setzt KEINEN Umbruch -- den hat `<li>` 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<object>}>}
*
* Zeilenarten: `hinweis` (was fehlt), `geschenk` (mit `coins` und
* `zusatz`), `punkt` (Aufzaehlung), `text` (Absatz).
*/
export function gliederung(text, zusatz = {}) {
const namen = (Array.isArray(zusatz.geschenke) ? zusatz.geschenke : [])
.filter((g) => g && typeof g.name === "string" && g.name.trim())
.map((g) => ({
name: g.name.trim(),
/* `Number(null)` IST 0, UND 0 IST ENDLICH (gemessen
07.10.2026, beim ersten Lauf gegen den echten
Datenbankeintrag). Hier stand
`Number.isFinite(Number(g.preis)) ? ... : null` -- und
„Feuerwerk", ein Kampagnengeschenk OHNE Coinpreis, bekam
dadurch „0 Coins" angeschrieben. Das ist keine
Kleinigkeit: „0 Coins" ist eine Preisangabe, und zwar
eine falsche. Erst auf „nichts da" pruefen, dann
rechnen. */
preis: (g.preis === null || g.preis === undefined || g.preis === "")
? null
: (Number.isFinite(Number(g.preis)) ? Number(g.preis) : null),
hinweis: typeof g.hinweis === "string" ? g.hinweis.trim() : null,
}))
.sort((a, b) => b.name.length - a.name.length);
const abschnitte = [];
let jetzt = null;
const neu = (titel, unter, stufe = 2) => {
jetzt = {
titel: titel || null, unter: unter || null,
stufe, art: null, zeilen: [],
};
abschnitte.push(jetzt);
};
for (const rohzeile of String(text || "").split(/\r?\n/)) {
const zeile = rohzeile.trim();
if (!zeile) continue;
const kopf = kopfzeile(zeile);
if (kopf) { neu(kopf.titel, kopf.unter, kopf.stufe); continue; }
/* Steht etwas VOR der ersten Ueberschrift -- wie die Hinweise --,
bekommt es einen Abschnitt ohne Titel. Der wird beim Anzeigen
ohne Ueberschrift ausgegeben, nicht mit einer erfundenen. */
if (!jetzt) neu(null, null);
if (G_HINWEIS.test(zeile)) {
jetzt.zeilen.push({ art: "hinweis", text: zeile.replace(G_HINWEIS, "").trim() });
continue;
}
if (G_AUFZAEHLUNG.test(zeile)) {
const nur = zeile.replace(G_AUFZAEHLUNG, "").trim();
/* Erst den Coinpreis abtrennen, DANN den Namen vergleichen:
sonst endet „300 Coins" als Zusatztext hinter dem Geschenk
und steht doppelt da -- einmal als Text, einmal als Zahl. */
const c = G_COINS.exec(nur);
const ohneCoins = c ? nur.slice(0, c.index).trim() : nur;
const coinsAusText = c ? Number(c[1]) : null;
const treffer = namen.find((g) => ohneCoins === g.name
|| ohneCoins.startsWith(`${g.name} `)
|| ohneCoins.startsWith(`${g.name}(`));
if (treffer) {
const rest = ohneCoins.slice(treffer.name.length).trim()
.replace(G_VORSPANN, "").trim().replace(G_KLAMMER, "$1").trim();
jetzt.zeilen.push({
art: "geschenk",
text: treffer.name,
coins: treffer.preis ?? coinsAusText,
zusatz: treffer.hinweis || rest || null,
});
continue;
}
jetzt.zeilen.push(coinsAusText !== null
? { art: "geschenk", text: ohneCoins, coins: coinsAusText, zusatz: null }
: { art: "punkt", text: nur });
continue;
}
jetzt.zeilen.push({ art: "text", text: zeile });
}
/* EINE FRAGE MIT ANTWORT DARUNTER IST EINE UEBERSCHRIFT
(07.10.2026).
Der Teilnahmeblock von „Gipfelstuermer" ist nach Fragen
gegliedert, und zwar durchgehend:
Um was geht es bei Gipfelstürmer?
Gipfelstürmer ist eine TikTok LIVE Kampagne, in welcher ...
Wer kann bei Gipfelstürmer teilnehmen?
Diese Kampagne findet von 04.10.2025 ... statt.
Wie kann ich Punkte sammeln?
...
Acht solche Fragen in rund 4000 Zeichen. Im Quelltext stehen
sie NICHT als Ueberschrift -- sie sind der erste Satz eines
Absatzes, getrennt durch `<br>` --, also kann `textAusHtml`
sie nicht als eine erkennen. Flach ausgegeben sahen sie wie
Fliesstext aus, und der ganze Block war eine Wand.
DIE REGEL: eine Textzeile, die mit `?` endet, hoechstens 90
Zeichen hat und auf die noch etwas folgt. Eine Frage, auf die
NICHTS folgt, ist keine Ueberschrift, sondern eine Frage --
„Hast du Fragen?" am Ende eines Blocks bleibt deshalb eine
gewoehnliche Zeile, solange darunter nichts steht.
DAS GESCHIEHT IM ANZEIGEN, NICHT IM TEXT. Im Feld aendert sich
nichts; wer die Regel fuer falsch haelt, bekommt eine Zeile
in Fettschrift zu sehen und keinen fehlenden Inhalt. Das ist
der Grund, warum sie ueberhaupt erlaubt ist: Sie raet, und
was sie raet, traegt nichts. */
for (const a of abschnitte) {
for (let i = 0; i < a.zeilen.length - 1; i += 1) {
const z = a.zeilen[i];
if (z.art !== "text" || z.text.length > 90 || !z.text.endsWith("?")) continue;
if (a.zeilen[i + 1].art !== "text" && a.zeilen[i + 1].art !== "punkt") continue;
z.art = "frage";
}
}
/* EIN ABSCHNITT OHNE ZEILEN FAELLT WEG -- ABER NICHT, WENN ER
UNTERABSCHNITTE HAT (berichtigt 07.10.2026, beim ersten Lauf
gegen „Gipfelstuermer").
Die Regel „ohne Zeilen, also weg" ist richtig fuer den Fall,
fuer den sie gedacht war: jemand loescht beim Bearbeiten den
Inhalt und laesst die Ueberschrift stehen. Gemessen hat sie
aber etwas anderes getroffen -- den Klapptext „WELCHE LIGA BIN
ICH?". Unter ihm steht keine einzige eigene Zeile, sondern
sechs Unterabschnitte („1. Liga", „2. Liga", ...). Die
Ueberschrift verschwand, und die sechs Ligen hingen danach
ohne Zusammenhang zwischen Rangliste und
Teilnahmebedingungen.
Ein Abschnitt bleibt deshalb, wenn ihm ein TIEFERER folgt.
Geprueft wird der unmittelbar naechste: Dazwischen kann
nichts liegen, denn jede Zeile gehoert zum vorherigen
Abschnitt. */
const fertig = abschnitte.filter((a, i) => a.zeilen.length
|| (abschnitte[i + 1] && abschnitte[i + 1].stufe > a.stufe));
for (const a of fertig) a.art = abschnittArt(a);
return fertig;
}
+67 -3
View File
@@ -726,7 +726,33 @@ melde("\n=== Die Kachel ===");
marke: k.querySelector(".ev-noch")?.textContent.trim(),
spanne: k.querySelector(".ev-spanne")?.textContent.trim(),
schilder: [...k.querySelectorAll(".ev-block__schild")].map((s) => s.textContent.trim()),
/* `.ev-punkt__text` SIND DIE AUFGABEN, `.ev-zeile__text` DIE
REGELN (07.10.2026). Bis dahin war beides dieselbe flache
Liste; seit der Gliederung sind es zwei Sorten Zeile, und
die Regeln haben kein Kästchen mehr davor. Gelesen werden
beide -- und getrennt, damit die Prüfung unten sagen kann,
WELCHE Sorte fehlt, statt nur „einer von zweien fehlt". */
texte: [...k.querySelectorAll(".ev-punkt__text")].map((s) => s.textContent.trim()),
/* ZEILEN **UND** ABSAETZE. Eine Regelzeile MIT
Aufzaehlungszeichen wird ein `.ev-zeile`, eine OHNE ein
`.ev-absatz` -- und das ist richtig so: Sonst wuerden die
Prosa-Absaetze einer Kampagnenseite („LIVE Glow Up ist
eine exklusive 4-woechige Challenge ...") alle zu
Aufzaehlungspunkten, also wieder zu der flachen Liste, die
am 07.10.2026 abgeschafft wurde.
Dieses Testevent wird ueber die Schnittstelle angelegt,
ohne Zeichen davor. Der Zeileneditor im Formular setzt sie
sonst selbst (geprueft in pruef-eventkarte.mjs:
„gleichmaessig gesetzte Zeichen"), im echten Betrieb ist
also meist die erste Form zu sehen. Gemessen werden beide,
getrennt -- so sagt die Prüfung, WELCHE Form herauskam. */
regelzeilen: [...k.querySelectorAll(".ev-zeile__text")].map((s) => s.textContent.trim()),
regelabsaetze: [...k.querySelectorAll(".ev-absatz")].map((s) => s.textContent.trim()),
/* Und ob die Regeln hinter dem Aufklappen liegen -- das ist
der Grund, warum die Kachel kürzer geworden ist. */
klappZu: k.querySelector(".ev-mehr__inhalt")?.hidden ?? null,
klappWort: k.querySelector(".ev-mehr__wort")?.textContent.trim() || null,
};
});
ok(!!event, "die Event-Karte Sommer-Aktion steht auf der Seite");
@@ -736,10 +762,48 @@ melde("\n=== Die Kachel ===");
ok(/^(noch \d+ Tage?|heute ist der letzte Tag)$/.test(event?.marke || ""),
`der Zustand steht als WORT da, nicht nur als Farbe ("${event?.marke}")`);
ok(/–/.test(event?.spanne || ""), `und der Zeitraum als Spanne ("${event?.spanne}")`);
ok(event?.schilder.join(",") === "Aufgaben,Regeln",
/* „Regeln & Infos" SEIT DEM 07.10.2026. Der Block trägt nicht
mehr nur die Regeln, sondern die ganze Gliederung der
Kampagnenseite -- Zeitplan, Rangliste, Teilnahme. „Regeln"
allein wäre dafür zu wenig gesagt. */
ok(event?.schilder.join(",") === "Aufgaben,Regeln & Infos",
`beide Bloecke sind beschriftet (${event?.schilder.join(", ")})`);
ok(event?.texte.includes("1 Punkt je Live-Stunde") && event?.texte.includes("Nur eigenes Konto"),
`und tragen wirklich ihren Inhalt (${JSON.stringify(event?.texte)})`);
ok(event?.texte.includes("1 Punkt je Live-Stunde"),
`die Aufgabe steht als abhakbarer Punkt da (${JSON.stringify(event?.texte)})`);
/* OHNE AUFZAEHLUNGSZEICHEN GETIPPT -> ABSATZ. Begruendung oben
bei der Messung. Geprueft wird, dass die Regel ANKOMMT, und
daneben, in welcher Form -- ein „irgendwo steht es" waere
auch dann gruen, wenn die Gliederung alles in einen Topf
wirft. */
ok(event?.regelabsaetze.includes("Nur eigenes Konto")
|| event?.regelzeilen.includes("Nur eigenes Konto"),
`die Regel kommt in der Gliederung an (Zeilen `
+ `${JSON.stringify(event?.regelzeilen)}, Absaetze `
+ `${JSON.stringify(event?.regelabsaetze)})`);
ok(event?.regelabsaetze.includes("Nur eigenes Konto"),
" und zwar als Absatz, weil kein Aufzaehlungszeichen davorstand");
/* GEGENPROBE: Sie steht NICHT zwischen den Aufgaben. Ohne sie
waere unbelegt, dass Aufgaben und Regeln getrennt bleiben --
und genau das war der Mangel im Bildschirmfoto vom
07.10.2026. */
ok(!event?.texte.includes("Nur eigenes Konto"),
"und nicht zwischen den abhakbaren Aufgaben");
/* ---- UND DIE REGELN LIEGEN HINTER EINEM DRUCK (07.10.2026) --
Filipe: „die kacheln von den events soll auch nicht so lang
sein, die soll man aufklappen koennen." Hier wird nur geprüft,
DASS es so ist; wie viel Höhe das spart, misst
pruef-eventkarte.mjs in Abschnitt 4b.
DIE ZEILE DARÜBER BLEIBT TROTZDEM GÜLTIG: `hidden` nimmt ein
Element nicht aus dem Baum, der Inhalt ist also vorhanden und
nur zugeklappt. Genau das soll es sein -- verborgen, nicht
verloren. */
ok(event?.klappZu === true,
`die Gliederung steht zugeklappt da (${event?.klappZu})`);
ok(event?.klappWort === "Alle Infos",
`und der Knopf dazu ist beschriftet ("${event?.klappWort}")`);
/* GEGENPROBE dazu: Eine Karte, die KEIN Event ist, darf nichts davon
haben. Sonst bewiese der Block oben nur, dass die Elemente
+570 -9
View File
@@ -98,11 +98,19 @@ const tag = (v) => tagLokal(v);
der Agentur. Die Pruefung braucht beide Seiten gefuellt -- sonst
misst sie eine Trennung, bei der eine Haelfte leer ist, und
„zwei nebeneinander" waere dann zu Recht rot. */
function evt(titel, von, bis, auf, reg, kampagneId = null) {
/* `daten` ist der Auszug, den der Leser beim Einlesen ablegt
(07.10.2026). Daraus baut der Server die Geschenke mit
Coinpreis, den Steckbrief und die Beschreibung je
Missionsgruppe. Ohne ihn waeren diese drei Dinge hier gar
nicht pruefbar -- und ein Feld, das keine Pruefung ansieht,
ist ein Feld, das beim naechsten Umbau still ausfaellt. */
function evt(titel, von, bis, auf, reg, kampagneId = null, daten = null) {
d.prepare("INSERT INTO eintraege (titel, text, bereich, art, status, datum, event_ende,"
+ " event_aufgaben, event_regeln, dringlichkeit, erstellt, erstellt_von, kampagne_id)"
+ " VALUES (?,?,'agentur','kampagne','offen',?,?,?,?,'mittel',?,?,?)")
.run(titel, "Beschreibung zum Test.", von, bis, auf, reg, jetzt, idDogi, kampagneId);
+ " event_aufgaben, event_regeln, dringlichkeit, erstellt, erstellt_von, kampagne_id,"
+ " kampagne_daten)"
+ " VALUES (?,?,'agentur','kampagne','offen',?,?,?,?,'mittel',?,?,?,?)")
.run(titel, "Beschreibung zum Test.", von, bis, auf, reg, jetzt, idDogi, kampagneId,
daten === null ? null : JSON.stringify(daten));
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
}
/* Die drei Zustaende, die eventStand kennt -- jeder einzeln, nicht als
@@ -116,6 +124,66 @@ const kommend = evt("November Challenge", tag(20), tag(45),
"· 30 Live-Stunden", "· Nur eigene Konten");
const vorbei = evt("September Rückblick", tag(-40), tag(-12),
"· 20 Live-Stunden", "· Nur eigene Konten");
/* ==== EIN EVENT MIT ECHTER GLIEDERUNG (07.10.2026) ==========
Der Regeltext ist ZEICHENGLEICH mit dem, was am 07.10.2026
wirklich in der Datenbank steht (Eintrag 89, „LIVE Glow Up")
-- nur gekuerzt. Erfundene Testdaten haetten hier genau das
verdeckt, worum es geht: Die Hinweise mit `* ` davor, die
Ueberschrift mit Untertitel hinter einem Gedankenstrich, der
Absatz ohne Zeichen und die Aufzaehlung mit `· `. Jede dieser
vier Zeilensorten sah vorher gleich aus.
ZWEI GESCHENKE, UND EINES OHNE PREIS. Das ist die Gegenprobe
zu einem Fehler, der am 07.10.2026 beim ersten Lauf wirklich
auftrat: `Number(null)` ist 0, und 0 ist endlich -- „Feuerwerk"
bekam dadurch „0 Coins" angeschrieben, also eine falsche
Preisangabe. Mit nur einem Geschenk MIT Preis waere das nie
aufgefallen.
ES LAEUFT UND KOMMT VON TIKTOK: Damit liegt es links in der
Gruppe „Laeuft gerade", die offen steht -- sonst waere alles
darin zugeklappt und nicht messbar. */
const struktText = [
"* Diese Kampagne hat keinen Einleitungstext — der Inhalt steht in den Klapptexten.",
"* Diese Kampagne hat keine Preisliste auf der Seite.",
"",
"SAMMLE DEIN FEUERWERK — Erhalte doppelte Punkte für die Rangliste",
"· Feuerwerk (Sammle Feuerwerk in deinem LIVE!)",
"· Ahorn-Welt — 12000 Coins",
"",
"INFOS ZUR KAMPAGNE",
"LIVE Glow Up ist eine exklusive 4-wöchige Wachstum Challenge.",
"Wofür brauche ich das?",
"Damit du stärkere LIVE-Gewohnheiten aufbaust.",
"",
"### Superstars",
"Du kannst teilnehmen, wenn du eine Einladung hast.",
"",
"KAMPAGNENZEITPLAN",
"· Woche 1: 5. - 11.10.",
"",
"TEILNAHME",
"Länder: DE, CH, AT, LI, LU",
].join("\n");
const strukt = evt("Glow Up Struktur", tag(-2), tag(20),
"Tägliche Mission\nWöchentliche Mission", struktText,
"7690927903777262344", {
geschenke: [
{ id: "1", name: "Feuerwerk", preis: null, hinweis: "Sammle Feuerwerk in deinem LIVE!" },
{ id: "2", name: "Ahorn-Welt", preis: 12000 },
],
aufgabenGruppen: [
{ titel: "Tägliche Mission", beschreibung: "Erfülle sie jeden Tag neu.",
von_tag: "2026-10-05", bis_tag: "2026-10-11" },
],
steckbrief: {
nummer: "7690927903777262344", kurznummer: "1139900164",
region: "EU-TTP2", sprache: "de-DE", verbund: null,
zeitzone: "Europe/Berlin", anmeldung_ab: null, ergebnis_bis: null,
},
});
/* Ein gewoehnlicher Eintrag auf demselben Brett: Er darf sich NICHT
mitverwandeln. Ohne ihn wuerde "nur Events sind betroffen" hier gar
nicht geprueft. */
@@ -249,8 +317,102 @@ melde("\n=== 1. Die Punkte kommen als Liste vom Server ===");
`mit Leerzeichen getippt -> gleich sauber: ${JSON.stringify(p[2]?.text)}`);
ok(p.every((x) => /^[0-9a-f]{16}$/.test(x.schluessel || "")) && p.length > 0,
"jeder Punkt hat einen Schluessel (16 Hexzeichen)");
ok((e?.event_regelpunkte || []).length === 2,
`2 Regelpunkte (${(e?.event_regelpunkte || []).length})`);
/* GEGLIEDERT STATT FLACH (07.10.2026). Hier stand
`event_regelpunkte` -- eine Liste gleichrangiger Zeilen. Der
Regeltext dieses Events hat keine Ueberschrift, also ist es
EIN Abschnitt ohne Titel mit zwei Punkten. Genau das wird
gemessen: nicht „es kommt irgendetwas", sondern die Form. */
const ab = e?.event_regelabschnitte || [];
ok(ab.length === 1 && ab[0].titel === null && ab[0].zeilen.length === 2,
`ein Abschnitt ohne Titel mit 2 Zeilen: ${JSON.stringify(ab)}`);
ok(ab[0]?.zeilen.every((z) => z.art === "punkt"),
"beide Zeilen sind Punkte, keine Absaetze");
/* DER ROHTEXT KOMMT WEITERHIN MIT -- er ist der dritte Ausgang,
wenn die Gliederung einmal ausfaellt. */
ok(typeof e?.event_regeln === "string" && e.event_regeln.includes("eigenen Konto"),
"der Rohtext steht als Rueckfall weiterhin in der Antwort");
/* ---- Das gegliederte Event: Geschenke, Steckbrief, Gruppen -- */
const s = (daten.eintraege || []).find((x) => x.titel === "Glow Up Struktur");
ok(!!s, "das gegliederte Event ist auf dem Brett");
const sab = s?.event_regelabschnitte || [];
const titel = sab.map((a) => a.titel);
ok(titel.includes("SAMMLE DEIN FEUERWERK") && titel.includes("INFOS ZUR KAMPAGNE")
&& titel.includes("KAMPAGNENZEITPLAN") && titel.includes("TEILNAHME"),
`die vier Ueberschriften sind erkannt: ${JSON.stringify(titel)}`);
const feuer = sab.find((a) => a.titel === "SAMMLE DEIN FEUERWERK");
ok(feuer?.unter === "Erhalte doppelte Punkte für die Rangliste",
`der Untertitel steht getrennt: ${JSON.stringify(feuer?.unter)}`);
ok(feuer?.art === "geschenke", `der Abschnitt ist als Geschenke erkannt (${feuer?.art})`);
/* DIE GEGENPROBE ZUM 0-COINS-FEHLER. `preis: null` darf NICHT
als 0 herauskommen -- „0 Coins" ist eine Preisangabe, und
zwar eine falsche. */
const gaben = (feuer?.zeilen || []).filter((z) => z.art === "geschenk");
ok(gaben.length === 2, `zwei Geschenke (${gaben.length})`);
const ohne = gaben.find((z) => z.text === "Feuerwerk");
const mit = gaben.find((z) => z.text === "Ahorn-Welt");
ok(ohne && (ohne.coins === null || ohne.coins === undefined),
`ein Geschenk ohne Preis hat KEINEN Preis, nicht 0: ${JSON.stringify(ohne)}`);
ok(mit?.coins === 12000, `und eines mit Preis hat ihn: ${JSON.stringify(mit?.coins)}`);
ok(ohne?.zusatz === "Sammle Feuerwerk in deinem LIVE!",
`der Hinweis aus der Geschenkliste kommt mit: ${JSON.stringify(ohne?.zusatz)}`);
/* Die Unterstufe aus einem fremden Text. */
const supers = sab.find((a) => a.titel === "Superstars");
ok(supers?.stufe === 3, `eine Unterueberschrift ist Stufe 3 (${supers?.stufe})`);
ok(sab.find((a) => a.titel === "INFOS ZUR KAMPAGNE")?.stufe === 2,
"und ein eigener Abschnitt Stufe 2");
/* Die Frage mit Antwort darunter. */
const infos = sab.find((a) => a.titel === "INFOS ZUR KAMPAGNE");
ok((infos?.zeilen || []).some((z) => z.art === "frage" && z.text === "Wofür brauche ich das?"),
`die Frage mit Antwort darunter ist erkannt: ${JSON.stringify(infos?.zeilen)}`);
/* GEGENPROBE: Eine Zeile aus nichts als Laendercodes waere
gross geschrieben und wuerde zur Ueberschrift. `regelnText`
schreibt deshalb „Länder: ..." -- und genau das muss ein
ABSATZ bleiben, kein Titel. */
const teil = sab.find((a) => a.titel === "TEILNAHME");
ok((teil?.zeilen || []).some((z) => z.art === "text" && z.text.startsWith("Länder:")),
`„Länder: DE, CH, ..." bleibt ein Absatz: ${JSON.stringify(teil?.zeilen)}`);
ok(!titel.includes("DE, CH, AT, LI, LU"),
"und wird nicht zur Ueberschrift");
/* Die Hinweise stehen in einem eigenen Abschnitt ohne Titel --
nicht zwischen den Regeln. Das war der Mangel im
Bildschirmfoto. */
const hin = sab.find((a) => a.art === "hinweise");
ok(hin && hin.titel === null && hin.zeilen.length === 2
&& hin.zeilen.every((z) => z.art === "hinweis"),
`die zwei Hinweise stehen fuer sich: ${JSON.stringify(hin)}`);
/* Steckbrief und Aufgabenbeschreibung. */
ok(s?.event_steckbrief?.kurznummer === "1139900164",
`der Steckbrief kommt mit: ${JSON.stringify(s?.event_steckbrief)}`);
ok(s?.event_steckbrief?.verbund === undefined,
"ein leeres Feld faellt weg, statt als Leerstelle zu erscheinen");
const tagMission = (s?.event_punkte || []).find((x) => x.text === "Tägliche Mission");
ok(tagMission?.beschreibung === "Erfülle sie jeden Tag neu.",
`die Beschreibung haengt an der Aufgabe: ${JSON.stringify(tagMission)}`);
ok(tagMission?.von_tag === "2026-10-05" && tagMission?.bis_tag === "2026-10-11",
"und ihr Zeitraum dazu");
const woche = (s?.event_punkte || []).find((x) => x.text === "Wöchentliche Mission");
ok(woche && woche.beschreibung === undefined,
"eine Aufgabe ohne Gruppe bekommt kein leeres Feld");
/* GEGENPROBE: Die drei Events OHNE Kampagnendaten bekommen
weder Geschenke noch Steckbrief. Ohne sie waere unbelegt,
dass die Felder aus `kampagne_daten` kommen und nicht
irgendwo erfunden werden. */
ok(e?.event_steckbrief === undefined,
"ein Event ohne Kampagnendaten hat keinen Steckbrief");
ok(!(e?.event_regelabschnitte || []).some((a) => a.art === "geschenke"),
"und keinen Geschenkabschnitt");
ok(e?.kampagne_daten === undefined,
"die Rohspalte selbst geht NICHT an die Oberflaeche");
ok(s?.kampagne_daten === undefined,
"auch nicht beim Event, das sie hat");
ok(e?.event_geschafft === 0, `noch nichts abgehakt (${e?.event_geschafft})`);
/* GEGENPROBE: Ein Eintrag, der KEIN Event ist, bekommt keine Punkte.
@@ -443,8 +605,16 @@ async function alsRolle(rolle, code, breite = 1280) {
{
const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001", 1280);
/* VIER SEIT DEM 07.10.2026: dazu kam „Glow Up Struktur" mit
echter Gliederung. Die Zahl steht hier als Bedingung und
nicht nur im Meldetext -- ein stiller Rueckgang waere sonst
unsichtbar (CLAUDE.md, 28.08.2026).
ZUGEKLAPPTE GRUPPEN ZAEHLEN MIT: „Vorbei" steht von sich aus
zu, aber `hidden` entfernt ein Element nicht aus dem Baum.
Waere das anders, muesste hier erst aufgeklappt werden. */
const karten = await seite.$$('.eintrag-karte[data-eventkarte="ja"]');
ok(karten.length === 3, `3 Eventkarten (${karten.length}) -- die Schulung ist keine`);
ok(karten.length === 4, `4 Eventkarten (${karten.length}) -- die Schulung ist keine`);
/* GEGENPROBE: Die Schulung bleibt eine gewoehnliche Karte. Ohne sie
waere "nur Events sind umgebaut" unbelegt. */
@@ -844,6 +1014,362 @@ async function alsRolle(rolle, code, breite = 1280) {
await ctx.close();
}
/* =======================================================================
4b. SIEHT MAN DIE GLIEDERUNG? (07.10.2026)
Das ist der Abschnitt, der Filipes Bildschirmfoto beantwortet. Und
zwar MESSEND: „es gibt ein h5" beweist nichts -- ein h5, das
genauso aussieht wie der Absatz darunter, ist genau der Zustand,
der beanstandet wurde. Gemessen werden deshalb die berechneten
Werte und ihr UNTERSCHIED.
EIN GRUENER HAKEN, DER NICHTS MISST, IST DAS SCHLIMMERE VON ZWEIEN
(CLAUDE.md, 01.09.2026): Jede Bedingung hier enthaelt die Zahl der
tatsaechlich untersuchten Dinge, und zu jeder Aussage „so sieht es
aus" gehoert eine, die „und so NICHT" sagt.
======================================================================= */
melde("\n=== 4b. Die Gliederung auf dem Bildschirm ===");
{
const { ctx, seite } = await alsRolle("admin", "CODE-DOGI-0001", 1280);
/* ---- Die Kachel ist zu, und das ist der Zweck --------------- */
const zuerst = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("Glow Up Struktur"));
if (!k) return { fehlt: true };
const mehr = k.querySelector(".ev-mehr__inhalt");
const knopf = k.querySelector(".ev-mehr__knopf");
return {
hoeheZu: Math.round(k.getBoundingClientRect().height),
verborgen: !!mehr?.hidden,
gedrueckt: knopf?.getAttribute("aria-expanded"),
zahl: k.querySelector(".ev-mehr__zahl")?.textContent.trim() || null,
wort: k.querySelector(".ev-mehr__wort")?.textContent.trim() || null,
/* Was TROTZ zugeklapptem Kasten dasteht. */
gaben: [...k.querySelectorAll(".ev-block--gaben .ev-gabe")].map((g) => ({
name: g.querySelector(".ev-gabe__name")?.textContent.trim(),
coins: g.querySelector(".ev-gabe__coins")?.textContent.trim() || null,
zusatz: g.querySelector(".ev-gabe__zusatz")?.textContent.trim() || null,
})),
hinweise: [...k.querySelectorAll(".ev-hinweise .ev-hinweis")]
.map((h) => h.textContent.trim()),
/* Die Hinweise duerfen NICHT in der Abschnittsliste stehen --
genau das war der Mangel. */
hinweisInRegeln: k.querySelectorAll(".ev-abschnitte .ev-hinweis").length,
zusatz: k.querySelector(".ev-punkt__zusatz")?.textContent.trim() || null,
};
});
melde(" -- zu: " + JSON.stringify(zuerst));
ok(!zuerst.fehlt, "die gegliederte Kachel steht auf dem Brett");
ok(zuerst.verborgen === true && zuerst.gedrueckt === "false",
`der Infokasten ist von sich aus zu (hidden=${zuerst.verborgen}, `
+ `aria-expanded=${zuerst.gedrueckt})`);
ok(zuerst.wort === "Alle Infos" && /Abschnitt/.test(zuerst.zahl || ""),
`und der Knopf sagt, was dahinter steht: "${zuerst.wort} / ${zuerst.zahl}"`);
/* ---- Die Geschenke stehen OHNE Aufklappen da ---------------- */
ok(zuerst.gaben.length === 2,
`zwei Geschenke sind sofort sichtbar (${zuerst.gaben.length})`);
const ahorn = zuerst.gaben.find((g) => g.name === "Ahorn-Welt");
const feuer = zuerst.gaben.find((g) => g.name === "Feuerwerk");
ok(/12\.000/.test(ahorn?.coins || "") && /Coins/.test(ahorn?.coins || ""),
`der Coinpreis steht mit Tausenderpunkt da: ${JSON.stringify(ahorn?.coins)}`);
/* DIE GEGENPROBE ZUM 0-COINS-FEHLER, diesmal auf dem Bildschirm:
Ein Kampagnengeschenk ohne Preis darf KEINE Preisplakette
bekommen. „0 Coins" waere eine falsche Preisangabe. */
ok(feuer && feuer.coins === null,
`ein Geschenk ohne Preis zeigt keinen Preis: ${JSON.stringify(feuer)}`);
ok(feuer?.zusatz === "Sammle Feuerwerk in deinem LIVE!",
`stattdessen steht da, wie man es bekommt: ${JSON.stringify(feuer?.zusatz)}`);
/* ---- Die Hinweise stehen fuer sich ------------------------- */
ok(zuerst.hinweise.length === 2,
`die zwei Hinweise stehen sichtbar in ihrem eigenen Kasten (${zuerst.hinweise.length})`);
ok(zuerst.hinweisInRegeln === 0,
`und KEINER zwischen den Regeln (${zuerst.hinweisInRegeln}) -- das war der Mangel`);
/* ---- Die Beschreibung haengt unter der Aufgabe -------------- */
ok(/Erfülle sie jeden Tag neu/.test(zuerst.zusatz || "")
&& /05\.10\.2026/.test(zuerst.zusatz || ""),
`unter der Aufgabe stehen Beschreibung und Zeitraum: ${JSON.stringify(zuerst.zusatz)}`);
/* ---- Aufklappen: wird die Kachel laenger? ------------------- */
/* DIE RICHTIGE KACHEL (berichtigt 07.10.2026).
Hier stand `.eintrag-karte[data-eventkarte="ja"] .ev-mehr__knopf`
-- das traf die ERSTE Eventkarte („Oktober Event"), nicht die
gegliederte. Gemessen wurde danach eine Kachel, deren Kasten
weiterhin zu war: „spart 0 px", „0 px eingerueckt",
„Untertitel null". Drei rote Zeilen, kein Schaden am Code.
Das ist die Sorte Fehlalarm, die Vertrauen kostet (CLAUDE.md,
26.09.2026) -- deshalb wird die Kachel ueber ihren TITEL
gesucht, nicht ueber ihre Position. */
const knopfNr = await seite.evaluate(() => {
const alle = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')];
const i = alle.findIndex((x) => x.textContent.includes("Glow Up Struktur"));
return i;
});
ok(knopfNr >= 0, `die gegliederte Kachel ist Karte Nr. ${knopfNr}`);
await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("Glow Up Struktur"));
k.querySelector(".ev-mehr__knopf").click();
});
await seite.waitForTimeout(300);
const danach = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("Glow Up Struktur"));
const mehr = k.querySelector(".ev-mehr__inhalt");
const lies = (w) => {
const s = getComputedStyle(w);
return {
groesse: Math.round(parseFloat(s.fontSize) * 100) / 100,
gewicht: Number(s.fontWeight),
sperrung: s.letterSpacing,
gross: s.textTransform,
farbe: s.color,
text: w.textContent.trim().slice(0, 40),
};
};
const k2 = k.querySelector('.ev-abschnitt[data-stufe="2"] > .ev-abschnitt__titel');
const k3 = k.querySelector('.ev-abschnitt[data-stufe="3"] > .ev-abschnitt__titel');
const abs = k.querySelector(".ev-abschnitt .ev-absatz");
const frage = k.querySelector(".ev-abschnitt .ev-frage");
return {
hoeheAuf: Math.round(k.getBoundingClientRect().height),
verborgen: !!mehr?.hidden,
titel: [...k.querySelectorAll(".ev-abschnitt__titel")].map((h) => h.textContent.trim()),
stufe2: k2 ? lies(k2) : null,
stufe3: k3 ? lies(k3) : null,
absatz: abs ? lies(abs) : null,
frage: frage ? lies(frage) : null,
/* DER UNTERTITEL DES GESCHENKABSCHNITTS STEHT IM
GESCHENKBLOCK (berichtigt 07.10.2026).
Hier stand `.ev-abschnitt__unter`. „SAMMLE DEIN FEUERWERK
— Erhalte doppelte Punkte" ist aber genau der Abschnitt,
der aus den Regeln HERAUSGENOMMEN und nach oben gezogen
wird (damit die Geschenke ohne Aufklappen sichtbar sind).
Sein Untertitel heisst dort `.ev-gaben__unter`. Die
Pruefung suchte ihn an der Stelle, an der er absichtlich
nicht mehr steht.
BEIDE WERDEN JETZT GEMESSEN: `unter` fuer einen
gewoehnlichen Abschnitt (es gibt hier keinen mit
Untertitel, also null -- das ist in Ordnung) und
`gabenWoher`/`gabenUnter` fuer den, um den es geht. */
unter: k.querySelector(".ev-gaben__unter")?.textContent.trim() || null,
gabenWoher: (() => {
const p = k.querySelector(".ev-gaben__woher");
if (!p) return null;
const u = p.querySelector(".ev-gaben__unter");
const ganz = p.textContent.trim();
return u ? ganz.slice(0, ganz.length - u.textContent.trim().length).trim() : ganz;
})(),
/* DIE EINRUECKUNG WIRD AM TEXT GEMESSEN, NICHT AM KASTEN
(berichtigt 07.10.2026).
Hier stand der Abstand der beiden ABSCHNITTE. Der ist 0,
und das ist richtig: Beide sind volle Kinder derselben
Spalte, die Einrueckung entsteht durch `padding-left` --
sie verschiebt den INHALT, nicht das Rechteck. Gemessen
kam „0 px eingerueckt" heraus, obwohl der Text sichtbar
eingerueckt war.
Was man sehen soll, ist der Text. Also wird der gemessen. */
einzug: (() => {
const h3 = k.querySelector('.ev-abschnitt[data-stufe="3"] > .ev-abschnitt__titel');
const h2 = k.querySelector('.ev-abschnitt[data-stufe="2"] > .ev-abschnitt__titel');
return h3 && h2
? Math.round(h3.getBoundingClientRect().x - h2.getBoundingClientRect().x)
: null;
})(),
steck: [...k.querySelectorAll(".ev-steck__name")].map((x) => x.textContent.trim()),
steckWerte: [...k.querySelectorAll(".ev-steck__wert")].map((x) => x.textContent.trim()),
};
});
melde(" -- auf: " + JSON.stringify(danach).slice(0, 700));
ok(danach.verborgen === false,
"ein Druck macht den Infokasten auf");
/* DAS IST DIE ANTWORT AUF „die kacheln sollen nicht so lang sein":
nicht „sie ist kurz", sondern „sie ist MESSBAR kuerzer als mit
allem offen". Eine Kachel, die zugeklappt genauso hoch ist, hat
nichts gespart. */
ok(danach.hoeheAuf > zuerst.hoeheZu + 80,
`zugeklappt spart die Kachel ${danach.hoeheAuf - zuerst.hoeheZu} px `
+ `(${zuerst.hoeheZu} statt ${danach.hoeheAuf})`);
/* ---- Und jetzt: SIEHT man die Ueberschrift? ----------------- */
ok(danach.titel.length >= 4,
`${danach.titel.length} Ueberschriften stehen da: ${JSON.stringify(danach.titel.slice(0, 6))}`);
ok(!!danach.stufe2 && !!danach.absatz,
"Ueberschrift und Absatz sind beide da (sonst misst der Vergleich nichts)");
/* DREI UNTERSCHIEDE, NICHT EINER. Ein einziger (etwa nur die
Farbe) wuerde bei einem anderen Farbschema wieder verschwinden. */
const u2 = danach.stufe2 || {};
const ua = danach.absatz || {};
const unterschiede = [
["Gewicht", u2.gewicht, ua.gewicht],
["Sperrung", u2.sperrung, ua.sperrung],
["Grossschreibung", u2.gross, ua.gross],
["Farbe", u2.farbe, ua.farbe],
].filter(([, a, b]) => a !== b);
ok(unterschiede.length >= 3,
`die Ueberschrift unterscheidet sich in ${unterschiede.length} von 4 Merkmalen `
+ `vom Absatz: ${JSON.stringify(unterschiede)}`);
ok(u2.gewicht >= 700 && ua.gewicht < 700,
`sie ist fett, der Absatz nicht (${u2.gewicht} gegen ${ua.gewicht})`);
ok(u2.gross === "uppercase" && ua.gross === "none",
`sie steht in Grossbuchstaben, der Absatz nicht (${u2.gross} / ${ua.gross})`);
/* Die Unterstufe sieht anders aus als die Oberstufe UND anders
als ein Absatz -- sonst waere die zweite Stufe keine. */
ok(!!danach.stufe3, "eine Unterueberschrift ist da");
const u3 = danach.stufe3 || {};
ok(u3.gross === "none" && u3.gewicht >= 600,
`die Unterstufe ist halbfett und NICHT in Grossbuchstaben `
+ `(${u3.gewicht}, ${u3.gross})`);
ok(u3.gewicht > ua.gewicht,
`aber immer noch kraeftiger als ein Absatz (${u3.gewicht} gegen ${ua.gewicht})`);
ok(danach.einzug >= 8,
`und sie ist ${danach.einzug} px eingerueckt (Grenze 8)`);
/* Der Untertitel hinter dem Gedankenstrich steht getrennt -- und
die Ueberschrift der Kampagne bleibt erhalten. „Sammle dein
Feuerwerk" sagt, WARUM diese Geschenke zaehlen; ein selbst
erfundenes „Geschenke" daneben haette das verschluckt. */
ok(danach.gabenWoher === "SAMMLE DEIN FEUERWERK",
`die Ueberschrift der Kampagne steht ueber den Geschenken: `
+ `${JSON.stringify(danach.gabenWoher)}`);
ok(danach.unter === "Erhalte doppelte Punkte für die Rangliste",
`der Untertitel steht als eigene Zeile: ${JSON.stringify(danach.unter)}`);
/* Die Frage ist kraeftiger als der Absatz unter ihr. */
ok(!!danach.frage && danach.frage.gewicht > ua.gewicht,
`eine Frage mit Antwort darunter steht kraeftiger da `
+ `(${danach.frage?.gewicht} gegen ${ua.gewicht}): ${JSON.stringify(danach.frage?.text)}`);
/* Der Steckbrief -- deutsche Namen, keine Rohfelder. */
ok(danach.steck.includes("Kurznummer") && danach.steck.includes("Markt"),
`der Steckbrief nennt die Felder auf Deutsch: ${JSON.stringify(danach.steck)}`);
ok(danach.steckWerte.includes("1139900164"),
`und zeigt die Werte: ${JSON.stringify(danach.steckWerte)}`);
ok(!danach.steck.some((n) => /_|activity|flag/i.test(n)),
"kein Rohfeldname steht auf der Kachel");
/* ---- Die eigene Wahl haelt das Neuladen aus ---------------- */
await seite.reload({ waitUntil: "networkidle" });
await seite.waitForSelector(".ev-mehr__knopf", { timeout: 15000 });
await seite.waitForTimeout(600);
const nachher = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.find((x) => x.textContent.includes("Glow Up Struktur"));
return { verborgen: !!k?.querySelector(".ev-mehr__inhalt")?.hidden };
});
/* WARUM DAS WICHTIG IST: Das Brett baut sich bei jeder
Aktualisierung neu auf. Eine Kachel, die dabei zuklappt,
waehrend man liest, ist aergerlicher als eine lange. */
ok(nachher.verborgen === false,
"aufgeklappt bleibt aufgeklappt, auch nach dem Neuladen");
/* ---- Die Gruppen der Spalten ------------------------------- */
const gruppen = await seite.evaluate(() => {
const seiten = [...document.querySelectorAll(".kv-seite")].map((s) => ({
seite: s.dataset.seite,
gruppen: [...s.querySelectorAll(".ev-gruppe__kopf")].map((g) => ({
name: g.dataset.gruppe,
wort: g.querySelector(".ev-gruppe__wort")?.textContent.trim(),
zahl: g.querySelector(".ev-gruppe__zahl")?.textContent.trim(),
zu: g.nextElementSibling?.hidden === true,
})),
}));
return { seiten };
});
melde(" -- Gruppen: " + JSON.stringify(gruppen));
const alleGruppen = gruppen.seiten.flatMap((s) => s.gruppen);
ok(alleGruppen.length >= 3,
`${alleGruppen.length} Gruppen stehen in den Spalten`);
const laeuft = alleGruppen.filter((g) => g.name === "laeuft");
const vorbei = alleGruppen.filter((g) => g.name.startsWith("vorbei"));
ok(laeuft.length >= 1 && laeuft.every((g) => !g.zu),
`„Laeuft gerade" steht offen (${laeuft.length})`);
ok(vorbei.length >= 1 && vorbei.every((g) => g.zu),
`und Vergangenes zu (${vorbei.length}: `
+ `${vorbei.map((g) => g.wort).join(", ")})`);
/* KEINE LEERE GRUPPE. Fuenf Ueberschriften ueber nichts waeren
schlimmer als eine lange Spalte. */
ok(alleGruppen.every((g) => Number(g.zahl) > 0),
`jede Gruppe hat Eintraege: ${JSON.stringify(alleGruppen.map((g) => g.zahl))}`);
/* UND DIE SUMME STIMMT: Keine Karte faellt zwischen zwei Gruppen
durch. Ohne diese Zeile waere „es gibt Gruppen" vereinbar damit,
dass ein Event in keiner davon auftaucht. */
const summe = alleGruppen.reduce((s, g) => s + Number(g.zahl), 0);
ok(summe === 4, `alle 4 Events stehen in genau einer Gruppe (Summe ${summe})`);
/* GEGENPROBE: Links und rechts teilen sich den Zustand NICHT.
Dieser Fehler war bei den Zeitstufen schon einmal da.
ERST DRUECKEN, DANN NACHSEHEN (berichtigt 07.10.2026). Vorher
stand hier `... || merker.length === 0` -- und weil nie
jemand eine Gruppe angefasst hatte, war die Liste leer und
die Zeile gruen, ohne etwas gemessen zu haben. Genau die
Sorte Haken, die immer bestaetigt (CLAUDE.md, 01.09.2026).
Jetzt wird eine Gruppe LINKS zugeklappt und danach geprueft,
dass (1) ein Merker entstanden ist, (2) er die Seite nennt
und (3) die gleichnamige Gruppe RECHTS davon unberuehrt ist. */
const vorher = await seite.evaluate(() => {
const links = document.querySelector('.kv-seite[data-seite="tiktok"] .ev-gruppe__kopf');
const name = links?.dataset.gruppe;
const rechts = [...document.querySelectorAll('.kv-seite[data-seite="agentur"] .ev-gruppe__kopf')]
.find((g) => g.dataset.gruppe === name);
links?.click();
return {
name,
linksZu: links?.nextElementSibling?.hidden === true,
rechtsDa: !!rechts,
rechtsZu: rechts ? rechts.nextElementSibling?.hidden === true : null,
};
});
const merker = await seite.evaluate(() => Object.keys(localStorage)
.filter((s) => s.startsWith("abschnitt:agentur:")));
melde(" -- nach dem Druck: " + JSON.stringify({ ...vorher, merker }));
ok(merker.length > 0 && merker.every((s) => /:(tiktok|agentur):/.test(s)),
`der Merker nennt die Seite: ${JSON.stringify(merker)}`);
/* DIESE ZEILE WAR SELBST FALSCH (berichtigt 07.10.2026).
Hier stand `!s.includes(":agentur:")`. Der Merker heisst aber
`abschnitt:agentur:tiktok:laeuft` -- und „agentur" darin ist der
BRETTNAME, nicht die Seite. Die Zeile war damit nie erfuellbar.
Zwei Dinge heissen gleich und sind verschieden; genau dieser
Fehler stand am 07.10.2026 schon einmal im Projekt (das Feld
„Gilt für" nennt das HAUS, nicht das Brett).
Gemessen wird jetzt die Form des Schluessels GANZ: genau einer,
und er nennt die angefasste Seite. Das ist die Aussage, auf die
es ankommt -- der Zustand ist je Seite abgelegt, also kann ein
Druck links rechts nichts bewirken. */
ok(merker.length === 1 && merker[0] === `abschnitt:agentur:tiktok:${vorher.name}`,
`genau ein Merker, fuer die angefasste Seite: ${JSON.stringify(merker)}`);
/* UND DIE ANDERE SEITE STEHT NOCH SO DA WIE VORHER. Gemessen an
der Gruppe, die es dort gibt -- „Kommt noch" war offen und muss
offen bleiben, obwohl links gerade zugeklappt wurde. */
const andereSeite = await seite.evaluate(() => {
const g = [...document.querySelectorAll('.kv-seite[data-seite="agentur"] .ev-gruppe__kopf')]
.map((x) => ({ name: x.dataset.gruppe, zu: x.nextElementSibling?.hidden === true }));
return g;
});
const kommt = andereSeite.find((g) => g.name === "kommt");
ok(!!kommt && kommt.zu === false,
`rechts ist nichts mitgeklappt: ${JSON.stringify(andereSeite)}`);
ok(vorher.linksZu === true,
`die angefasste Gruppe links ist zu (${vorher.name})`);
await ctx.close();
}
melde("\n=== 5. Abhaken im Browser, mit Neuladen ===");
{
const { ctx, seite } = await alsRolle("creator", "CODE-CREA-0001", 1280);
@@ -1037,11 +1563,42 @@ melde("\n=== 7. Auf dem Handy (412 px) ===");
199 px breit. Dass die Regel UMSCHALTET, muss gemessen werden;
sonst beweist „zwei nebeneinander" am Rechner nur, dass es dort
passt, und nichts darueber, was auf dem Telefon passiert. */
/* GEMESSEN WIRD, WAS MAN SIEHT (berichtigt 07.10.2026).
Hier standen ALLE Eventkarten. Seit die Spalten nach Zustand
und Alter gegliedert sind, steht „Vorbei — aelter" von sich
aus zugeklappt -- und eine Karte in einem `hidden`-Kasten hat
ein Rechteck von 0x0 an Position 0,0. Gemessen kam
„379, 379, 379, 379, 0 px" heraus, und die Pruefung meldete
eine zu schmale Karte, obwohl keine zu schmal war.
ZWEI SCHAEDEN, NICHT EINER: Die Paarprobe daneben haette bei
ZWEI zugeklappten Karten „zwei auf gleicher Hoehe" gemeldet --
beide liegen auf y=0. Das waere ein Fehlalarm, der erst
auftritt, wenn ein zweites Event vorbeigelaufen ist, also
irgendwann von selbst. Solche Fehlalarme kosten das Vertrauen
in den ganzen Satz (CLAUDE.md, 26.09.2026).
UND DAS VERSCHWINDEN WIRD SELBST GEPRUEFT: Eine unsichtbare
Karte darf nur deshalb unsichtbar sein, weil ihre Gruppe zu
ist. Ohne diese zweite Haelfte haette „wir messen nur die
sichtbaren" genau den Fall weggeschaut, in dem eine Karte
wirklich fehlt. */
const schmal = await seite.evaluate(() => {
const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.map((x) => { const r = x.getBoundingClientRect(); return { x: Math.round(r.x), y: Math.round(r.y), b: Math.round(r.width) }; });
const alle = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')]
.map((x) => {
const r = x.getBoundingClientRect();
const gruppe = x.closest(".ev-gruppe__inhalt");
return {
x: Math.round(r.x), y: Math.round(r.y), b: Math.round(r.width),
sichtbar: r.width > 0 && r.height > 0,
inZu: !!(gruppe && gruppe.hidden),
};
});
const k = alle.filter((a) => a.sichtbar);
return {
karten: k,
versteckt: alle.filter((a) => !a.sichtbar),
/* Stehen zwei auf gleicher Hoehe? Dann waere nicht umgeschaltet. */
paar: k.some((a, i) => k.slice(i + 1).some((b2) => Math.abs(a.y - b2.y) < 5)),
quer: document.documentElement.scrollWidth - document.documentElement.clientWidth,
@@ -1051,6 +1608,10 @@ melde("\n=== 7. Auf dem Handy (412 px) ===");
`auf 412 px steht eine Karte je Reihe (${schmal.karten.map((a) => `${a.b}px@${a.y}`).join(", ")})`);
ok(schmal.karten.every((a) => a.b > 330),
`und sie ist breit genug zum Bedienen (${schmal.karten.map((a) => a.b).join(", ")} px)`);
ok(schmal.versteckt.every((a) => a.inZu),
`was man nicht sieht, steckt in einer zugeklappten Gruppe `
+ `(${schmal.versteckt.length} verborgen, davon `
+ `${schmal.versteckt.filter((a) => a.inZu).length} erklaert)`);
ok(schmal.quer <= 1,
`die Seite laesst sich nicht seitlich schieben (${schmal.quer} px)`);
/* UND WENN DOCH, DANN WELCHES ELEMENT? Eine Zahl allein schickt
+365 -2
View File
@@ -149,7 +149,8 @@ for (const [name, s] of Object.entries(SEITEN)) {
const {
leseKampagne, regelnText, aufgabenText, tagInZone, textAusHtml,
KampagneUnlesbar, datenAusSeite,
KampagneUnlesbar, datenAusSeite, gliederung, deutschesDatum, zeitOderNull,
geruestVon, bausteine,
} = await import("./kampagne-lesen.mjs");
/* =====================================================================
@@ -394,9 +395,50 @@ melde("=== 4. Was in event_aufgaben und event_regeln landet ===");
ok(r.includes("1. Liga") && r.includes("2. Liga") && r.includes("5. Liga"),
"die Ligen sind durchnummeriert (1. bis 5.)");
{
/* `### ` DAVOR SEIT DEM 07.10.2026 -- und das ist der Zweck.
Hier stand `=== "3. Liga"`. Auf der Seite ist ein
Listenpunkt, dessen GANZER Inhalt hervorgehoben ist, eine
Überschrift mit Nummer: Darunter steht jeweils die Bedingung
für diese Liga. Flach ausgegeben standen zwölf
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 (`<ol start>`), und das
`&nbsp;` hinter `</strong>` darf nicht stören -- die fünfte
Liga hat eines, die ersten vier nicht. */
const rohHtml = "<ol start=\"3\"><li><strong>Liga&nbsp;</strong></li></ol>";
ok(textAusHtml(rohHtml) === "3. Liga",
ok(textAusHtml(rohHtml) === "### 3. Liga",
`Gegenprobe am Baustein selbst: ${JSON.stringify(textAusHtml(rohHtml))}`);
const mitNbsp = "<ol start=\"5\"><li><strong>Liga&nbsp;</strong>&nbsp;</li></ol>";
ok(textAusHtml(mitNbsp) === "### 5. Liga",
`auch mit geschütztem Leerzeichen dahinter: ${JSON.stringify(textAusHtml(mitNbsp))}`);
/* GEGENPROBE: Ein Listenpunkt mit gewöhnlichem Text wird KEINE
Überschrift. Ohne sie wäre unbelegt, dass die Hervorhebung
entscheidet und nicht einfach jede Zeile eine Überschrift
wird. */
const schlicht = "<ol start=\"2\"><li>Nur Text, nicht fett</li></ol>";
ok(textAusHtml(schlicht) === "2. Nur Text, nicht fett",
`ein gewöhnlicher Punkt bleibt ein Punkt: ${JSON.stringify(textAusHtml(schlicht))}`);
/* Und ein Absatz, dessen ganzer Inhalt hervorgehoben ist,
ebenfalls -- so stehen die Ligennamen auf der Seite. */
const absatz = "<p style=\"text-align: center;\"><strong>Superstars</strong></p>"
+ "<p>Du kannst teilnehmen, wenn ...</p>";
/* MIT LEERZEILE DAZWISCHEN -- so soll es sein. Die
Überschrift bekommt ihren eigenen Umbruch, der Absatz
seinen; `gliederung()` wirft leere Zeilen ohnehin weg, und
im Rohtext, den DogFather beim Bearbeiten sieht, liest sich
das sauber. Meine Erwartung von EINEM Umbruch war
geraten. */
ok(textAusHtml(absatz) === "### Superstars\n\nDu kannst teilnehmen, wenn ...",
`ein Absatz nur aus Hervorhebung wird Überschrift: ${JSON.stringify(textAusHtml(absatz))}`);
/* GEGENPROBE dazu: Eine Frage MIT hervorgehobenem Wort ist auch
der Sache nach eine Überschrift -- aber das zu behaupten wäre
geraten. Sie bleibt ein Absatz. */
const gemischt = "<p>Um was geht es bei <strong>Gipfelstürmer?</strong></p>";
ok(textAusHtml(gemischt) === "Um was geht es bei Gipfelstürmer?",
`eine Frage mit fettem Wort bleibt ein Absatz: ${JSON.stringify(textAusHtml(gemischt))}`);
ok(rohHtml.replace(/<[^>]+>/g, "").trim() === "Liga&nbsp;",
"Gegenprobe: bloßes Markenentfernen verliert die Nummer");
}
@@ -831,6 +873,327 @@ melde("=== 12. Gleiche Eingabe, gleiches Ergebnis ===");
`${gelesen} der fünf Seiten lesen sich, ${geworfen} wirft (die kaputte)`);
}
/* =====================================================================
13. DIE RANGLISTE, DER STECKBRIEF UND DIE MISSIONSGRUPPEN
Drei Bausteine, die in allen vier gespeicherten Seiten dastanden
und bis zum 07.10.2026 nie gelesen wurden. Filipe: „ich will alle
informationen die möglich sind wenn man den link hochlädt."
===================================================================== */
melde("");
melde("=== 13. Rangliste, Steckbrief, Missionsgruppen ===");
{
const g = leseKampagne(seite.gipfel);
const r = g.kuer.rangliste;
ok(!!r, "die Rangliste wird gelesen");
/* DER WERTUNGSZEITRAUM IST NICHT DER KAMPAGNENZEITRAUM -- genau
deshalb lohnt das Lesen. Bei „Gipfelstürmer" sind beide gleich,
bei „LIVE Glow Up" nicht; die Gegenprobe steht unten. */
ok(r.von_tag === "2026-10-04" && r.bis_tag === "2026-10-07",
`gewertet wird ${r.von_tag} bis ${r.bis_tag}`);
ok(r.reiter.length === 6
&& r.reiter[0] === "Superstars" && r.reiter[5] === "5. Liga",
`sechs Gruppen, aus dem Wörterbuch aufgelöst: ${JSON.stringify(r.reiter)}`);
ok(r.punkteVerborgen === false,
`die Punktzahl ist hier sichtbar (${r.punkteVerborgen})`);
ok(typeof r.beschreibung === "string" && r.beschreibung.length > 0,
`und die Wertung ist beschriftet: ${JSON.stringify(r.beschreibung)}`);
/* Der Steckbrief: nur Felder, die man ohne Deutung zeigen kann. */
const s = g.kuer.steckbrief;
ok(s.kurznummer === "1140116740", `Kurznummer ${s.kurznummer}`);
ok(s.region === "EU-TTP2", `Markt ${s.region}`);
ok(s.sprache === "de-DE", `Sprache ${s.sprache}`);
ok(s.zeitzone === "Europe/Berlin", `Zeitzone ${s.zeitzone}`);
/* LEER IST NICHT „—": `union_name` steht in allen vier gemessenen
Seiten leer und wird deshalb zu null. */
ok(s.verbund === null, `der leere Verbund wird null, nicht "" (${JSON.stringify(s.verbund)})`);
/* ANMELDUNG UND ERGEBNIS NUR BEI ABWEICHUNG. Sie sind hier gleich
dem Kampagnenzeitraum; sie trotzdem zu zeigen hieße, dieselbe
Zahl dreimal hinzuschreiben. */
ok(s.anmeldung_ab === null && s.ergebnis_bis === null,
"Anmeldung und Ergebnis fallen weg, weil sie nichts Neues sagen");
/* GEGENPROBE: Die Rohwerte sind sehr wohl da -- sonst wäre
„fällt weg" nicht von „wurde nie gelesen" zu unterscheiden. */
const rohAktion = datenAusSeite(seite.gipfel).globalData.activityInfo;
ok(zeitOderNull(rohAktion.warm_up_start_time) === g.auszug.start_ms,
`Gegenprobe: warm_up_start_time steht da und ist gleich dem Beginn `
+ `(${rohAktion.warm_up_start_time})`);
/* Kein Rohfeld, das niemand lesen kann, rutscht in den
Steckbrief -- die stehen weiter im Auszug, nicht auf der
Kachel. */
ok(!Object.keys(s).some((k) => /activity_type|kind_flag|platform|sdlc|biz/.test(k)),
`nur erklärbare Felder im Steckbrief: ${JSON.stringify(Object.keys(s))}`);
/* ---- Die zweite Vorlage: Missionsgruppen mit Beschreibung ---- */
const h = leseKampagne(seite.aufgabengruppe);
const gr = h.kuer.aufgabenGruppen;
/* ZWEI, NICHT VIER -- und das ist der Zweck der Entdopplung.
In `taskItemList` stehen VIER Einträge; die Vorlage legt je
Reiter zwei an (einen für Creator, einen für Zuschauer), und
beide zeigen auf denselben Wörterbucheintrag. Nachgemessen:
die Platzhalter `..._7_...` und `..._8_...` enden auf
derselben Prüfsumme, also auf demselben Text. Zweimal
derselbe Haken wäre ein Haken an zwei Stellen.
Meine Erwartung von vier war die ROHZAHL, nicht das
Ergebnis. Gemessen wird deshalb beides: vier hinein, zwei
heraus -- sonst wäre „zwei" auch dann grün, wenn die Hälfte
gar nicht gelesen würde. */
/* GEZÄHLT WIRD AM BAUSTEIN, nicht im Text gesucht (berichtigt
07.10.2026). Mein erster Versuch suchte den Platzhalternamen
`taskFormData_displayInfo_title` im ganzen Schema und fand ihn
ACHTMAL -- er steht dort noch an anderen Stellen. Eine Zahl aus
einer Textsuche ist keine Messung der Liste; gezählt wird
`taskItemList` selbst, also genau das, was der Leser liest. */
const rohTeile = bausteine(geruestVon(datenAusSeite(seite.aufgabengruppe)).geruest);
const rohListe = (rohTeile.get("live_task_group") || [])[0]?.props?.taskItemList || [];
ok(rohListe.length === 4, `vier Einträge stehen in der Seite (${rohListe.length})`);
ok(gr.length === 2, `und zwei verschiedene Missionsgruppen bleiben (${gr.length})`);
ok(new Set(gr.map((x) => x.titel)).size === gr.length,
`keine zwei mit demselben Titel: ${JSON.stringify(gr.map((x) => x.titel))}`);
ok(gr.every((x) => typeof x.beschreibung === "string" && x.beschreibung.length > 3),
`jede mit eigener Beschreibung: ${JSON.stringify(gr.map((x) => x.beschreibung.slice(0, 28)))}`);
ok(gr.every((x) => /^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(x.von_tag || "")),
`und mit eigenem Zeitraum: ${JSON.stringify(gr.map((x) => `${x.von_tag}..${x.bis_tag}`))}`);
/* DIE HAKENLISTE IST ENTDOPPELT, DIE GRUPPENLISTE DECKUNGSGLEICH.
Die Vorlage legt je Reiter zwei Einträge an (Creator und
Zuschauer); zweimal derselbe Haken wäre ein Haken an zwei
Stellen. */
ok(h.kuer.aufgaben.length === gr.length
&& h.kuer.aufgaben.every((a, i) => a === gr[i].titel),
`Aufgaben und Gruppen passen Zeile für Zeile zusammen `
+ `(${h.kuer.aufgaben.length} / ${gr.length})`);
/* ---- WAS DIE RANGLISTE WIRKLICH BEITRÄGT ------------------
Hier stand „bei LIVE Glow Up beginnt die Wertung später als
die Kampagne". Das ist nachgemessen FALSCH: In allen drei
Seiten mit Rangliste ist der Wertungszeitraum auf die
Millisekunde gleich dem Kampagnenzeitraum. Ich hatte zwei
verschiedene Kampagnen verglichen. Die ausführliche
Berichtigung steht bei `rangBaustein` in kampagne-lesen.mjs.
GEMESSEN WIRD JETZT, WAS STIMMT -- und zwar beides: dass die
Zeiträume hier gleich sind (damit niemand wieder etwas
anderes behauptet) UND dass der Baustein trotzdem etwas
beiträgt, das sonst nirgends steht: die Gruppen. */
const gleich = [seite.gipfel, seite.ohnePreise, seite.aufgabengruppe]
.map((x) => leseKampagne(x))
.filter((x) => x.kuer.rangliste)
.map((x) => x.kuer.rangliste.von === x.auszug.start_ms
&& x.kuer.rangliste.bis === x.auszug.ende_ms);
ok(gleich.length === 3 && gleich.every(Boolean),
`in allen ${gleich.length} Seiten mit Rangliste ist die Wertung `
+ "gleich dem Kampagnenzeitraum -- gemessen, nicht angenommen");
/* DAS IST DER GEWINN: zwei Gruppen hier, sechs bei
Gipfelstürmer. „Gegen wen trete ich an" stand vorher
nirgends auf der Kachel. */
ok(h.kuer.rangliste.reiter.length === 2
&& g.kuer.rangliste.reiter.length === 6,
`die Gruppen unterscheiden sich sehr wohl: `
+ `${JSON.stringify(h.kuer.rangliste.reiter)} gegen `
+ `${g.kuer.rangliste.reiter.length} Ligen`);
/* UND IM REGELTEXT STEHT ES AUCH. Ohne diese Zeile wäre
geprüft, dass gelesen wird -- nicht, dass es ankommt. */
const rt = regelnText(h.kuer, h.hinweise);
ok(/## RANGLISTE/.test(rt) && /Gruppen: /.test(rt),
"und die Rangliste steht als eigener Abschnitt im Regeltext");
/* Der zweite Bildbaustein. */
ok(h.auszug.weitere_bilder.length === 2,
`zwei weitere Bilder aus live_banner (${h.auszug.weitere_bilder.length})`);
ok(h.auszug.weitere_bilder.every((u) => /^https:\/\//.test(u)),
"und beide mit absoluter Adresse");
/* GEGENPROBE: Geholt wird trotzdem nur EINES -- das Banner bleibt
das aus live_top_image. */
ok(h.kuer.bannerUrl !== h.auszug.weitere_bilder[0],
"das Banner kommt weiterhin aus live_top_image, nicht von dort");
/* Und das deutsche Datum. */
ok(deutschesDatum("2026-11-04") === "04.11.2026",
`deutschesDatum: ${deutschesDatum("2026-11-04")}`);
ok(deutschesDatum("Unsinn") === null, "und Unsinn gibt nichts");
}
/* =====================================================================
14. DIE GLIEDERUNG DES REGELTEXTES
Filipe, mit Bildschirmfoto: „man soll die titel in den texten
besser erkennen und sehen was zu was passt."
Geprüft wird hier die REGEL, nicht die Anzeige -- was auf dem
Bildschirm daraus wird, misst pruef-eventkarte.mjs, Abschnitt 4b.
===================================================================== */
melde("");
melde("=== 14. Der Regeltext, gegliedert ===");
{
/* ZEICHENGLEICH MIT DEM, WAS AM 07.10.2026 WIRKLICH IN DER
DATENBANK STAND (Eintrag 89, „LIVE Glow Up"). Erfundene
Testdaten hätten genau das verdeckt, worum es geht. */
const echt = [
"* Diese Kampagne hat keinen Einleitungstext — der Inhalt steht in den Klapptexten.",
"* Diese Kampagne hat keine Preisliste auf der Seite.",
"",
"SAMMLE DEIN FEUERWERK — Erhalte doppelte Punkte für die Rangliste",
"· Feuerwerk (Sammle Feuerwerk in deinem LIVE!)",
"",
"INFOS ZUR KAMPAGNE",
"LIVE Glow Up ist eine exklusive 4-wöchige Wachstum Challenge.",
"",
"KAMPAGNENZEITPLAN",
"· Woche 1: 5. - 11.10.",
"",
"TEILNAHME",
"Länder: DE, CH, AT, LI, LU",
].join("\n");
const ab = gliederung(echt, {
geschenke: [{ name: "Feuerwerk", preis: null, hinweis: "Sammle Feuerwerk in deinem LIVE!" }],
});
const titel = ab.map((a) => a.titel);
ok(ab.length === 5,
`fünf Abschnitte aus dem echten Text (${ab.length}): ${JSON.stringify(titel)}`);
/* ---- Die Überschrift wird an der Großschreibung erkannt ---- */
ok(titel[1] === "SAMMLE DEIN FEUERWERK",
`die Überschrift ohne ihren Untertitel: ${JSON.stringify(titel[1])}`);
ok(ab[1].unter === "Erhalte doppelte Punkte für die Rangliste",
`der Untertitel getrennt dahinter: ${JSON.stringify(ab[1].unter)}`);
/* ---- Die vier Zeilensorten ---------------------------------- */
const arten = ab.flatMap((a) => a.zeilen.map((z) => z.art));
ok(new Set(arten).size >= 3 && arten.includes("hinweis")
&& arten.includes("geschenk") && arten.includes("punkt") && arten.includes("text"),
`alle vier Zeilensorten kommen vor: ${JSON.stringify([...new Set(arten)])}`);
/* ---- Die Hinweise stehen für sich, nicht zwischen den Regeln - */
ok(ab[0].titel === null && ab[0].art === "hinweise" && ab[0].zeilen.length === 2,
`die zwei Hinweise bilden den ersten, titellosen Abschnitt`);
ok(!ab.slice(1).some((a) => a.zeilen.some((z) => z.art === "hinweis")),
"und keiner steckt in einem anderen Abschnitt -- das war der Mangel");
/* ---- Das Geschenk kommt AUS DER LISTE, nicht aus dem Text --- */
const gabe = ab[1].zeilen.find((z) => z.art === "geschenk");
ok(gabe?.text === "Feuerwerk", `der Name: ${JSON.stringify(gabe?.text)}`);
/* `Number(null)` IST 0 -- und „0 Coins" wäre eine Preisangabe,
und zwar eine falsche. Dieser Fehler war am 07.10.2026 wirklich
da, an drei Stellen. */
ok(gabe && gabe.coins === null,
`ohne Preis bleibt ohne Preis, nicht 0: ${JSON.stringify(gabe?.coins)}`);
ok(gabe?.zusatz === "Sammle Feuerwerk in deinem LIVE!",
`stattdessen steht da, wie man es bekommt: ${JSON.stringify(gabe?.zusatz)}`);
ok(ab[1].art === "geschenke",
`der Abschnitt mit einem Geschenk ist ein Geschenkabschnitt (${ab[1].art})`);
/* GEGENPROBE: Ohne Geschenkliste ist dieselbe Zeile ein
gewöhnlicher Punkt. Sonst wäre unbelegt, dass die LISTE
entscheidet. */
const ohne = gliederung(echt);
const z1 = ohne[1].zeilen.find((z) => z.text.startsWith("Feuerwerk"));
ok(z1?.art === "punkt",
`ohne Liste ist es ein Punkt, kein Geschenk (${z1?.art})`);
/* Und der Coinpreis aus dem TEXT greift, wo es keine Liste gibt --
für von Hand getippte Zeilen. */
const vonHand = gliederung("## GESCHENKE\n· Rose — 1 Coin\n· Löwe — 29999 Coins");
const rose = vonHand[0].zeilen.find((z) => z.text === "Rose");
const loewe = vonHand[0].zeilen.find((z) => z.text === "Löwe");
ok(rose?.coins === 1 && loewe?.coins === 29999,
`aus dem Text gelesen: ${JSON.stringify([rose?.coins, loewe?.coins])}`);
/* DIE LÄNGSTEN NAMEN ZUERST: „Rose" und „Rose Gold" in derselben
Kampagne -- ein Vergleich von vorne träfe bei „Rose Gold" den
Eintrag „Rose" und schriebe dessen Preis an. */
const zwei = gliederung("## GESCHENKE\n· Rose Gold\n· Rose", {
geschenke: [{ name: "Rose", preis: 10 }, { name: "Rose Gold", preis: 500 }],
});
const gold = zwei[0].zeilen.find((z) => z.text === "Rose Gold");
ok(gold?.coins === 500,
`„Rose Gold" bekommt 500, nicht die 10 von „Rose" (${gold?.coins})`);
/* ---- Die Gegenprobe zur Überschriftenerkennung -------------- */
/* Eine Zeile aus nichts als Ländercodes IST großgeschrieben und
würde zur Überschrift. `regelnText` schreibt deshalb „Länder:
..." -- die Kleinbuchstaben im Wort davor sind die Sicherung. */
ok(ab[4].zeilen[0].art === "text",
`„Länder: DE, CH, ..." bleibt ein Absatz (${ab[4].zeilen[0].art})`);
const nackt = gliederung("DE, CH, AT");
ok(nackt[0]?.titel === "DE, CH, AT" || nackt.length === 0,
"ohne Wort davor wäre es tatsächlich eine Überschrift -- "
+ "deshalb schreibt regelnText nie nackte Codes");
/* Keine Überschrift ohne Buchstaben, keine mit Satzzeichen am
Ende, keine über 80 Zeichen. */
const proben = [
["12.10.", "eine Zahl ist keine Überschrift"],
["DAS IST EIN GESCHRIENER SATZ.", "ein Satz mit Punkt am Ende auch nicht"],
["Gemischte Schreibweise", "und Kleinbuchstaben erst recht nicht"],
["A".repeat(90), "und nichts über 80 Zeichen"],
];
for (const [zeile, was] of proben) {
const p = gliederung(zeile);
ok(p.length === 1 && p[0].titel === null, `${was}: ${JSON.stringify(zeile.slice(0, 20))}`);
}
/* ---- Zwei Stufen ------------------------------------------- */
const stufen = gliederung("## OBEN\nText.\n### Unten\nMehr Text.");
ok(stufen.length === 2 && stufen[0].stufe === 2 && stufen[1].stufe === 3,
`## ist Stufe 2, ### ist Stufe 3: ${JSON.stringify(stufen.map((a) => [a.titel, a.stufe]))}`);
/* EINE ÜBERSCHRIFT ÜBER LAUTER UNTERABSCHNITTEN BLEIBT STEHEN.
Das war beim ersten Lauf ein echter Verlust: „WELCHE LIGA BIN
ICH?" hat keine eigene Zeile, nur sechs Unterabschnitte -- und
verschwand deshalb. */
const ueber = gliederung("## WELCHE LIGA BIN ICH?\n### Superstars\nBedingung.");
ok(ueber.length === 2 && ueber[0].titel === "WELCHE LIGA BIN ICH?",
`die Überschrift über Unterabschnitten bleibt: ${JSON.stringify(ueber.map((a) => a.titel))}`);
/* GEGENPROBE: Eine Überschrift über NICHTS fällt weg -- sie
entsteht, wenn jemand den Inhalt löscht und den Titel stehen
lässt, und sieht aus wie ein Ladefehler. */
ok(gliederung("## LEER").length === 0,
"eine Überschrift über nichts fällt weg");
/* Und sie ist kein Hinweisblock -- `every()` auf einem leeren
Feld ist `true` (CLAUDE.md, 01.09.2026). */
ok(ueber[0].art !== "hinweise",
`der leere Abschnitt ist kein stiller Hinweiskasten (${ueber[0].art})`);
/* ---- Die Frage mit Antwort darunter ------------------------ */
const frage = gliederung("## INFO\nWie geht das?\nSo geht das.\nHast du Fragen?");
const fz = frage[0].zeilen;
ok(fz[0].art === "frage", `eine Frage MIT Antwort darunter: ${JSON.stringify(fz[0])}`);
/* GEGENPROBE: Eine Frage, auf die NICHTS folgt, ist keine
Überschrift, sondern eine Frage. */
ok(fz[2].art === "text",
`die letzte Zeile bleibt ein Absatz: ${JSON.stringify(fz[2])}`);
/* ---- Nichts geht verloren ---------------------------------- */
/* DIE WICHTIGSTE ZEILE DIESES ABSCHNITTS: Die Gliederung ist eine
Heuristik. Sie darf sich irren -- aber sie darf nichts
verschlucken. Gezählt wird gegen den Eingabetext. */
const eingabe = echt.split(/\r?\n/).map((x) => x.trim()).filter(Boolean);
const ausgabe = ab.flatMap((a) => [
...(a.titel ? [1] : []), ...a.zeilen.map(() => 1),
]).length;
ok(ausgabe === eingabe.length,
`${eingabe.length} Zeilen hinein, ${ausgabe} Überschriften und Zeilen heraus`);
/* Und am echten, langen Text dasselbe. */
const lang = regelnText(leseKampagne(seite.gipfel).kuer, []);
const langAb = gliederung(lang, { geschenke: leseKampagne(seite.gipfel).kuer.geschenke });
const langEin = lang.split(/\r?\n/).map((x) => x.trim()).filter(Boolean).length;
const langAus = langAb.reduce((s, a) => s + (a.titel ? 1 : 0) + a.zeilen.length, 0);
ok(langAus === langEin,
`am echten Regeltext ebenfalls: ${langEin} hinein, ${langAus} heraus `
+ `(${langAb.length} Abschnitte)`);
}
melde("");
melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
+179 -9
View File
@@ -531,13 +531,84 @@ let kachelId = null;
"Titel und Einleitung stehen drin");
/* KEIN FELD LÄNGER ALS DAS FORMULAR ERLAUBT. Sonst entstünde eine
Kachel, die DogFather öffnen, aber nicht speichern kann. */
for (const feld of ["text", "event_aufgaben", "event_regeln"]) {
ok((z[feld] || "").length <= 6000,
` ${feld}: ${(z[feld] || "").length} Zeichen (höchstens 6000)`);
Kachel, die DogFather öffnen, aber nicht speichern kann.
JE FELD SEINE EIGENE GRENZE (berichtigt 07.10.2026): `text`
ist die Beschreibung und darf 6000 Zeichen haben (TEXT_MAX),
die zwei Eventfelder 20000 (EVENT_TEXT_MAX). Beide gegen
dieselbe Zahl zu prüfen hieße, eine der zwei Grenzen nicht
zu prüfen. */
const { EVENT_TEXT_MAX } = await import("./workspace-bereiche.js");
ok(EVENT_TEXT_MAX === 20000,
`die Grenze für die Eventfelder steht auf ${EVENT_TEXT_MAX}`);
for (const [feld, grenze] of [
["text", 6000], ["event_aufgaben", EVENT_TEXT_MAX], ["event_regeln", EVENT_TEXT_MAX],
]) {
ok((z[feld] || "").length <= grenze,
` ${feld}: ${(z[feld] || "").length} Zeichen (höchstens ${grenze})`);
}
/* ---- DER REGELTEXT PASST JETZT GANZ HINEIN (07.10.2026) ----
Hier stand das Gegenteil: „und wo gekürzt wurde, sagt die
Kachel es selbst". Das war richtig, solange das Feld 6000
Zeichen fasste -- der Regeltext von „Gipfelstürmer" ist
gemessen 7679 Zeichen lang, also fielen jedes Mal rund 1600
Zeichen echter Inhalt weg. Filipe: „ich will alle
informationen die möglich sind wenn man den link hochlädt."
Gemessen wird deshalb jetzt, dass NICHTS gekürzt wurde -- und
zwar mit Beleg, dass der Text wirklich länger als die alte
Grenze ist. Sonst wäre die Zeile auch bei einem leeren Feld
grün. */
ok((z.event_regeln || "").length > 6000,
`der Regeltext ist ${(z.event_regeln || "").length} Zeichen lang `
+ "-- mit der alten Grenze von 6000 wäre hier gekürzt worden");
ok(!(z.event_regeln || "").includes("Der Regeltext war länger"),
"und er steht jetzt vollständig in der Kachel, ohne Kürzungsfußnote");
/* DER SCHLUSS DES TEXTES IST DA. „Nicht gekürzt" ließe sich auch
behaupten, wenn nur der Anfang drinstünde; geprüft wird der
LETZTE Abschnitt, den die Kürzung als ersten weggenommen hat. */
ok(/TEILNAHME\nLänder: /.test(z.event_regeln || "")
|| /## TEILNAHME/.test(z.event_regeln || ""),
"bis zum letzten Abschnitt (TEILNAHME mit den Ländern)");
/* ---- UND DIE KÜRZUNG SELBST BLEIBT GEPRÜFT ----------------
Sie läuft im Betrieb nicht mehr (kein gemessener Regeltext
ist so lang). Eine Funktion, die nie läuft, geht beim
nächsten Umbau still kaputt -- und ausgerechnet dann, wenn
eine Kampagne mit 25000 Zeichen kommt. Also einzeln, mit
kleiner Grenze. */
{
const { kuerzen } = await import("./workspace-kampagne.js");
/* ZEHN ABSCHNITTE, rund 600 Zeichen. Beim ersten Versuch standen
hier drei Abschnitte mit 61 Zeichen und eine Grenze von 200 --
der Text passte also hinein und wurde zu Recht nicht gekürzt.
Die Prüfung meldete „sagt nicht, dass gekürzt wurde" und hatte
formal recht: Es wurde nichts gekürzt. Eine Prüfung, deren
Voraussetzung nicht erfüllt ist, misst nichts (CLAUDE.md,
03.09.2026) -- deshalb muss der Text WIRKLICH zu lang sein,
und genau das wird vorher festgestellt. */
const lang = Array.from({ length: 10 }, (x, i) =>
`## ABSCHNITT ${i + 1}\nEine Zeile mit Inhalt, Nummer ${i + 1}.`).join("\n\n");
ok(lang.length > 400,
`der Probetext ist ${lang.length} Zeichen lang, die Grenze 400 `
+ "-- er MUSS also gekürzt werden");
const kurz = kuerzen(lang, 400);
ok(kurz.length <= 400 && kurz.length < lang.length,
`kuerzen(…, 400) hält die Grenze ein (${kurz.length} statt ${lang.length})`);
ok(kurz.includes("Der Regeltext war länger"),
" und sagt, dass gekürzt wurde");
/* AN EINER ABSCHNITTSGRENZE, nicht mitten im Wort: Ein
abgeschnittenes Wort sieht nicht nach einer Grenze aus,
sondern nach einem Fehler beim Einlesen. */
const inhalt = kurz.split("\n\n*")[0];
ok(/\.$|^## [A-ZÄÖÜ]+$/m.test(inhalt.split("\n").pop() || ""),
` an einer Zeilengrenze: …${JSON.stringify(inhalt.slice(-24))}`);
ok(kuerzen(lang, 20000) === lang,
" und ein Text, der passt, bleibt unangetastet");
}
ok((z.event_regeln || "").includes("Der Regeltext war länger"),
"und wo gekürzt wurde, sagt die Kachel es selbst");
/* DER AUSZUG BEWAHRT, WAS NICHT INS FELD PASST. */
const auszug = JSON.parse(z.kampagne_daten || "{}");
@@ -919,16 +990,41 @@ melde("=== 12b. Ein Event nach draußen teilen ===");
&& /noindex/.test(String(seiteOffen.kopf["x-robots-tag"] || "")),
"Suchmaschinen bleiben draußen — im HTML UND im Kopf der Antwort");
/* ---- UND NUR DAS GEHT HINAUS ---------------------------------- */
/* ---- WAS DIE KAMPAGNE SAGT, GEHT HINAUS -- WER WIR SIND, NICHT
DIE GRENZE IST AM 07.10.2026 VERSCHOBEN WORDEN, auf Filipes
Ansage: „auch wenn man teilt soll es einfach perfekt sein. man
soll alle infos sehen die man braucht."
Vorher standen „eine Aufgabe" und „der Regeltext" in dieser
Liste, und das war zu eng gedacht. Diese Angaben stammen von
einer OEFFENTLICHEN TikTok-Seite; sie weiterzugeben verraet
nichts ueber uns, und ohne sie ist der Link eine Einladung,
die nicht sagt, wozu.
DIE GRENZE LIEGT BEIM GEGENSTAND, NICHT BEI DER MENGE: nichts
ueber Personen (Name, Rolle, Haken), nichts ueber die
Verwaltung (Feldnamen, Haus, Keks), nichts aus der Buchhaltung
(Kampagnennummer, Steckbrief). Jede dieser Zeilen steht hier
weiterhin -- die Liste ist kuerzer geworden, nicht weicher.
UND NEU: KEINE HINWEISZEILE. „Die Preise konnten nicht gelesen
werden — bitte nachtragen" ist eine Notiz AN DOGFATHER. Auf
einer Seite, die er in Discord postet, stuende damit vor
seinen Creatorn, dass seine Kachel unfertig ist. Geprueft wird
das weiter unten an einer Kampagne, die wirklich einen Hinweis
hat -- an „Gipfelstuermer" waere es ein gruener Haken ohne
Messung, denn die hat gar keinen. */
const verboten = [
["eine Aufgabe", "1 Diamant = 1 Punkt"],
["der Regeltext", "KAMPAGNENGESCHENKE"],
["ein Name", "Dogfather"],
["eine Rolle", "admin"],
["die Kampagnennummer", ID_GIPFEL],
["die Kurznummer", "1140116740"],
["ein Feldname aus der Datenbank", "erstellt_von"],
["die Hausangabe", "\"haus\""],
["ein Keks", "workspace_sitzung"],
["ein Hinweis an DogFather", "bitte nachtragen"],
["die Kuerzungsfussnote", "länger als das Feld fasst"],
];
/* NUR DER KOERPER, NICHT DAS STILBLATT (berichtigt 07.10.2026).
Hier stand zuerst schlicht "agentur" als verbotenes Wort -- und
@@ -949,6 +1045,80 @@ melde("=== 12b. Ein Event nach draußen teilen ===");
&& /04\.10\.2026/.test(seiteOffen.text),
"Gegenprobe: Titel und Zeitraum stehen sehr wohl darauf");
/* ---- UND JETZT ALLES, WAS MAN BRAUCHT (07.10.2026) ---------
Gemessen wird nicht „die Seite ist lang", sondern dass jede
der vier Auskünfte wirklich dasteht: Aufgaben, Geschenke mit
Preis, Überschriften, gegliederte Unterstufen. */
const koerper = ohneStil;
ok(/<h2 class="k__schild2">Aufgaben<\/h2>/.test(koerper)
&& koerper.includes("1 Diamant = 1 Punkt"),
"die Aufgaben stehen auf der Seite");
const wieVieleAufgaben = (koerper.match(/<li>/g) || []).length;
ok(wieVieleAufgaben >= 5,
`und zwar alle fünf, nicht nur eine (${wieVieleAufgaben} Zeilen insgesamt)`);
ok(/Diese Geschenke zählen/.test(koerper) && koerper.includes("Ahorn-Welt"),
"die Geschenke stehen darauf");
ok(/12\.000 Coins/.test(koerper),
"mit Coinpreis und Tausenderpunkt");
ok(/<p class="k__woher">KAMPAGNENGESCHENKE/.test(koerper),
"unter der Überschrift, die die Kampagne dafür vorgesehen hat");
const titel = [...koerper.matchAll(/<h3 class="k__titel">([^<]+)<\/h3>/g)]
.map((m) => m[1]);
ok(titel.includes("BELOHNUNGEN") && titel.includes("TEILNAHMEBEDINGUNGEN"),
`die Abschnitte sind als Überschriften gesetzt: ${JSON.stringify(titel.slice(0, 8))}`);
ok(titel.includes("Superstars") && titel.includes("1. Liga"),
"auch die Ligen aus dem Klapptext");
ok(/data-stufe="3"/.test(koerper) && /data-stufe="2"/.test(koerper),
"und sie stehen auf zwei Stufen, nicht alle gleichrangig");
/* DIE VORSCHAU IN DISCORD BLEIBT KURZ. Discord zeigt rund 300
Zeichen; eine Beschreibung von 7000 Zeichen wäre dort
abgeschnitten und hier nutzlos. Die LANGE Fassung steht im
Körper, die kurze im Kopf -- das ist der ganze Trick. */
const beschr = /<meta property="og:description" content="([^"]*)"/.exec(seiteOffen.text);
/* 300 IST DIE ZAHL, DIE ZAEHLT -- so viel zeigt Discord.
Beim ersten Lauf kam 304 heraus, weil die Textgrenze (280)
den Zeitraum davor nicht mitrechnete. */
ok(beschr && beschr[1].length <= 300,
`og:description bleibt unter 300 Zeichen (${beschr?.[1].length})`);
/* UND SIE IST NICHT LEER. „Kurz" ist auch eine leere
Beschreibung -- dann zeigte Discord nur den Titel. */
ok(beschr && beschr[1].length > 40,
` und sagt wirklich etwas (${beschr?.[1].length} Zeichen)`);
ok(koerper.length > 3000,
`der Körper trägt dagegen alles (${koerper.length} Zeichen)`);
/* ---- EINE KAMPAGNE MIT HINWEIS ------------------------------
Das ist die Messung, die „keine Notiz an DogFather geht
hinaus" überhaupt erst zu einer Messung macht:
„Gipfelstürmer" hat null Hinweise, „ohne Preise" hat einen.
Ohne diesen Abschnitt wäre die Zeile oben ein grüner Haken
über einem leeren Feld (CLAUDE.md, 01.09.2026). */
const opId = dbLesen("SELECT id FROM eintraege WHERE kampagne_id = ?", ID_OHNEPREISE)[0]?.id;
ok(!!opId, `die Kampagne ohne Preise liegt in der Datenbank (#${opId})`);
const opRegeln = dbLesen("SELECT event_regeln FROM eintraege WHERE id = ?", opId)[0];
ok(/bitte nachtragen|keine Preisliste/.test(opRegeln?.event_regeln || ""),
"und ihre Kachel trägt wirklich einen Hinweis (sonst misst das Folgende nichts)");
const opT = await roh(`/workspace/api/eintrag/${opId}/teilen`,
{ methode: "POST", keks: keksDogi, rumpf: {} });
const opS = String(JSON.parse(opT.text || "{}").weg || "").split("/e/")[1];
ok(!!opS, `sie lässt sich teilen (HTTP ${opT.code})`);
const opSeite = await offen(`/e/${opS}`);
const opKoerper = opSeite.text.split("<style")[0]
+ (opSeite.text.split("</style>")[1] || "");
ok(opSeite.code === 200, `die Seite steht (${opSeite.code})`);
ok(!/bitte nachtragen/.test(opKoerper) && !/keine Preisliste/.test(opKoerper),
"KEIN Hinweis an DogFather steht auf der geteilten Seite");
/* GEGENPROBE: Der Rest der Kampagne steht sehr wohl darauf --
sonst wäre die Zeile darüber auch bei einer leeren Seite grün. */
ok(/Diese Geschenke zählen/.test(opKoerper) && /<h3 class="k__titel">/.test(opKoerper),
"Gegenprobe: Geschenke und Überschriften stehen sehr wohl darauf");
await roh(`/workspace/api/eintrag/${opId}/teilen`, { methode: "DELETE", keks: keksDogi });
/* ---- Das Banner ------------------------------------------------ */
const bild = await offen(`/e/${schluessel}/bild`);
ok(bild.code === 200 && /^image\//.test(String(bild.kopf["content-type"] || "")),
+135 -3
View File
@@ -46,6 +46,11 @@ import { terminVon } from "./workspace-termin-regel.js";
-- und dann der Link). Die vierte ist immer die, die etwas
durchlaesst. */
import { darfSeite } from "./rechte.js";
/* Die Gliederung des Regeltextes kommt aus demselben Modul, das
ihn schreibt. Sie im Browser zu bauen waere die zweite Fassung
derselben Regel -- und die Teilen-Seite (workspace-teilen.js)
braeuchte dann eine dritte. */
import { gliederung } from "./kampagne-lesen.mjs";
/* EIN KATALOG AUS ZWEI QUELLEN (17.09.2026)
@@ -766,6 +771,38 @@ const MIT_ANTWORTEN = new Set(["treff", "wunsch"]);
const TITEL_MAX = 160;
const TEXT_MAX = 6000;
/* SO VIEL FASSEN DIE ZWEI EVENT-TEXTFELDER (07.10.2026).
Filipe: „ich will alle informationen die möglich sind wenn man
den link hochlädt." Gemessen stehen dem 6000 Zeichen im Weg:
Der Regeltext von „Gipfelstuermer" ist 7605 Zeichen lang --
Geschenke, sechs Preisgruppen, Rangliste mit sechs Ligen,
Teilnahmebedingungen, Themenideen. Er wurde beim Einlesen an
einer Abschnittsgrenze abgeschnitten, und abgeschnitten wurde
das Ende: rund 1600 Zeichen echter Inhalt, jedes Mal.
WARUM NICHT EINFACH TEXT_MAX ERHOEHEN: Weil die 6000 fuer das
Beschreibungsfeld eines Aushangs eine sinnvolle Grenze sind --
wer dort mehr tippt, schreibt keinen Aushang mehr. Diese zwei
Felder sind etwas anderes: Sie halten das Regelwerk einer
fremden Kampagne, und wie lang das ist, entscheidet TikTok.
Eine Grenze fuer zwei verschiedene Dinge wird immer fuer eines
von beiden falsch sein.
WARUM 20000 UND NICHT UNBEGRENZT: Eine Grenze muss es geben,
sonst ist das Feld ein Ablageplatz fuer beliebig viel Text aus
dem Netz. 20000 ist das Zweieinhalbfache des laengsten
gemessenen Regeltextes -- Platz fuer eine Kampagne, die
doppelt so ausfuehrlich ist, und keiner fuer ein Buch.
DIE ZAHL STEHT NUR HIER. workspace-kampagne.js holt sie sich
von hier (`FELD_MAX`), statt sie abzuschreiben: Zwei Fassungen
derselben Grenze laufen auseinander, und dann kuerzt der eine
Weg auf 6000, waehrend der andere 20000 erlaubt -- eine Kachel,
die man speichern kann, aber nicht so, wie sie eingelesen
wurde. */
export const EVENT_TEXT_MAX = 20000;
/* =====================================================================
DIE PUNKTE EINES AGENTUR-EVENTS (03.10.2026)
@@ -1485,6 +1522,15 @@ const SPALTEN = `
KEINE BACKTICKS IN DIESEM BLOCK: SPALTEN ist selbst ein Template.
Ein Backtick im Kommentar beendet es mitten im SQL. */
e.kampagne_id, e.kampagne_quelle,
/* DAS GELESENE SELBST (07.10.2026, zweite Runde). Daraus baut
der Server die Geschenkliste mit Coinpreisen, den Steckbrief
und die Beschreibung je Missionsgruppe. Die Spalte selbst
geht NICHT an die Oberflaeche -- sie wird unten gelesen und
dann aus der Antwort entfernt: ein Auszug ist mehrere
Kilobyte gross, und davon braucht die Kachel drei Felder.
KEINE BACKTICKS IN DIESEM BLOCK, siehe oben. */
e.kampagne_daten,
/* OB DIESES EVENT GETEILT IST (07.10.2026). Die Oberflaeche baut
daraus entweder "Teilen" oder "Link kopieren" plus "nicht mehr
teilen". Der Schluessel selbst geht NICHT mit: Er steht in der
@@ -1814,9 +1860,90 @@ bereicheRouter.get("/workspace/api/bereich/:bereich", (req, res) => {
for (const e of events) {
const haken = meine.get(e.id) || new Set();
/* ---- WAS AUS DER KAMPAGNE GELESEN WURDE -------------
`kampagne_daten` ist der Auszug, den der Leser beim
Einlesen abgelegt hat. Drei Dinge daraus stehen auf
der Kachel, und alle drei sind GELESENE Daten, nicht
aus dem Text zurueckgewonnene:
geschenke Name, Coinpreis und Hinweis je Geschenk
steckbrief Kurznummer, Region, Sprache, Zeitzone
gruppen die Beschreibung je Missionsgruppe
WARUM NICHT AUS DEM TEXT: Im Regeltext steht
„· Feuerwerk (Sammle Feuerwerk in deinem LIVE!)" --
ohne Preis, weil es ein Kampagnengeschenk ist. Wer
daraus den Preis lesen will, muss raten; wer die
Liste hat, weiss es. Genau danach hat Filipe
gefragt: „ich will auch dass man sieht welche
geschenke bei dem event zaehlen und so."
IN EIN try: Die Spalte ist Text, und ein Eintrag von
Hand oder aus einer alten Fassung muss kein JSON
sein. Eine Kachel darf daran nicht scheitern -- ohne
Auszug fehlen drei Felder, der Regeltext steht
trotzdem da. */
let auszug = null;
if (e.kampagne_daten) {
try { auszug = JSON.parse(e.kampagne_daten); } catch { auszug = null; }
}
delete e.kampagne_daten;
const gruppen = new Map();
for (const g of auszug?.aufgabenGruppen || []) {
if (g?.titel) gruppen.set(String(g.titel).trim(), g);
}
e.event_punkte = punkteAusText(e.event_aufgaben)
.map((p) => ({ ...p, meiner: haken.has(p.schluessel) }));
e.event_regelpunkte = punkteAusText(e.event_regeln);
.map((p) => {
const g = gruppen.get(p.text.trim());
return {
...p,
meiner: haken.has(p.schluessel),
/* Nur wenn es etwas zu sagen gibt. Ein leeres
Feld an jeder Aufgabe ist eine Zeile Luft
unter jedem Haken. */
...(g?.beschreibung ? { beschreibung: g.beschreibung } : {}),
...(g?.von_tag && g?.bis_tag
? { von_tag: g.von_tag, bis_tag: g.bis_tag } : {}),
};
});
/* DIE GLIEDERUNG STATT EINER FLACHEN LISTE (07.10.2026).
Hier stand `punkteAusText(e.event_regeln)` -- jede
Zeile derselbe Strich. Damit sah „INFOS ZUR
KAMPAGNE" genauso aus wie der Absatz darunter, und
die Hinweise standen zwischen den Regeln, als waeren
sie welche. Die Begruendung in Laenge steht bei
`gliederung()` in kampagne-lesen.mjs.
DER DRITTE AUSGANG BLEIBT: `event_regeln` geht
weiterhin als Rohtext mit (siehe SPALTEN). Kommt
hier nichts heraus, zeigt die Kachel den Text --
schlicht, aber vollstaendig. Eine zweite
Punkteliste daneben waere eine dritte Abschrift
desselben Inhalts in derselben Antwort. */
e.event_regelabschnitte = gliederung(e.event_regeln,
{ geschenke: auszug?.geschenke || [] });
if (auszug?.geschenke?.length) {
e.event_geschenke = {
titel: auszug.geschenkTitel || null,
hinweis: auszug.geschenkHinweis || null,
liste: auszug.geschenke,
};
}
/* Leere Felder fallen weg, statt als „—" zu erscheinen:
`union_name` ist in allen gemessenen Kampagnen leer. */
if (auszug?.steckbrief) {
const s = Object.fromEntries(Object.entries(auszug.steckbrief)
.filter(([, v]) => v !== null && v !== undefined && v !== ""));
if (Object.keys(s).length) e.event_steckbrief = s;
}
/* DIE ZAHL KOMMT MIT, statt im Browser gezaehlt zu werden --
sie steht sonst an zwei Stellen, und „2 von 3" ist genau
die Sorte Angabe, die man nicht zweimal haben will. */
@@ -2187,7 +2314,12 @@ function pruefe(bereich, körper, { neu }) {
for (const [feld, name] of [["event_aufgaben", "Die Aufgaben"], ["event_regeln", "Die Regeln"]]) {
if (körper[feld] === undefined) continue;
const t = String(körper[feld] ?? "").trim();
if (t.length > TEXT_MAX) fehler.push(`${name} sind zu lang.`);
/* EVENT_TEXT_MAX, nicht TEXT_MAX -- Begruendung oben bei der
Zahl. Eine Kachel, die der Server beim Speichern ablehnt,
laesst den Menschen davor sich selbst fuer den Fehler
halten: Er hat ein Wort geaendert und bekommt eine
Meldung ueber 1600 Zeichen, die er nie getippt hat. */
if (t.length > EVENT_TEXT_MAX) fehler.push(`${name} sind zu lang.`);
else aus[feld] = t || null;
}
/* "Bis" vor "von" ist kein Zeitraum, sondern ein Tippfehler -- und
+23 -3
View File
@@ -76,6 +76,7 @@ import {
db, protokolliere, echteIp, sitzungLesen, istLeitung,
DATEN_ORDNER, hausFuerNeuenEintrag } from "./workspace.js";
import { bildArtVon } from "./workspace-dateien.js";
import { EVENT_TEXT_MAX } from "./workspace-bereiche.js";
import {
linkAusText, folgeUmleitung, holeSeite, holeMitFrist, bildAdresseErlaubt,
hostVon, FRIST_SEITE_MS } from "./helfer-tiktok.mjs";
@@ -116,8 +117,15 @@ const DRAENGT_AB_TAGEN = 7;
OEFFNEN, aber nicht SPEICHERN kann: Er aendert ein Wort, drueckt
Speichern und bekommt eine Fehlermeldung ueber etwas, das er nie
getippt hat. Ein Formular, das der Server ablehnt, laesst den
Menschen davor sich selbst fuer den Fehler halten. */
const FELD_MAX = 6000;
Menschen davor sich selbst fuer den Fehler halten.
SEIT DEM 07.10.2026 STEHT DIE ZAHL NUR NOCH AN EINER STELLE
(`EVENT_TEXT_MAX` in workspace-bereiche.js) und ist dort von
6000 auf 20000 gehoben worden -- der Regeltext von
„Gipfelstuermer" passt jetzt vollstaendig hinein. Abgeschrieben
wird sie nicht: Zwei Fassungen derselben Grenze laufen
auseinander. */
const FELD_MAX = EVENT_TEXT_MAX;
/** Kuerzt an einer ABSCHNITTSGRENZE und sagt, dass gekuerzt wurde.
*
@@ -127,7 +135,19 @@ const FELD_MAX = 6000;
*
* Verloren geht nichts: Das Gelesene steht gegliedert in
* kampagne_daten. */
function kuerzen(text, max = FELD_MAX) {
/* AUSGEFUEHRT SEIT DEM 07.10.2026, damit sie pruefbar bleibt.
Seit das Feld 20000 Zeichen fasst, wird der laengste gemessene
Regeltext (7679 Zeichen) nicht mehr gekuerzt -- und damit liefe
diese Funktion im echten Betrieb nie. Eine Funktion, die nie
laeuft, ist eine Funktion, die beim naechsten Umbau still
kaputtgeht: Kommt eine Kampagne mit 25000 Zeichen, soll sie
genau dann richtig arbeiten.
Also wird sie einzeln geprueft, mit einer kleinen Grenze
(pruef-kampagne.mjs, Abschnitt 4). Das ist der Unterschied
zwischen „laeuft gerade nicht" und „ist ungeprueft". */
export function kuerzen(text, max = FELD_MAX) {
const s = String(text || "");
if (s.length <= max) return s;
const fussnote = "\n\n* Der Regeltext war länger als das Feld fasst und endet "
+252 -6
View File
@@ -66,6 +66,14 @@ import {
db, protokolliere, echteIp, sitzungLesen, istLeitung,
DATEN_ORDNER } from "./workspace.js";
import { liefereDatei } from "./helfer-ausliefern.mjs";
/* DIESELBE ZERLEGUNG WIE DIE KACHEL, nicht eine zweite.
`gliederung` macht aus dem Regeltext Abschnitte, `punkteAusText`
aus den Aufgaben eine Liste. Beides hier nachzubauen hiesse: Die
geteilte Seite zeigt irgendwann etwas anderes als die Kachel,
und niemand koennte sagen, welche von beiden recht hat. */
import { gliederung } from "./kampagne-lesen.mjs";
import { punkteAusText } from "./workspace-bereiche.js";
export const teilenRouter = express.Router();
@@ -193,7 +201,14 @@ function eventZu(token) {
if (!/^[A-Za-z0-9_-]{10,64}$/.test(String(token || ""))) return null;
try {
return db().prepare(
`SELECT id, titel, text, datum, event_ende, kampagne_id, teilen_seit
/* NEU SEIT DEM 07.10.2026: event_aufgaben, event_regeln,
kampagne_daten. Siehe die Begruendung bei `seitePlakat`,
wo die Grenze gezogen wird -- was die KAMPAGNE sagt, geht
hinaus; was ueber UNS etwas sagt, nicht. Deshalb steht
`erstellt_von` hier weiterhin nicht, und `haus` auch
nicht. */
`SELECT id, titel, text, datum, event_ende, kampagne_id, teilen_seit,
event_aufgaben, event_regeln, kampagne_daten
FROM eintraege
WHERE teilen_token = ? AND bereich = 'agentur' AND art = 'kampagne'`)
.get(String(token)) || null;
@@ -240,12 +255,77 @@ teilenRouter.get("/e/:token", (req, res) => {
const bis = tagText(e.event_ende);
const zeitraum = von && bis && bis !== von ? `${von} – ${bis}` : (von || "");
const text = String(e.text || "").replace(/\s+/g, " ").trim();
/* Discord zeigt rund 300 Zeichen. Ein Satz, der mitten im Wort
endet, sieht nach einem Fehler aus -- also an einer Wortgrenze. */
const kurz = text.length > 280
? text.slice(0, text.lastIndexOf(" ", 277)) + " …"
/* DISCORD ZEIGT RUND 300 ZEICHEN -- UND DER ZEITRAUM STEHT MIT
DAVOR (berichtigt 07.10.2026).
Hier stand 280. Gemessen kam `og:description` auf 304
Zeichen heraus: Die Beschreibung ist `zeitraum · kurz`, und
der Zeitraum („04.10.2026 – 07.10.2026 · ") braucht allein
26 Zeichen. Wer nur den Text begrenzt, begrenzt nicht die
Angabe, die wirklich hinausgeht.
250 statt 280: 250 + 26 = 276, mit Luft fuer einen laengeren
Zeitraum. Eine Wortgrenze bleibt es -- ein Satz, der mitten
im Wort endet, sieht nach einem Fehler aus. */
const kurz = text.length > 250
? text.slice(0, text.lastIndexOf(" ", 247)) + " …"
: text;
const beschreibung = [zeitraum, kurz].filter(Boolean).join(" · ");
/* ---- WAS DIE KAMPAGNE SAGT (07.10.2026) ------------------
Filipe: „auch wenn man teilt soll es einfach perfekt sein.
man soll alle infos sehen die man braucht."
Bis heute standen hier Titel, Zeitraum, Einleitung und Banner
-- also gerade so viel, dass Discord eine Vorschau bauen kann.
Wer dem Link folgte, erfuhr nicht, was zu tun ist.
DIE GRENZE LIEGT NICHT BEI DER MENGE, SONDERN BEIM GEGENSTAND:
Was die KAMPAGNE sagt, darf hinaus -- Aufgaben, Geschenke,
Regeln. Was ueber UNS etwas sagt, nicht: kein Name, keine
Rolle, kein Haken, kein Haus, keine Kampagnennummer, kein
Steckbrief. Der Grund ist nicht Geheimhaltung um ihrer selbst
willen: Wer den Link weitergibt, gibt eine EINLADUNG weiter,
und eine Einladung nennt die Sache, nicht die Einladenden.
DIE HINWEISE BLEIBEN DRINNEN, und das ist die wichtigste
Zeile hier. „Die Preise konnten nicht gelesen werden -- bitte
nachtragen" ist eine Notiz AN DOGFATHER. Auf einer Seite, die
er in Discord postet, stuende damit vor seinen Creatorn, dass
seine Kachel unfertig ist. Dasselbe gilt fuer die Fussnote
ueber einen gekuerzten Regeltext. Beides sind Hinweiszeilen,
beide fallen hier weg -- an einer Stelle, mit einer Regel.
DER STECKBRIEF BLEIBT EBENFALLS DRINNEN. Kurznummer, Markt
und Zeitzone sind Buchhaltung, keine Einladung; sie stehen
auf der Kachel, wo sie gebraucht werden. */
let auszug = null;
if (e.kampagne_daten) {
try { auszug = JSON.parse(e.kampagne_daten); } catch { auszug = null; }
}
const aufgaben = punkteAusText(e.event_aufgaben).map((p) => p.text);
const abschnitte = gliederung(e.event_regeln,
{ geschenke: auszug?.geschenke || [] })
/* Kein Hinweisabschnitt, und auch keine Hinweiszeile in einem
anderen Abschnitt. */
.filter((a) => a.art !== "hinweise")
.map((a) => ({ ...a, zeilen: a.zeilen.filter((z) => z.art !== "hinweis") }))
.filter((a, i, alle) => a.zeilen.length
|| (alle[i + 1] && alle[i + 1].stufe > a.stufe));
/* Die Geschenke werden aus den Abschnitten herausgezogen -- wie
auf der Kachel: Sie stehen oben, nicht mitten im Regeltext. */
const gabenAb = abschnitte.find((a) => a.art === "geschenke") || null;
const rest = abschnitte.filter((a) => a !== gabenAb);
const gaben = (gabenAb?.zeilen || []).filter((z) => z.art === "geschenk");
/* DIE VORSCHAU IN DISCORD SOLL AUCH OHNE EINLEITUNG ETWAS SAGEN.
Gemessen an „LIVE Glow Up": Die Kampagne hat gar keinen
Einleitungstext (der Inhalt steht in den Klapptexten) -- in der
Vorschau stand damit nur ein Zeitraum. Die ersten Aufgaben
sagen in derselben Zeile mehr als jede Umschreibung. */
const aufgabenKurz = aufgaben.slice(0, 3).join(" · ");
const beschreibung = [zeitraum, kurz || aufgabenKurz]
.filter(Boolean).join(" · ");
const basis = `${TEILEN_BASIS}/e/${encodeURIComponent(req.params.token)}`;
const hatBild = (() => {
try {
@@ -262,6 +342,11 @@ teilenRouter.get("/e/:token", (req, res) => {
basis,
bild: hatBild ? `${basis}/bild` : null,
vonTikTok: !!e.kampagne_id,
aufgaben,
gaben,
gabenTitel: gabenAb?.titel || null,
gabenUnter: gabenAb?.unter || null,
abschnitte: rest,
}));
});
@@ -312,8 +397,166 @@ const STIL = `
color: #7f95ad; font-size: .85rem;
}
a { color: #7fd2fb; }
/* ---- Alles, was seit dem 07.10.2026 dazugekommen ist ----
Dieselbe Gliederung wie auf der Kachel, nur ohne Aufklappen:
Wer einem geteilten Link folgt, will lesen und nicht
bedienen. Augenschonend bleibt es trotzdem -- der
Unterschied laeuft ueber Groesse, Gewicht und Sperrung,
nicht ueber grelle Farben. */
.k__block {
margin: 20px 0 0; padding-top: 16px;
border-top: 1px solid rgba(255,255,255,.08);
}
.k__schild2 {
margin: 0 0 10px;
font-size: .74rem; font-weight: 700;
letter-spacing: .1em; text-transform: uppercase;
color: #9fb2c6;
}
.k__liste { margin: 0 0 8px; padding-left: 20px; color: #c5d4e3; }
.k__liste li { margin: 3px 0; font-size: .93rem; }
.k__liste:last-child { margin-bottom: 0; }
.k__woher { margin: 0 0 10px; color: #c5d4e3; font-size: .9rem; }
.k__unter { display: block; margin-top: 2px; color: #9fb2c6; font-size: .88rem; }
.k__gaben { display: flex; flex-wrap: wrap; gap: 7px; margin: 0; padding: 0; list-style: none; }
.k__gabe {
display: inline-flex; align-items: center; gap: 7px;
padding: 5px 11px; border-radius: 999px;
border: 1px solid rgba(226,193,90,.28);
background: rgba(226,193,90,.08);
font-size: .9rem; max-width: 100%;
}
.k__gname { font-weight: 600; overflow-wrap: anywhere; }
.k__coins {
padding: 1px 8px; border-radius: 999px;
background: rgba(226,193,90,.18); color: #e8d08f;
font-size: .82rem; font-weight: 700; white-space: nowrap;
}
.k__gzusatz { color: #9fb2c6; font-size: .84rem; overflow-wrap: anywhere; }
.k__abschnitt { margin: 0 0 14px; }
.k__abschnitt:last-child { margin-bottom: 0; }
.k__abschnitt[data-stufe="3"] {
margin-left: 2px; padding-left: 12px;
border-left: 2px solid rgba(255,255,255,.1);
}
.k__titel {
margin: 0 0 6px;
font-size: .8rem; font-weight: 700;
letter-spacing: .08em; text-transform: uppercase;
color: #d7e3f0;
}
.k__abschnitt[data-stufe="3"] > .k__titel {
font-size: .92rem; font-weight: 650;
letter-spacing: .01em; text-transform: none;
}
.k__absatz { margin: 0 0 7px; color: #c5d4e3; font-size: .93rem; }
.k__absatz:last-child { margin-bottom: 0; }
.k__frage { margin: 11px 0 5px; color: #e8f1fa; font-weight: 650; font-size: .93rem; }
.k__frage:first-child { margin-top: 0; }
@media (max-width: 480px) {
.k__abschnitt[data-stufe="3"] { padding-left: 9px; }
}
`;
/* ===================================================================
DIE BAUTEILE DER OFFENEN SEITE (07.10.2026)
ALLES GEHT DURCH `sicher()`. Diese Seite ist die einzige im
Haus ohne Anmeldung, und ihr Inhalt kommt aus einer FREMDEN
Quelle (einer TikTok-Seite). Ein `<script>` im Titel einer
Kampagne waere sonst ein Skript auf unserer Domain.
LEERE BLOECKE ERSCHEINEN NICHT. Eine Ueberschrift „Aufgaben"
ueber einer leeren Flaeche sieht aus, als sei das Laden
abgebrochen -- auf einer Seite, die jemand gerade in Discord
gepostet hat, ist das das Letzte, was man will.
=================================================================== */
/** Eine Zahl mit Tausenderpunkt, wie man sie liest. */
function coinText(n) {
/* `Number(null)` IST 0 -- und „0 Coins" waere eine falsche
Preisangabe. Derselbe Fehler ist mir am 07.10.2026 an einem Tag
zweimal passiert (Server und Kachel); hier steht er zum
dritten Mal und deshalb mit derselben Sicherung. */
if (n === null || n === undefined || n === "") return null;
const z = Number(n);
if (!Number.isFinite(z)) return null;
return `${z.toLocaleString("de-DE")} ${z === 1 ? "Coin" : "Coins"}`;
}
function blockAufgaben(liste) {
const a = (liste || []).filter(Boolean);
if (!a.length) return "";
return `<section class="k__block">
<h2 class="k__schild2">${a.length === 1 ? "Aufgabe" : "Aufgaben"}</h2>
<ul class="k__liste">${a.map((x) => `<li>${sicher(x)}</li>`).join("")}</ul>
</section>`;
}
function blockGaben(d) {
const g = (d.gaben || []).filter(Boolean);
if (!g.length) return "";
const kopf = d.gabenTitel
? `<p class="k__woher">${sicher(d.gabenTitel)}${
d.gabenUnter ? ` <span class="k__unter">${sicher(d.gabenUnter)}</span>` : ""}</p>`
: "";
return `<section class="k__block">
<h2 class="k__schild2">Diese Geschenke zählen</h2>
${kopf}
<ul class="k__gaben">${g.map((x) => {
const c = coinText(x.coins);
return `<li class="k__gabe"><span class="k__gname">${sicher(x.text)}</span>${
c ? `<span class="k__coins">${sicher(c)}</span>` : ""}${
x.zusatz ? `<span class="k__gzusatz">${sicher(x.zusatz)}</span>` : ""}</li>`;
}).join("")}</ul>
</section>`;
}
/** Die Zeilen eines Abschnitts in ihrer Reihenfolge.
* Aufeinanderfolgende Punkte kommen in EINE Liste; ein Absatz
* dazwischen beendet sie -- dieselbe Regel wie auf der Kachel, und
* aus demselben Grund: Bei den Teilnahmebedingungen steht zwischen
* zwei Aufzaehlungen ein Satz, der sich auf die ZWEITE bezieht. */
function zeilenHtml(zeilen) {
const teile = [];
let liste = null;
for (const z of zeilen || []) {
if (z.art === "punkt" || z.art === "geschenk") {
if (!liste) { liste = []; teile.push({ liste }); }
liste.push(z);
continue;
}
liste = null;
teile.push({ zeile: z });
}
return teile.map((s) => {
if (s.liste) {
return `<ul class="k__liste">${s.liste.map((z) => {
const c = coinText(z.coins);
return `<li>${sicher(z.text)}${c ? ` <span class="k__coins">${sicher(c)}</span>` : ""}</li>`;
}).join("")}</ul>`;
}
const klasse = s.zeile.art === "frage" ? "k__frage" : "k__absatz";
return `<p class="${klasse}">${sicher(s.zeile.text)}</p>`;
}).join("");
}
function blockRegeln(abschnitte) {
const a = (abschnitte || []).filter(Boolean);
if (!a.length) return "";
return `<section class="k__block">
<h2 class="k__schild2">Regeln &amp; Infos</h2>
${a.map((s) => `<div class="k__abschnitt" data-stufe="${s.stufe === 3 ? 3 : 2}">${
s.titel ? `<h3 class="k__titel">${sicher(s.titel)}</h3>` : ""}${
s.unter ? `<p class="k__unter">${sicher(s.unter)}</p>` : ""}${
zeilenHtml(s.zeilen)}</div>`).join("")}
</section>`;
}
function seitePlakat(d) {
const bildMarke = d.bild
? `<meta property="og:image" content="${sicher(d.bild)}" />
@@ -345,6 +588,9 @@ ${d.bild ? `<img class="k__bild" src="${sicher(d.bild)}" alt="" />` : ""}
<h1>${sicher(d.titel)}</h1>
${d.zeitraum ? `<p class="k__zeit">${sicher(d.zeitraum)}</p>` : ""}
${d.text ? `<p class="k__text">${sicher(d.text)}</p>` : ""}
${blockAufgaben(d.aufgaben)}
${blockGaben(d)}
${blockRegeln(d.abschnitte)}
<p class="k__fuss">Geteilt aus dem Creator Workspace von Spicy Media.</p>
</div>
</main>