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`. --> - - + + - +