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
+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;
}