From cadab24a5fde4c208743c934b91d97c83d318ee4 Mon Sep 17 00:00:00 2001 From: Dogfather Date: Wed, 7 Oct 2026 22:39:03 +0200 Subject: [PATCH] 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 --- server/bild-kampagne.mjs | 178 +++++++- server/kampagne-lesen.mjs | 717 ++++++++++++++++++++++++++++++- server/pruef-agentur.mjs | 70 ++- server/pruef-eventkarte.mjs | 579 ++++++++++++++++++++++++- server/pruef-kampagne-lesen.mjs | 367 +++++++++++++++- server/pruef-kampagne.mjs | 188 +++++++- server/workspace-bereiche.js | 138 +++++- server/workspace-kampagne.js | 26 +- server/workspace-teilen.js | 258 ++++++++++- workspace/anleitung.html | 28 +- workspace/anruf-probe.html | 10 +- workspace/app.webmanifest | 8 +- workspace/assets/css/bereich.css | 428 +++++++++++++++++- workspace/assets/js/bereich.js | 419 ++++++++++++++++-- workspace/aufgaben.html | 38 +- workspace/automation.html | 32 +- workspace/befinden.html | 34 +- workspace/bereich.html | 48 +-- workspace/bewerben.html | 38 +- workspace/bewerbungen.html | 36 +- workspace/buehne.html | 6 +- workspace/calls.html | 34 +- workspace/chat.html | 42 +- workspace/content.html | 34 +- workspace/crew-index.html | 12 +- workspace/crew.webmanifest | 8 +- workspace/dateien.html | 34 +- workspace/entwicklung.html | 36 +- workspace/hilfe.html | 30 +- workspace/index.html | 10 +- workspace/kalender.html | 34 +- workspace/leistung.html | 32 +- workspace/manager-ziele.html | 28 +- workspace/material.html | 32 +- workspace/notizen.html | 28 +- workspace/personen.html | 32 +- workspace/profil.html | 36 +- workspace/reaktion.html | 38 +- workspace/rechte.html | 34 +- workspace/report.html | 36 +- workspace/scouting.html | 32 +- workspace/start.html | 38 +- workspace/startcheck.html | 34 +- workspace/steckbrief.html | 36 +- workspace/support.html | 28 +- workspace/tafel.html | 6 +- workspace/talente.html | 36 +- workspace/team.html | 32 +- workspace/teamlage.html | 34 +- workspace/teilen.html | 30 +- workspace/treff-moderation.html | 32 +- workspace/treff-regeln.html | 32 +- workspace/uebersicht.html | 32 +- workspace/unsere-seiten.html | 30 +- workspace/unterstuetzen.html | 32 +- workspace/werdegang.html | 32 +- workspace/willkommen.html | 2 +- workspace/wissen.html | 34 +- 58 files changed, 3950 insertions(+), 798 deletions(-) diff --git a/server/bild-kampagne.mjs b/server/bild-kampagne.mjs index 12500cd2..a481cbad 100644 --- a/server/bild-kampagne.mjs +++ b/server/bild-kampagne.mjs @@ -16,7 +16,13 @@ Aufrufen mit: node server/bild-kampagne.mjs */ -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync, rmSync, readFileSync } from "node:fs"; +import { brotliDecompressSync } from "node:zlib"; +/* DER ECHTE LESER (07.10.2026) -- damit auf dem Bild eine Kampagne + steht, die wirklich so auf tiktok.com stand, und keine nachgebaute. + Ohne Netz: Die Seite liegt als Datei in server/pruefdaten und wird + von pruef-kampagne-lesen.mjs an ihrer Pruefsumme festgehalten. */ +import { leseKampagne, regelnText, aufgabenText } from "./kampagne-lesen.mjs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { notbremse } from "./helfer-notbremse.mjs"; @@ -90,24 +96,50 @@ const tagHin = (v) => { const z = (n) => String(n).padStart(2, "0"); return `${x.getFullYear()}-${z(x.getMonth() + 1)}-${z(x.getDate())}`; }; -function event(titel, text, von, bis, auf, reg, kampagneId) { +function event(titel, text, von, bis, auf, reg, kampagneId, daten) { d.prepare(`INSERT INTO eintraege (bereich, art, titel, text, datum, event_ende, event_aufgaben, event_regeln, - dringlichkeit, status, nur_leitung, erstellt, erstellt_von, haus, kampagne_id) - VALUES ('agentur','kampagne',?,?,?,?,?,?,?,'offen',0,?,?,NULL,?)`) + dringlichkeit, status, nur_leitung, erstellt, erstellt_von, haus, kampagne_id, + kampagne_daten) + VALUES ('agentur','kampagne',?,?,?,?,?,?,?,'offen',0,?,?,NULL,?,?)`) .run(titel, text, von, bis, auf, reg, - bis < tagHin(7) ? "hoch" : "mittel", j, idDogi, kampagneId); + bis < tagHin(7) ? "hoch" : "mittel", j, idDogi, kampagneId, + daten === undefined || daten === null ? null : JSON.stringify(daten)); } + +/* ==== EINE ECHTE KAMPAGNE, NICHT EINE NACHGEBAUTE (07.10.2026) == + + Die uebrigen Kacheln hier sind von Hand getippt, und das ist fuer + ihren Zweck richtig: kurze, berechenbare Daten fuer Abstaende und + Toene. Fuer die Gliederung taugt das nicht. „KAMPAGNENZEITPLAN" + mit zwei Zeilen zeigt weder Geschenke mit Coinpreis noch + Unterabschnitte noch einen Steckbrief -- also waere auf dem Bild + nichts von dem zu sehen, worum es Filipe ging. + + Deshalb geht EINE Kachel durch den echten Leser: dieselbe + gespeicherte Seite, die pruef-kampagne-lesen.mjs an ihrer + Pruefsumme festhaelt. Was auf dem Bild steht, hat damit wirklich + auf tiktok.com gestanden. + + KEIN NETZ: Die Seite liegt als Datei in server/pruefdaten. */ +const echteSeite = brotliDecompressSync( + readFileSync(new URL("./pruefdaten/kampagne-gipfelstuermer.html.br", import.meta.url))) + .toString("utf8"); +const echt = leseKampagne(echteSeite); event("LIVE Glow Up", "Eine vierwöchige Wachstums-Challenge für deine LIVEs.", - tagHin(-2), tagHin(4), + tagHin(-9), tagHin(-1), "Deine täglichen LIVE Missionen\nDeine wöchentliche Missionen", "KAMPAGNENZEITPLAN\n· Woche 1: 5. - 11.10.\n· Woche 2: 12. - 18.10.", "7690927903777262344"); -event("Gipfelstürmer", "Bereit für Alpen-Vibes in deinem LIVE?", - tagHin(-9), tagHin(-1), - "1 Diamant = 1 Punkt\nDreifache Punkte für Kampagnengeschenke", - "BELOHNUNGEN\nSuperstars: Top 1 = 60.000 Diamanten", - "7691493175936699143"); +/* LAEUFT (frueher: vorbei). So steht sie in der offenen Gruppe und + ist auf dem Bild zu sehen. „LIVE Glow Up" uebernimmt dafuer den + Zustand „vorbei" -- die drei Zustaende bleiben also vertreten, + und die Tonmessung weiter unten misst weiterhin drei + verschiedene. */ +event("Gipfelstürmer", echt.kuer.einleitung || "Bereit für Alpen-Vibes in deinem LIVE?", + tagHin(-3), tagHin(10), + aufgabenText(echt.kuer), regelnText(echt.kuer, echt.hinweise), + "7691493175936699143", echt.auszug); event("Spicy Media Teamabend", "Unser eigener Abend — Rückblick und Planung.", tagHin(3), tagHin(3), "Pünktlich um 20 Uhr da sein\nEinen Punkt für die Runde mitbringen", @@ -400,10 +432,24 @@ for (const [breite, name] of [[1400, 'Am Rechner'], [412, 'Auf dem Handy']]) { zahl: s.querySelector(".kv-seite__zahl")?.textContent?.trim(), ...r(s), ton: getComputedStyle(s).getPropertyValue("--kv-ton").trim(), - karten: [...s.querySelectorAll(".eintrag-karte")].map((k) => ({ - titel: k.querySelector(".eintrag-karte__titel, h3, h2")?.textContent?.trim()?.slice(0, 28), - ton: getComputedStyle(k).getPropertyValue("--ton").trim(), - })), + karten: [...s.querySelectorAll(".eintrag-karte")].map((k) => { + const st = getComputedStyle(k); + return { + titel: k.querySelector(".eintrag-karte__titel, h3, h2")?.textContent?.trim()?.slice(0, 28), + ton: st.getPropertyValue("--ton").trim(), + /* ZWEI TOENE, ZWEI KANAELE (siehe die Begruendung bei der + Pruefung unten): `--ton` sagt den ZUSTAND, `--kv-ton` + die SEITE. Gemessen werden beide, sonst kann die + Pruefung sie nicht auseinanderhalten. */ + kvTon: st.getPropertyValue("--kv-ton").trim(), + stand: k.dataset.event || null, + /* Und ob der Seitenton wirklich GEZEICHNET wird. Ein + Merkmal, das gesetzt ist, aber nichts malt, ist keine + Auskunft -- derselbe Fehler wie beim Farbpunkt am + 07.10.2026, der 10 statt 22 px breit war. */ + bild: st.backgroundImage.slice(0, 200), + }; + }), })); const kopfA = t.querySelector('.kv-seite[data-seite="tiktok"] .kv-seite__titel'); const zahlA = t.querySelector('.kv-seite[data-seite="tiktok"] .kv-seite__zahl'); @@ -429,8 +475,63 @@ for (const [breite, name] of [[1400, 'Am Rechner'], [412, 'Auf dem Handy']]) { "links TikTok, rechts die Agentur"); ok(mass.seiten[0].ton !== mass.seiten[1].ton, `jede Seite hat ihren eigenen Ton (${mass.seiten[0].ton} / ${mass.seiten[1].ton})`); - ok(mass.seiten.every((s) => s.karten.every((k) => k.ton === s.ton)), - "und jede Kachel traegt den Ton ihrer Seite"); + /* ==== ZWEI TOENE, ZWEI KANAELE (berichtigt 07.10.2026) ======= + + HIER STAND `k.ton === s.ton` -- „jede Kachel traegt den Ton + ihrer Seite". Diese Zeile war ROT, und zwar schon vor dem + heutigen Umbau: Am 07.10.2026 wurde bewusst entschieden, dass + die Kachel den Ton ihres ZUSTANDS traegt (laeuft gruen, kommt + blau, vorbei stumpf) und die SEITE eine eigene Kante links + bekommt. Die Pruefung hier wurde dabei nicht mitgezogen und + widersprach seitdem dem eigenen Entwurf. + + SIE IST NIEMANDEM AUFGEFALLEN, weil nach dem Lauf nur die + Zahl der Messungen notiert wurde („53 Messungen") und nicht + die Zahl der Fehler. Genau davor warnt CLAUDE.md seit dem + 28.08.2026 -- und diesmal hat es nicht die Zahl der Pruefungen + getroffen, sondern die der Fehler. Beides gehoert verglichen. + + WARUM DER ZUSTAND GEWINNT: Ob etwas LAEUFT oder VORBEI ist, + entscheidet, ob man heute etwas tut. Woher es kommt, steht + ohnehin zweimal da -- in der Spaltenueberschrift und auf dem + Herkunftsschild der Kachel. + + GEPRUEFT WIRD JETZT BEIDES, getrennt: der Zustandston am + `data-event` der Kachel, der Seitenton daran, dass er bis zur + Kachel durchkommt UND dort wirklich gezeichnet wird. */ + const ZUSTAND_TON = { + laeuft: "#cace02", kommt: "#5b8fd4", vorbei: "#6b7686", + }; + const alleKarten = mass.seiten.flatMap((s) => + s.karten.map((k) => ({ ...k, seitenTon: s.ton }))); + ok(alleKarten.length >= 2, + `${alleKarten.length} Kacheln in den Spalten (sonst misst das Folgende nichts)`); + const falscheToene = alleKarten.filter((k) => + ZUSTAND_TON[k.stand] && k.ton.toLowerCase() !== ZUSTAND_TON[k.stand]); + ok(falscheToene.length === 0, + `jede Kachel traegt den Ton ihres ZUSTANDS ` + + `(${alleKarten.map((k) => `${k.stand}=${k.ton}`).join(", ")})`); + /* GEGENPROBE: Die Toene sind nicht alle gleich -- sonst waere + „jeder Zustand hat einen eigenen" bei einer einzigen Farbe + auch gruen. */ + ok(new Set(alleKarten.map((k) => k.ton)).size >= 2, + `und verschiedene Zustaende verschiedene Toene ` + + `(${[...new Set(alleKarten.map((k) => k.ton))].join(" / ")})`); + + /* DER SEITENTON KOMMT BIS ZUR KACHEL ... */ + ok(alleKarten.every((k) => k.kvTon === k.seitenTon), + `der Seitenton erbt bis in jede Kachel ` + + `(${alleKarten.map((k) => k.kvTon).join(", ")})`); + /* ... UND WIRD DORT GEZEICHNET. `--kv-ton` gesetzt zu haben, + heisst noch nicht, dass man etwas sieht: Der Verlauf fuer die + Kante links steht in `background-image`, und wenn die Regel + dafuer ausfaellt (etwa weil ein Direktkind-Selektor nach einem + Umbau nicht mehr greift), ist das Merkmal da und die Kante + weg. Geprueft wird deshalb der GEZEICHNETE Verlauf. */ + const mitKante = alleKarten.filter((k) => /gradient/.test(k.bild || "")); + ok(mitKante.length === alleKarten.length, + `und jede Kachel zeichnet ihre Kante wirklich ` + + `(${mitKante.length} von ${alleKarten.length})`); if (breite >= 820) { ok(mass.nebeneinander, "sie stehen nebeneinander"); ok(Math.abs(mass.seiten[0].b - mass.seiten[1].b) <= 2, @@ -502,6 +603,49 @@ for (const [breite, name] of [[1400, 'Am Rechner'], [412, 'Auf dem Handy']]) { const t2 = await seite.$(".kv-trennung"); await t2.screenshot({ path: `server/bild-kampagne-trennung-${breite}.png` }); + + /* ---- UND EINMAL AUFGEKLAPPT (07.10.2026) --------------------------- + + Das Bild oben zeigt, dass die Kachel KURZ ist. Es zeigt nicht, was + dahinter steht -- und genau darum ging es Filipe zuerst: „man soll + die titel in den texten besser erkennen und sehen was zu was + passt." Also ein zweites Bild mit offenem Kasten. + + NUR DIE EINE KACHEL, nicht das ganze Brett: Mit 13 Abschnitten + waere das Bild sonst mehrere tausend Pixel hoch und niemand sieht + etwas. */ + const gipfel = await seite.evaluateHandle(() => { + const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')] + .find((x) => x.textContent.includes("Gipfelstürmer")); + k?.querySelector(".ev-mehr__knopf")?.click(); + return k; + }); + await seite.waitForTimeout(400); + const offen = await seite.evaluate(() => { + const k = [...document.querySelectorAll('.eintrag-karte[data-eventkarte="ja"]')] + .find((x) => x.textContent.includes("Gipfelstürmer")); + return { + auf: k?.querySelector(".ev-mehr__inhalt")?.hidden === false, + titel: [...(k?.querySelectorAll(".ev-abschnitt__titel") || [])] + .map((h) => h.textContent.trim()).slice(0, 5), + stufen: [...(k?.querySelectorAll(".ev-abschnitt") || [])] + .map((a) => a.dataset.stufe), + }; + }); + ok(offen.auf === true, `der Infokasten laesst sich aufklappen (${offen.auf})`); + /* DIE ZAHL GEHOERT IN DIE BEDINGUNG, nicht nur in den Meldetext: + Ein Bild von einem leeren Kasten waere sonst genauso gruen. */ + ok(offen.titel.length >= 3, + `und zeigt die Ueberschriften der Kampagne: ${JSON.stringify(offen.titel)}`); + ok(offen.stufen.includes("2") && offen.stufen.includes("3"), + `auf zwei Stufen, nicht alle gleichrangig ` + + `(${offen.stufen.filter((s) => s === "2").length}x Stufe 2, ` + + `${offen.stufen.filter((s) => s === "3").length}x Stufe 3)`); + if (gipfel) { + await gipfel.asElement()?.screenshot({ + path: `server/bild-kampagne-infos-${breite}.png`, + }); + } await ctx.close(); } diff --git a/server/kampagne-lesen.mjs b/server/kampagne-lesen.mjs index 99c1acfd..700458f7 100644 --- a/server/kampagne-lesen.mjs +++ b/server/kampagne-lesen.mjs @@ -136,6 +136,28 @@ export class KampagneUnlesbar extends Error { /** Der Kalendertag zu einem Zeitpunkt, in einer bestimmten Zone. * `sv-SE` liefert `JJJJ-MM-TT` ohne eigenes Zusammenbauen. */ +/** Ein Zeitstempel in Millisekunden -- oder nichts. + * + * DIESELBE SCHWELLE WIE BEI DEN PFLICHTFELDERN (1e12): Eine Zahl + * darunter ist eine Sekundenangabe, und die ergibt das Jahr 1970. + * Ein falsches Datum sieht aus wie ein Datum -- deshalb lieber + * nichts als etwas Falsches. */ +export function zeitOderNull(roh) { + const n = Number(roh); + return Number.isFinite(n) && n >= 1_000_000_000_000 ? n : null; +} + +/** `2026-11-04` -> `04.11.2026`. + * + * `tagInZone` liefert absichtlich die sortierbare Form (sv-SE). + * In einem Text, den DogFather liest, steht aber das deutsche + * Datum. Rein umgestellt, nicht neu gerechnet -- es wird keine + * Zone und keine Uhr angefasst. */ +export function deutschesDatum(tag) { + const m = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.exec(String(tag || "")); + return m ? `${m[3]}.${m[2]}.${m[1]}` : null; +} + export function tagInZone(ms, zone) { const zahl = Number(ms); if (!Number.isFinite(zahl)) return null; @@ -310,6 +332,30 @@ function zeichenZurueck(text) { * „1. Liga", „2. Liga", „3. Liga" wuerde eine unbrauchbare Liste * gleicher Zeilen, und niemand koennte sagen, welche Liga gemeint * ist. Deshalb werden Nummern gesetzt, nicht verworfen. */ +/* WAS AUF DIESER SEITE „LEERRAUM" HEISST (gemessen 07.10.2026). + + Die fuenfte Liga bei „Gipfelstuermer" steht als + +
  • Liga  
  • + + -- mit einem geschuetzten Leerzeichen HINTER der Auszeichnung. + Die ersten vier haben es nicht. Ein Muster, das dort `\s*` + erwartet, erkennt vier von fuenf Ligen als Ueberschrift und die + fuenfte nicht; gemessen hing „5. Liga" danach als Absatz unter + der vierten. Die Entitaeten werden erst viel spaeter aufgeloest + (`zeichenZurueck` am Ende), also muessen die Muster hier noch + mit ihnen rechnen. + + Als Zeichenkette und `new RegExp`, nicht als Literal: Dieselbe + Gruppe steht in zwei Mustern, und zwei Abschriften derselben + Regel laufen auseinander. */ +const LEER = "(?:\\s| | | )*"; +const NUR_STARK = new RegExp( + `^${LEER}<(strong|b|em)\\b[^>]*>([^<]*)<\\/\\1>${LEER}$`, "i"); +const ABSATZ_STARK = new RegExp( + `<(p|div)\\b[^>]*>${LEER}<(strong|b|em)\\b[^>]*>([^<]{1,90})<\\/\\2>${LEER}<\\/\\1>`, + "gi"); + export function textAusHtml(html) { let text = String(html || ""); if (!text) return ""; @@ -321,10 +367,84 @@ export function textAusHtml(html) { text = text.replace(/]*)>([\s\S]*?)<\/ol>/gi, (ganz, merkmale, inhalt) => { const m = /\bstart\s*=\s*["']?(\d+)/i.exec(merkmale); let n = m ? parseInt(m[1], 10) : 1; - return inhalt.replace(/]*>/gi, () => `\n${n++}. `); + + /* EIN LISTENPUNKT, DER NUR AUS EINER HERVORHEBUNG BESTEHT, IST + EINE UEBERSCHRIFT MIT NUMMER (gemessen 07.10.2026). + + So stehen die Ligen bei „Gipfelstuermer" auf der Seite: + +
    1. Liga 
    +

    Du kannst an dieser Liga teilnehmen, wenn ...

    +
    1. Liga 
    +

    Du kannst an dieser Liga teilnehmen, wenn ...

    + + Sechsmal „Liga" mit einer Nummer von aussen, und darunter + jeweils die Bedingung dafuer. Flach ausgegeben standen zwoelf + gleichrangige Zeilen da, und die Frage „ab wie vielen + Diamanten bin ich in der 2. Liga" war daran nicht mehr zu + beantworten -- obwohl die Antwort danebenstand. + + DIE NUMMER BLEIBT DABEI. Sie kommt aus `
      `, nicht + aus dem Text; „### Liga" sechsmal hintereinander waere + schlimmer als vorher. + + ZWEI DURCHGAENGE, UND DER ZWEITE IST DIE SICHERUNG: Das + erste Muster braucht ein schliessendes ``. Fehlt es -- + in HTML erlaubt --, faellt der Punkt durch, und ohne den + zweiten Durchgang waere er samt Inhalt verschwunden. Vorher + stand hier nur das oeffnende `
    1. `; diese Nachsicht darf + nicht verloren gehen. */ + let aus = inhalt.replace(/]*>([\s\S]*?)<\/li\s*>/gi, (g2, drin) => { + const stark = NUR_STARK.exec(drin); + return stark ? `\n### ${n++}. ${stark[2].trim()}\n` : `\n${n++}. ${drin}`; + }); + aus = aus.replace(/]*>/gi, () => `\n${n++}. `); + return aus; }); text = text.replace(/]*>/gi, "\n· "); + /* UEBERSCHRIFTEN IM KLAPPTEXT (07.10.2026, zweite Runde). + + Der laengste Block der Kachel sind die Teilnahmebedingungen + -- bei „Gipfelstuermer" rund 4000 Zeichen in einem Stueck. + Darin steht eine Gliederung, die beim Entfernen der + Auszeichnungen verloren ging. Im Quelltext der Seite: + +

      Superstars

      +

      Du kannst an dieser Liga teilnehmen, wenn ...

      + + „Superstars" ist die Ueberschrift zu dem Absatz darunter. + Flach ausgegeben standen beide als gleichwertige Zeilen da, + und bei sechs Ligen hintereinander war nicht mehr zu sehen, + welche Beschreibung zu welcher Liga gehoert. Genau das meint + „sehen was zu was passt". + + ERKANNT WIRD NUR DER EINDEUTIGE FALL: ein Absatz, dessen + GANZER Inhalt eine einzige Auszeichnung ist. `

      Um was geht + es bei Gipfelstuermer?

      ` ist dagegen eine + Frage MIT hervorgehobenem Wort -- auch das ist der Sache nach + eine Ueberschrift, aber das zu behaupten waere geraten. Sie + bleibt ein Absatz. + + `###` UND NICHT `##`: Zwei Stufen. `regelnText` setzt `##` + fuer die Abschnitte, die es selbst bildet (GESCHENKE, + BELOHNUNGEN, TEILNAHME); was aus einem fremden Text kommt, + steht darunter. Die Kachel ruecke es dadurch sichtbar ein, + statt es auf eine Hoehe mit den eigenen Ueberschriften zu + stellen -- was eine Gliederung behaupten wuerde, die die + Seite so nicht hat. + + NACH der `
    2. `-Behandlung, nicht davor: Die Ligennummern + stehen auf der Seite als `
      1. Liga`, + also in einer Liste. Liefe diese Ersetzung zuerst, wuerde + aus „1. Liga" eine Ueberschrift „Liga" ohne Nummer -- und + sechsmal dieselbe. */ + text = text.replace(ABSATZ_STARK, + (ganz, huelle, marke, inhalt) => `\n### ${inhalt.trim()}\n`); + text = text.replace( + /]*>([\s\S]{1,160}?)<\/h[1-6]>/gi, + (ganz, inhalt) => `\n### ${inhalt.replace(/<[^>]+>/g, " ").trim()}\n`); + text = text .replace(//gi, "\n") /* `
      2. ` setzt KEINEN Umbruch -- den hat `
      3. ` 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}>} + * + * 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 `
        ` --, 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; } diff --git a/server/pruef-agentur.mjs b/server/pruef-agentur.mjs index d98cb267..0b7a6c68 100644 --- a/server/pruef-agentur.mjs +++ b/server/pruef-agentur.mjs @@ -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 diff --git a/server/pruef-eventkarte.mjs b/server/pruef-eventkarte.mjs index ed68a842..147e3334 100644 --- a/server/pruef-eventkarte.mjs +++ b/server/pruef-eventkarte.mjs @@ -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 diff --git a/server/pruef-kampagne-lesen.mjs b/server/pruef-kampagne-lesen.mjs index 6c90b65f..0b7ea3ce 100644 --- a/server/pruef-kampagne-lesen.mjs +++ b/server/pruef-kampagne-lesen.mjs @@ -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 (`
          `), und das + ` ` hinter `` darf nicht stören -- die fünfte + Liga hat eines, die ersten vier nicht. */ const rohHtml = "
          1. Liga 
          "; - ok(textAusHtml(rohHtml) === "3. Liga", + ok(textAusHtml(rohHtml) === "### 3. Liga", `Gegenprobe am Baustein selbst: ${JSON.stringify(textAusHtml(rohHtml))}`); + const mitNbsp = "
          1. Liga  
          "; + 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 = "
          1. Nur Text, nicht fett
          "; + 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 = "

          Superstars

          " + + "

          Du kannst teilnehmen, wenn ...

          "; + /* 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 = "

          Um was geht es bei Gipfelstürmer?

          "; + 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 ", "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"); diff --git a/server/pruef-kampagne.mjs b/server/pruef-kampagne.mjs index 8e3f5183..d9a12fa7 100644 --- a/server/pruef-kampagne.mjs +++ b/server/pruef-kampagne.mjs @@ -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(/

          Aufgaben<\/h2>/.test(koerper) + && koerper.includes("1 Diamant = 1 Punkt"), + "die Aufgaben stehen auf der Seite"); + const wieVieleAufgaben = (koerper.match(/
        1. /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(/

          KAMPAGNENGESCHENKE/.test(koerper), + "unter der Überschrift, die die Kampagne dafür vorgesehen hat"); + + const titel = [...koerper.matchAll(/

          ([^<]+)<\/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 = / 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("")[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) && /

          /.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"] || "")), diff --git a/server/workspace-bereiche.js b/server/workspace-bereiche.js index 0b33e000..5358afdb 100644 --- a/server/workspace-bereiche.js +++ b/server/workspace-bereiche.js @@ -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 diff --git a/server/workspace-kampagne.js b/server/workspace-kampagne.js index c6e84288..c9925428 100644 --- a/server/workspace-kampagne.js +++ b/server/workspace-kampagne.js @@ -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 " diff --git a/server/workspace-teilen.js b/server/workspace-teilen.js index ee3900ad..396ada8d 100644 --- a/server/workspace-teilen.js +++ b/server/workspace-teilen.js @@ -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 ` + - - - - - - + + + + + + diff --git a/workspace/anruf-probe.html b/workspace/anruf-probe.html index e84dc20a..db3fa609 100644 --- a/workspace/anruf-probe.html +++ b/workspace/anruf-probe.html @@ -16,8 +16,8 @@ verdeckt. Gefunden hat es pruef-struktur. Richtig sind `gate.css` (die Farbwerte des Hauses) und `haus.css`. --> - - + + - +