Filipe, mit einem Bildschirmfoto des Regelblocks: "man soll die titel
in den texten besser erkennen und sehen was zu was passt. ich will
auch dass man sieht welche geschenke bei dem event zaehlen und so.
ich will alle informationen die moeglich sind wenn man den link
hochlaedt. ... auch wenn man teilt soll es einfach perfekt sein. man
soll alle infos sehen die man braucht. die kacheln von den events
soll auch nicht so lang sein, die soll man aufklappen koennen. genau
wie eine trennung fuer heute, letzte woche letzten monat und so."
DER FEHLER LAG NICHT IM TEXT, SONDERN IN SEINER ANZEIGE. Im Feld
stand die ganze Zeit eine Gliederung -- Hinweise mit "* ", eine
Ueberschrift mit Untertitel hinter einem Gedankenstrich, Absaetze
ohne Zeichen, Aufzaehlungen mit "- ". Die Kachel hat daraus mit
punkteAusText() EINE flache Liste gemacht: jede Zeile derselbe
Strich, alle gleich gross.
gliederung() liest die Gliederung, die schon dasteht, statt
den Text umzuschreiben. Die beiden Kampagnen, die
heute in der Datenbank liegen, sehen dadurch
sofort richtig aus -- ohne Umstellung.
Geschenke kommen aus der gelesenen LISTE, nicht aus dem
Text, und stehen OHNE Aufklappen auf der Kachel.
Aufklappen alles Uebrige hinter einem Druck: gemessen 555 px
weniger je Kachel bei Gipfelstuermer.
Gruppen je Spalte laeuft / kommt / vorbei, Vergangenes
nach diese Woche, dieser Monat, aelter.
NEU GELESEN (stand in allen vier gespeicherten Seiten und wurde nie
angesehen): die Rangliste mit ihren Gruppen ("Superstars" bis
"5. Liga", bei Glow Up "Team Glow"/"Team Shine"), die Beschreibung je
Missionsgruppe, der Steckbrief (Kurznummer, Markt, Sprache, Zone),
der zweite Bildbaustein, und Ueberschriften INNERHALB der Klapptexte
-- "WELCHE LIGA BIN ICH?" hat jetzt sechs eingerueckte Unterpunkte
statt zwoelf gleichrangiger Zeilen.
EVENT_TEXT_MAX 6000 -> 20000. Der Regeltext von Gipfelstuermer ist
7679 Zeichen lang; es fielen bisher jedes Mal rund 1600 Zeichen
echter Inhalt weg. Die Zahl steht jetzt an EINER Stelle.
TEILEN: Was die KAMPAGNE sagt, geht hinaus (Aufgaben, Geschenke,
Regeln) -- wer WIR sind, nicht (Name, Rolle, Haus, Kampagnennummer,
Steckbrief). Und keine Hinweiszeile: "Die Preise konnten nicht
gelesen werden -- bitte nachtragen" ist eine Notiz an DogFather und
hat auf einer Seite, die er in Discord postet, nichts zu suchen.
BERICHTIGT, WEIL NACHGEMESSEN:
- Number(null) ist 0 und 0 ist endlich -- "Feuerwerk" bekam an drei
Stellen "0 Coins" angeschrieben, also eine falsche Preisangabe.
- Ein Kommentar im Leser behauptete, der Wertungszeitraum weiche
vom Kampagnenzeitraum ab. Nachgemessen ist er in allen drei
Seiten mit Rangliste auf die Millisekunde gleich; ich hatte zwei
verschiedene Kampagnen verglichen.
- bild-kampagne.mjs verlangte "jede Kachel traegt den Ton ihrer
Seite" und war damit seit dem 07.10. rot, weil an dem Tag bewusst
das Gegenteil entschieden wurde (Zustandston, Seitenkante). Nicht
aufgefallen, weil nach dem Lauf nur die Zahl der MESSUNGEN notiert
wurde, nicht die der FEHLER.
- .kv-seite > .eintrag-karte wird .kv-seite .eintrag-karte: Die
Karten haengen durch die Gruppen eine Ebene tiefer. Das waere der
fuenfte Direktkind-Selektor gewesen, der nach einem Umbau still
ausfaellt -- diesmal vorher gesehen.
GEPRUEFT: 186 (lesen, +58), 148 (Route/Teilen, +25), 158 (Kachel,
+64), 67 (Agentur, +5), 67 Messungen (Bild, +14), dazu struktur,
css-klassen, zeichen, deutsche-texte, tippziele, lesbarkeit und
haus-trennung (107, unveraendert). Sechs Gegenproben: alle sechs
Sabotagen werden bemerkt, alle Dateien byte-gleich wiederhergestellt.
Co-Authored-By: Claude Opus 5 <[email protected]>
1495 lines
65 KiB
JavaScript
1495 lines
65 KiB
JavaScript
/* =====================================================================
|
||
EINE TIKTOK-KAMPAGNENSEITE LESEN — nur lesen (07.10.2026)
|
||
|
||
Etappe 1 des Bauplans „Kampagnen-Kachel aus TikTok-Link".
|
||
|
||
HIER PASSIERT NICHTS AUSSER LESEN. Keine Netzanfrage, keine
|
||
Datenbank, kein Express, keine Datei, keine Uhr. Text hinein, Daten
|
||
heraus. Das ist nicht Ordnungsliebe, sondern der Grund, warum diese
|
||
Datei ueberhaupt getrennt ist: So laesst sich das Auslesen gegen
|
||
GESPEICHERTE Seiten pruefen -- ohne Server, ohne Netz, ohne
|
||
Datenbank. Eine Pruefung, die dafuer das echte TikTok braucht, misst
|
||
fremde Verfuegbarkeit statt unseren Code und wird irgendwann rot,
|
||
ohne dass etwas kaputt ist.
|
||
|
||
AUCH KEINE UHR: `dringlichkeit` („Ende in unter 7 Tagen") steht
|
||
absichtlich NICHT hier. Eine Lesefunktion, die die Wanduhr befragt,
|
||
liefert zu verschiedenen Zeiten verschiedene Ergebnisse auf
|
||
dieselbe Eingabe -- und eine Pruefung darauf ist eine Zeitbombe
|
||
(CLAUDE.md, 06.09.2026). Das rechnet die Route, mit ihrem eigenen
|
||
Jetzt.
|
||
|
||
---------------------------------------------------------------------
|
||
DIE DREI AUSGAENGE
|
||
|
||
1. Gelesen -> { pflicht, kuer, hinweise, auszug }
|
||
2. Pflichtfeld fehlt -> KampagneUnlesbar, art "pflicht"
|
||
3. Aufbau unbekannt -> KampagneUnlesbar, art "aufbau"
|
||
|
||
Der Unterschied zwischen 2 und 3 ist der Kern: Eine Kachel ohne
|
||
Zeitraum ist eine FALSCHAUSKUNFT -- sie behauptet einen Termin, den
|
||
sie nicht kennt. Eine Kachel ohne Preisliste ist unvollstaendig und
|
||
sagt das selbst, ueber `hinweise`.
|
||
|
||
Pflicht: titel, start, ende, id, zone
|
||
Nebensache: einleitung, aufgaben, geschenke, preise, klapptexte,
|
||
laender, bannerUrl
|
||
|
||
---------------------------------------------------------------------
|
||
DAS SCHEMA IST DIE WAHRHEIT, DAS WOERTERBUCH IST NUR DAS WOERTERBUCH
|
||
|
||
Der wichtigste Satz dieser Datei. Am 07.10.2026 an der echten Seite
|
||
von „Gipfelstuermer" nachgemessen:
|
||
|
||
Platzhalter im Seitengeruest : 134
|
||
Eintraege im Woerterbuch : 135
|
||
|
||
Der eine Ueberzaehlige lautet „1 schenkende Person = 100 Punkte"
|
||
und steht im Geruest an KEINER Stelle -- ein Ueberrest einer
|
||
frueheren Fassung der Kampagne. Wer das Woerterbuch durchliest,
|
||
holt sich damit eine Regel in die Kachel, die gar nicht gilt, und
|
||
das Team richtet seinen Stream danach aus.
|
||
|
||
Deshalb wird hier NIE ueber das Woerterbuch gelaufen. Es wird
|
||
ausschliesslich NACHGESCHLAGEN: Ein Platzhalter, der im Geruest
|
||
steht, wird aufgeloest. Mehr nicht.
|
||
|
||
---------------------------------------------------------------------
|
||
UND DIE BAUSTEINNUMMER IM SCHLUESSEL IST NICHT STABIL
|
||
|
||
Die Schluessel heissen
|
||
`…__Pages_0_GameplayFEData_`**9**`_ruleIntroFormData_…`. Die 9 ist
|
||
die POSITION des Bausteins auf der Seite -- bei Gipfelstuermer
|
||
campaignIntro 5, rewardIntro 8, ruleIntro 9. Ein zusaetzlicher
|
||
Baustein schiebt alles nach hinten.
|
||
|
||
Gesucht wird deshalb nicht nach der Zahl, sondern nach dem Bausteins
|
||
EIGENEM Namen: `ttlive-component-live_rule_introduction`. Dann ist
|
||
die Reihenfolge gleichgueltig.
|
||
|
||
NACHGEMESSEN AM 07.10.2026, und der Bauplan kannte diesen Fall
|
||
nicht: Bei einer BEENDETEN Kampagne („Sonne im Gepaeck",
|
||
activity_status 16) liefert TikTok
|
||
`activityInfo.ac_schema_with_interaction_rules` ueberhaupt nicht
|
||
mehr. Das Geruest steht dann nur noch unter `value.schema` -- mit
|
||
denselben Bausteinen, deren `type` aber auf `_rep_remove` endet.
|
||
Beides wird deshalb akzeptiert, und die Endung wird abgeschnitten.
|
||
Wer nur die erste Quelle kennt, kann abgelaufene Kampagnen nicht
|
||
nachtragen.
|
||
|
||
---------------------------------------------------------------------
|
||
WAS AUF DER SEITE STEHT UND WAS IN DEN DATEN STEHT
|
||
|
||
`pageInfo.topImageInfo.url` und `props.url` zeigen auf das Bild der
|
||
VORLAGE -- bei Gipfelstuermer ein goldener Loewe aus einer
|
||
Nahost-Kampagne vom August 2024 (`pageVisualEnName: "MENA Black
|
||
Gold PK"`, 750x770). Sichtbar ist es nirgends: Der Baustein
|
||
`head_picture` ueberschreibt es mit seinem eigenen Bild
|
||
(`props.imageUrl[0].url`, 750x500) und setzt Titel und Untertitel
|
||
auf ein Leerzeichen.
|
||
|
||
Genommen wird deshalb `props.imageUrl[0].url`. Beim TITEL ist es
|
||
dagegen umgekehrt: `pageInfo.title` ist „Gipfelstuermer" und damit
|
||
richtig -- hier irrt der Bauplan, nachgemessen am 07.10.2026.
|
||
===================================================================== */
|
||
|
||
/** Der dritte Ausgang, als Fehler mit Grund.
|
||
*
|
||
* `art` trennt die zwei Sorten, die verschieden beantwortet werden:
|
||
* „aufbau" heisst, die Seite sieht anders aus als erwartet (TikTok
|
||
* hat umgebaut); „pflicht" heisst, die Seite ist lesbar, aber ein
|
||
* Feld fehlt, ohne das die Kachel luegen wuerde. */
|
||
export class KampagneUnlesbar extends Error {
|
||
constructor(grund, art = "aufbau") {
|
||
super(grund);
|
||
this.name = "KampagneUnlesbar";
|
||
this.grund = grund;
|
||
this.art = art;
|
||
}
|
||
}
|
||
|
||
/* ===================================================================
|
||
EIN KALENDERTAG IN DER ZEITZONE DER KAMPAGNE
|
||
|
||
DER FEHLER, DEN DAS VERHINDERT, gemessen am 07.10.2026:
|
||
|
||
start_time 1791064800000
|
||
new Date(ms).toISOString().slice(0, 10) -> "2026-10-03" FALSCH
|
||
in Europe/Berlin -> "2026-10-04" richtig
|
||
|
||
Der Zeitstempel ist der 03.10. um 22:00 UTC, also Mitternacht
|
||
Berliner Zeit. Die Kampagne stuende einen Tag zu frueh in der Liste,
|
||
und bei „noch X Tage" waere jede Zahl um eins daneben. Das faellt
|
||
niemandem auf, weil ein Datum, das um eins danebenliegt, immer noch
|
||
wie ein Datum aussieht.
|
||
|
||
UND WARUM NICHT `time_zone`: Die Seite liefert beides. Der feste
|
||
Versatz `+02:00` stimmt nur bis zum 25.10.2026 -- dann endet die
|
||
Sommerzeit und Berlin ist `+01:00`. Eine Kampagne im November, mit
|
||
dem gespeicherten Versatz gerechnet, liegt um eine Stunde daneben
|
||
und ueber Mitternacht um einen TAG. Dieselbe Sorte Fehler wie eine
|
||
feste Schwelle im Seitenkopf: eine Zahl, die einmal stimmte.
|
||
|
||
`heuteLokal()` taugt hier ebenfalls nicht -- es nimmt die Zeit des
|
||
SERVERS, nicht die der Kampagne.
|
||
=================================================================== */
|
||
|
||
/** 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;
|
||
try {
|
||
return new Intl.DateTimeFormat("sv-SE", {
|
||
timeZone: String(zone),
|
||
year: "numeric", month: "2-digit", day: "2-digit",
|
||
}).format(new Date(zahl));
|
||
} catch {
|
||
/* Eine unbekannte Zone ist kein Grund, auf UTC auszuweichen --
|
||
das waere stillschweigend ein anderer Tag. Lieber nichts. */
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/* ===================================================================
|
||
DIE SEITE AUFMACHEN
|
||
=================================================================== */
|
||
|
||
/* Das Datenpaket der Seite. Gesucht wird das Skript mit DIESER
|
||
Kennung -- nicht „das erste Skript" und nicht „das groesste". Auf
|
||
der echten Seite stehen rund zwei Dutzend Skripte davor, eines
|
||
davon enthaelt eine Zeile, die aussieht wie unser Ziel. */
|
||
const DATENBLOCK = /<script\b[^>]*\bid=["']__MODERN_ROUTER_DATA__["'][^>]*>/i;
|
||
|
||
/** Holt das Datenpaket aus dem HTML. Wirft, wenn es nicht da ist. */
|
||
export function datenAusSeite(html) {
|
||
const text = String(html || "");
|
||
const treffer = DATENBLOCK.exec(text);
|
||
if (!treffer) {
|
||
/* ZWEI SEHR VERSCHIEDENE FAELLE, EINE FEHLENDE MARKE
|
||
(nachgetragen 07.10.2026, nachdem genau das passiert ist).
|
||
|
||
(a) TikTok hat die Seite umgebaut -- dann kommt eine volle
|
||
Seite an, nur anders gebaut. Das muss jemand ansehen.
|
||
(b) TikTok schickt nur eine HUELLE, die sich erst im Browser
|
||
aufbaut. Gemessen am 07.10.2026: 17 KB statt rund 1 MB,
|
||
Titel "Campaign", leerer Koerper, zwei Skripte
|
||
(tiktok-environment, gfdatav1). Von jedem Abrufweg aus
|
||
dasselbe -- mit ehrlicher Kennung, mit Browser-Kennung,
|
||
mit Keksen, sogar aus einem echten Chromium.
|
||
|
||
Fuer den Menschen davor ist der Unterschied alles: Bei (a)
|
||
ist etwas zu reparieren, bei (b) kann er gar nichts machen
|
||
und soll das auch hoeren. Eine Meldung, die beides
|
||
zusammenwirft, schickt ihn auf die falsche Suche. */
|
||
const huelle = text.length < 200_000
|
||
&& (text.includes("gfdatav1") || text.includes("tiktok-environment"));
|
||
throw new KampagneUnlesbar(
|
||
huelle
|
||
? `TikTok hat nur eine leere Hülle geschickt `
|
||
+ `(${text.length < 1024 ? text.length + " Bytes"
|
||
: Math.round(text.length / 1024) + " KB"} statt rund 1 MB). `
|
||
+ `Die Seite baut sich erst im Browser auf -- daran kann `
|
||
+ `von hier aus niemand etwas ändern`
|
||
: "__MODERN_ROUTER_DATA__ fehlt in der Seite",
|
||
"aufbau");
|
||
}
|
||
const anfang = treffer.index + treffer[0].length;
|
||
const ende = text.indexOf("</script>", anfang);
|
||
if (ende < 0) {
|
||
throw new KampagneUnlesbar(
|
||
"__MODERN_ROUTER_DATA__ ist nicht geschlossen", "aufbau");
|
||
}
|
||
let daten;
|
||
try {
|
||
daten = JSON.parse(text.slice(anfang, ende));
|
||
} catch (f) {
|
||
throw new KampagneUnlesbar(
|
||
`__MODERN_ROUTER_DATA__ ist kein lesbares JSON (${f.message})`, "aufbau");
|
||
}
|
||
const wert = daten?.loaderData?.home_page?.COSMIC_D_K?.value;
|
||
if (!wert || typeof wert !== "object") {
|
||
throw new KampagneUnlesbar(
|
||
"loaderData.home_page.COSMIC_D_K.value fehlt im Datenpaket", "aufbau");
|
||
}
|
||
return wert;
|
||
}
|
||
|
||
/** Das Seitengeruest -- aus der bevorzugten Quelle, sonst aus der
|
||
* zweiten. Gibt auch zurueck, WOHER es kam; das steht spaeter im
|
||
* Protokoll und erspart das Raten, wenn eine Seite anders liest. */
|
||
export function geruestVon(wert) {
|
||
const ausAktion = wert?.globalData?.activityInfo?.ac_schema_with_interaction_rules;
|
||
if (typeof ausAktion === "string" && ausAktion.trim()) {
|
||
try { return { geruest: JSON.parse(ausAktion), woher: "activityInfo" }; }
|
||
catch { /* dann die zweite Quelle versuchen, nicht gleich aufgeben */ }
|
||
}
|
||
const zweite = wert?.schema;
|
||
if (typeof zweite === "string" && zweite.trim()) {
|
||
try { return { geruest: JSON.parse(zweite), woher: "value.schema" }; }
|
||
catch { /* beides unlesbar -- unten */ }
|
||
}
|
||
if (zweite && typeof zweite === "object") {
|
||
return { geruest: zweite, woher: "value.schema" };
|
||
}
|
||
throw new KampagneUnlesbar(
|
||
"das Seitengeruest fehlt -- weder activityInfo."
|
||
+ "ac_schema_with_interaction_rules noch value.schema sind lesbar",
|
||
"aufbau");
|
||
}
|
||
|
||
/* ===================================================================
|
||
BAUSTEINE FINDEN — am Namen, nie an der Position
|
||
=================================================================== */
|
||
|
||
const BAUSTEIN_VORSATZ = "ttlive-component-";
|
||
|
||
/** Der Name eines Bausteins ohne Herkunft und ohne die Endung, die
|
||
* eine beendete Kampagne anhaengt. */
|
||
function bausteinName(typ) {
|
||
if (typeof typ !== "string") return null;
|
||
const i = typ.indexOf(BAUSTEIN_VORSATZ);
|
||
if (i < 0) return null;
|
||
return typ.slice(i + BAUSTEIN_VORSATZ.length).replace(/_rep_remove$/, "");
|
||
}
|
||
|
||
/** Alle Bausteine der Seite, nach ihrem Namen. Mehrere gleichen
|
||
* Namens bleiben alle erhalten -- „den ersten nehmen" waere wieder
|
||
* eine Entscheidung ueber die Position. */
|
||
export function bausteine(geruest) {
|
||
const nach = new Map();
|
||
(function lauf(knoten, tiefe) {
|
||
if (!knoten || typeof knoten !== "object" || tiefe > 20) return;
|
||
const name = bausteinName(knoten.type);
|
||
if (name) {
|
||
if (!nach.has(name)) nach.set(name, []);
|
||
nach.get(name).push(knoten);
|
||
}
|
||
for (const wert of Object.values(knoten)) {
|
||
if (Array.isArray(wert)) { for (const x of wert) lauf(x, tiefe + 1); }
|
||
else if (wert && typeof wert === "object") lauf(wert, tiefe + 1);
|
||
}
|
||
})(geruest, 0);
|
||
return nach;
|
||
}
|
||
|
||
/* ===================================================================
|
||
TEXT AUS HTML — flach, aber nicht nachlaessig
|
||
=================================================================== */
|
||
|
||
const ZEICHEN = new Map([
|
||
[" ", " "], ["&", "&"], ["<", "<"], [">", ">"],
|
||
[""", "\""], ["'", "'"], ["'", "'"], ["—", "—"],
|
||
["–", "–"], ["…", "…"], ["•", "·"], ["€", "€"],
|
||
]);
|
||
|
||
/** Benannte und numerische Zeichen zurueckuebersetzen. */
|
||
function zeichenZurueck(text) {
|
||
return String(text)
|
||
.replace(/&[a-z]+;|&#\d+;|&#x[0-9a-f]+;/gi, (m) => {
|
||
const klein = m.toLowerCase();
|
||
if (ZEICHEN.has(klein)) return ZEICHEN.get(klein);
|
||
if (klein.startsWith("&#x")) {
|
||
const n = parseInt(klein.slice(3, -1), 16);
|
||
return Number.isFinite(n) ? String.fromCodePoint(n) : m;
|
||
}
|
||
if (klein.startsWith("&#")) {
|
||
const n = parseInt(klein.slice(2, -1), 10);
|
||
return Number.isFinite(n) ? String.fromCodePoint(n) : m;
|
||
}
|
||
return m;
|
||
});
|
||
}
|
||
|
||
/** HTML zu Text.
|
||
*
|
||
* WARUM NICHT EINFACH ALLE MARKEN WEGWERFEN: Die Ligentabelle von
|
||
* Gipfelstuermer steht als `<ol start="2"><li><strong>Liga</strong>
|
||
* </li></ol>`. Wer nur die Marken entfernt, bekommt viermal das Wort
|
||
* „Liga" -- die Nummer steckt im `start`-Merkmal, nicht im Text. Aus
|
||
* „1. Liga", „2. Liga", „3. Liga" wuerde eine unbrauchbare Liste
|
||
* gleicher Zeilen, und niemand koennte sagen, welche Liga gemeint
|
||
* ist. Deshalb werden Nummern gesetzt, nicht verworfen. */
|
||
/* WAS AUF DIESER SEITE „LEERRAUM" HEISST (gemessen 07.10.2026).
|
||
|
||
Die fuenfte Liga bei „Gipfelstuermer" steht als
|
||
|
||
<li ...><strong>Liga </strong> </li>
|
||
|
||
-- mit einem geschuetzten Leerzeichen HINTER der Auszeichnung.
|
||
Die ersten vier haben es nicht. Ein Muster, das dort `\s*`
|
||
erwartet, erkennt vier von fuenf Ligen als Ueberschrift und die
|
||
fuenfte nicht; gemessen hing „5. Liga" danach als Absatz unter
|
||
der vierten. Die Entitaeten werden erst viel spaeter aufgeloest
|
||
(`zeichenZurueck` am Ende), also muessen die Muster hier noch
|
||
mit ihnen rechnen.
|
||
|
||
Als Zeichenkette und `new RegExp`, nicht als Literal: Dieselbe
|
||
Gruppe steht in zwei Mustern, und zwei Abschriften derselben
|
||
Regel laufen auseinander. */
|
||
const LEER = "(?:\\s| | | )*";
|
||
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 "";
|
||
|
||
/* Nummerierte Aufzaehlungen: jede `<li>` bekommt ihre Zahl, von
|
||
`start` an gezaehlt. Verschachtelte Listen kommen auf der Seite
|
||
nicht vor; kaemen sie, wuerde die innere einfach weiterzaehlen --
|
||
das ist falsch, aber harmlos und sichtbar, nicht still. */
|
||
text = text.replace(/<ol\b([^>]*)>([\s\S]*?)<\/ol>/gi, (ganz, merkmale, inhalt) => {
|
||
const m = /\bstart\s*=\s*["']?(\d+)/i.exec(merkmale);
|
||
let n = m ? parseInt(m[1], 10) : 1;
|
||
|
||
/* EIN LISTENPUNKT, DER NUR AUS EINER HERVORHEBUNG BESTEHT, IST
|
||
EINE UEBERSCHRIFT MIT NUMMER (gemessen 07.10.2026).
|
||
|
||
So stehen die Ligen bei „Gipfelstuermer" auf der Seite:
|
||
|
||
<ol start="1"><li ...><strong>Liga </strong></li></ol>
|
||
<p>Du kannst an dieser Liga teilnehmen, wenn ...</p>
|
||
<ol start="2"><li ...><strong>Liga </strong></li></ol>
|
||
<p>Du kannst an dieser Liga teilnehmen, wenn ...</p>
|
||
|
||
Sechsmal „Liga" mit einer Nummer von aussen, und darunter
|
||
jeweils die Bedingung dafuer. Flach ausgegeben standen zwoelf
|
||
gleichrangige Zeilen da, und die Frage „ab wie vielen
|
||
Diamanten bin ich in der 2. Liga" war daran nicht mehr zu
|
||
beantworten -- obwohl die Antwort danebenstand.
|
||
|
||
DIE NUMMER BLEIBT DABEI. Sie kommt aus `<ol start>`, nicht
|
||
aus dem Text; „### Liga" sechsmal hintereinander waere
|
||
schlimmer als vorher.
|
||
|
||
ZWEI DURCHGAENGE, UND DER ZWEITE IST DIE SICHERUNG: Das
|
||
erste Muster braucht ein schliessendes `</li>`. Fehlt es --
|
||
in HTML erlaubt --, faellt der Punkt durch, und ohne den
|
||
zweiten Durchgang waere er samt Inhalt verschwunden. Vorher
|
||
stand hier nur das oeffnende `<li>`; diese Nachsicht darf
|
||
nicht verloren gehen. */
|
||
let aus = inhalt.replace(/<li\b[^>]*>([\s\S]*?)<\/li\s*>/gi, (g2, drin) => {
|
||
const stark = NUR_STARK.exec(drin);
|
||
return stark ? `\n### ${n++}. ${stark[2].trim()}\n` : `\n${n++}. ${drin}`;
|
||
});
|
||
aus = aus.replace(/<li\b[^>]*>/gi, () => `\n${n++}. `);
|
||
return aus;
|
||
});
|
||
text = text.replace(/<li\b[^>]*>/gi, "\n· ");
|
||
|
||
/* UEBERSCHRIFTEN IM KLAPPTEXT (07.10.2026, zweite Runde).
|
||
|
||
Der laengste Block der Kachel sind die Teilnahmebedingungen
|
||
-- bei „Gipfelstuermer" rund 4000 Zeichen in einem Stueck.
|
||
Darin steht eine Gliederung, die beim Entfernen der
|
||
Auszeichnungen verloren ging. Im Quelltext der Seite:
|
||
|
||
<p style="text-align: center;"><strong>Superstars</strong></p>
|
||
<p>Du kannst an dieser Liga teilnehmen, wenn ...</p>
|
||
|
||
„Superstars" ist die Ueberschrift zu dem Absatz darunter.
|
||
Flach ausgegeben standen beide als gleichwertige Zeilen da,
|
||
und bei sechs Ligen hintereinander war nicht mehr zu sehen,
|
||
welche Beschreibung zu welcher Liga gehoert. Genau das meint
|
||
„sehen was zu was passt".
|
||
|
||
ERKANNT WIRD NUR DER EINDEUTIGE FALL: ein Absatz, dessen
|
||
GANZER Inhalt eine einzige Auszeichnung ist. `<p>Um was geht
|
||
es bei <strong>Gipfelstuermer?</strong></p>` ist dagegen eine
|
||
Frage MIT hervorgehobenem Wort -- auch das ist der Sache nach
|
||
eine Ueberschrift, aber das zu behaupten waere geraten. Sie
|
||
bleibt ein Absatz.
|
||
|
||
`###` UND NICHT `##`: Zwei Stufen. `regelnText` setzt `##`
|
||
fuer die Abschnitte, die es selbst bildet (GESCHENKE,
|
||
BELOHNUNGEN, TEILNAHME); was aus einem fremden Text kommt,
|
||
steht darunter. Die Kachel ruecke es dadurch sichtbar ein,
|
||
statt es auf eine Hoehe mit den eigenen Ueberschriften zu
|
||
stellen -- was eine Gliederung behaupten wuerde, die die
|
||
Seite so nicht hat.
|
||
|
||
NACH der `<li>`-Behandlung, nicht davor: Die Ligennummern
|
||
stehen auf der Seite als `<ol start=N><li><strong>Liga</strong>`,
|
||
also in einer Liste. Liefe diese Ersetzung zuerst, wuerde
|
||
aus „1. Liga" eine Ueberschrift „Liga" ohne Nummer -- und
|
||
sechsmal dieselbe. */
|
||
text = text.replace(ABSATZ_STARK,
|
||
(ganz, huelle, marke, inhalt) => `\n### ${inhalt.trim()}\n`);
|
||
text = text.replace(
|
||
/<h[1-6]\b[^>]*>([\s\S]{1,160}?)<\/h[1-6]>/gi,
|
||
(ganz, inhalt) => `\n### ${inhalt.replace(/<[^>]+>/g, " ").trim()}\n`);
|
||
|
||
text = text
|
||
.replace(/<br\s*\/?>/gi, "\n")
|
||
/* `</li>` setzt KEINEN Umbruch -- den hat `<li>` schon gesetzt.
|
||
Beides zusammen ergibt eine Leerzeile zwischen zwei Punkten, und
|
||
eine Aufzaehlung mit Luecken sieht nach einem Fehler im Einlesen
|
||
aus. Bei `</p>` ist es umgekehrt richtig: Zwei Absaetze SOLLEN
|
||
durch eine Leerzeile getrennt sein. */
|
||
.replace(/<\/(p|div|h[1-6]|ul|ol|tr|blockquote)\s*>/gi, "\n")
|
||
.replace(/<\/li\s*>/gi, "")
|
||
.replace(/<(p|div|h[1-6]|ul|ol|table|tr)\b[^>]*>/gi, "\n")
|
||
.replace(/<\/?(td|th)\b[^>]*>/gi, " ")
|
||
.replace(/<script[\s\S]*?<\/script>/gi, "")
|
||
.replace(/<style[\s\S]*?<\/style>/gi, "")
|
||
.replace(/<[^>]+>/g, "");
|
||
|
||
return zeichenZurueck(text)
|
||
/* Das geschuetzte Leerzeichen (U+00A0) AUSGESCHRIEBEN. Als Zeichen
|
||
getippt waere es im Quelltext von einem normalen Leerzeichen nicht
|
||
zu unterscheiden -- und ein unsichtbares Zeichen in einem regulaeren
|
||
Ausdruck ist eine Falle, die dieses Haus schon einmal einen Abend
|
||
gekostet hat. Auf der Seite steht es als ` ` in jedem
|
||
`<strong>Liga </strong>`. */
|
||
.replace(/\u00a0/g, " ")
|
||
.split("\n")
|
||
.map((z) => z.replace(/[ \t]+/g, " ").trim())
|
||
.join("\n")
|
||
.replace(/\n{3,}/g, "\n\n")
|
||
.trim();
|
||
}
|
||
|
||
/* ===================================================================
|
||
LESEN
|
||
=================================================================== */
|
||
|
||
const PLATZHALTER = /^\$\$__activity_/;
|
||
|
||
/** Baut die Nachschlagefunktion fuer EINE Seite.
|
||
*
|
||
* Sie schlaegt NACH, sie durchsucht nicht. Ein Platzhalter ohne
|
||
* Eintrag gibt `null` zurueck und wird gezaehlt -- so faellt es auf,
|
||
* wenn TikTok die Woerterbuecher trennt, statt dass stillschweigend
|
||
* ein Platzhalter in der Kachel landet. */
|
||
function nachschlagen(wert, geruest) {
|
||
const woerterbuch = {
|
||
...(geruest?.sourceTextMap || {}),
|
||
...(wert?.sourceTextMap || {}),
|
||
};
|
||
const sprache = wert?.globalData?.currentLang
|
||
|| wert?.globalData?.language || "de-DE";
|
||
const ohneEintrag = [];
|
||
|
||
/** Ein Wert, wie er in der Kachel stehen soll. */
|
||
const auf = (roh) => {
|
||
if (typeof roh !== "string") return null;
|
||
if (!PLATZHALTER.test(roh)) return roh.trim() ? roh : null;
|
||
if (Object.prototype.hasOwnProperty.call(woerterbuch, roh)) {
|
||
const text = woerterbuch[roh];
|
||
return typeof text === "string" && text.trim() ? text : null;
|
||
}
|
||
ohneEintrag.push(roh.slice(0, 120));
|
||
return null;
|
||
};
|
||
|
||
/** Erst den Platzhalter, dann den fertigen Text in der Sprache.
|
||
* Manche Bausteine tragen beides -- `multiLingualContent` ist dann
|
||
* dieselbe Aussage, nur schon aufgeloest. */
|
||
const aufMit = (knoten, feld) => {
|
||
const direkt = auf(knoten?.[feld]);
|
||
if (direkt) return direkt;
|
||
const viele = knoten?.multiLingualContent;
|
||
if (viele && typeof viele === "object") {
|
||
const text = viele[sprache] ?? Object.values(viele)[0];
|
||
if (typeof text === "string" && text.trim()) return text;
|
||
}
|
||
return null;
|
||
};
|
||
|
||
return { auf, aufMit, sprache, woerterbuch, ohneEintrag };
|
||
}
|
||
|
||
/** Liest eine gespeicherte oder geholte TikTok-Kampagnenseite.
|
||
* @param {string} html die ganze Seite als Text
|
||
* @returns {{pflicht: object, kuer: object, hinweise: string[], auszug: object}}
|
||
*/
|
||
export function leseKampagne(html) {
|
||
const wert = datenAusSeite(html);
|
||
const { geruest, woher } = geruestVon(wert);
|
||
const { auf, aufMit, sprache, woerterbuch, ohneEintrag } = nachschlagen(wert, geruest);
|
||
const teile = bausteine(geruest);
|
||
const erster = (name) => (teile.get(name) || [])[0] || null;
|
||
|
||
const global = wert.globalData || {};
|
||
const aktion = global.activityInfo || {};
|
||
const seite = global.pageInfo || {};
|
||
const hinweise = [];
|
||
|
||
/* ---- PFLICHT ---------------------------------------------------- */
|
||
const id = String(aktion.activity_id || global.activityId || "").trim();
|
||
const zone = String(aktion.tz_location_name || "").trim();
|
||
const titel = String(seite.title || "").trim();
|
||
|
||
const fehlt = [];
|
||
if (!id) fehlt.push("die Kampagnennummer (activity_id)");
|
||
if (!zone) {
|
||
/* KEIN Ausweichen auf UTC und keines auf `time_zone`. Siehe oben:
|
||
ein fester Versatz ist nach der Zeitumstellung falsch, und ein
|
||
falsches Datum sieht aus wie ein Datum. */
|
||
fehlt.push("die Zeitzone der Kampagne (tz_location_name)");
|
||
}
|
||
if (!titel) fehlt.push("der Titel (pageInfo.title)");
|
||
|
||
/* Beide Zeitstempel in Millisekunden. Eine Zahl, die zu klein ist,
|
||
waere eine Sekundenangabe -- und damit das Jahr 1970. */
|
||
const zeitpunkt = (roh, name) => {
|
||
const n = Number(roh);
|
||
if (!Number.isFinite(n) || n < 1_000_000_000_000) {
|
||
fehlt.push(name);
|
||
return null;
|
||
}
|
||
return n;
|
||
};
|
||
const startMs = zeitpunkt(aktion.start_time, "der Beginn (start_time)");
|
||
const endeMs = zeitpunkt(aktion.end_time, "das Ende (end_time)");
|
||
|
||
const start = startMs !== null && zone ? tagInZone(startMs, zone) : null;
|
||
const ende = endeMs !== null && zone ? tagInZone(endeMs, zone) : null;
|
||
if (zone && startMs !== null && !start) fehlt.push(`die Zone „${zone}" ist unbekannt`);
|
||
|
||
if (fehlt.length) {
|
||
throw new KampagneUnlesbar(
|
||
`gelesen, aber ohne ${fehlt.join(" und ohne ")} -- daraus wird keine Kachel`,
|
||
"pflicht");
|
||
}
|
||
|
||
/* ---- NEBENSACHEN ------------------------------------------------ */
|
||
|
||
/* Einleitung */
|
||
const einleitungBaustein = erster("live_campaign_intro");
|
||
const einleitung = einleitungBaustein
|
||
? aufMit(einleitungBaustein.props || {}, "content") : null;
|
||
const einleitungTitel = einleitungBaustein
|
||
? auf(einleitungBaustein.props?.title) : null;
|
||
if (!einleitung) {
|
||
hinweise.push(einleitungBaustein
|
||
? "Der Einleitungstext konnte nicht gelesen werden — bitte nachtragen."
|
||
: "Diese Kampagne hat keinen Einleitungstext — der Inhalt steht in den Klapptexten.");
|
||
}
|
||
|
||
/* Aufgaben und Geschenke stehen im selben Baustein, in Abschnitten.
|
||
Gesucht wird der Abschnitt mit `type: "basic"` -- nicht
|
||
`ruleConfigList[0]`. Bei Gipfelstuermer ist das dasselbe; bei der
|
||
naechsten Kampagne muss es das nicht sein. */
|
||
const regelBaustein = erster("live_rule_introduction");
|
||
const abschnitte = regelBaustein?.props?.ruleConfigList;
|
||
const abschnittListe = Array.isArray(abschnitte) ? abschnitte : [];
|
||
|
||
const einfache = abschnittListe.filter((a) => a?.type === "basic");
|
||
const ersteEinfach = einfache[0] || null;
|
||
const aufgaben = (ersteEinfach?.ruleBasic || [])
|
||
.map((z) => auf(z?.title))
|
||
.filter((t) => typeof t === "string" && t.trim())
|
||
.map((t) => t.trim());
|
||
let aufgabenTitel = ersteEinfach ? auf(ersteEinfach.headerTitle) : null;
|
||
|
||
/* ZWEITE VORLAGE: DIE AUFGABENGRUPPE (07.10.2026).
|
||
|
||
Nicht jede Kampagne ist wie Gipfelstuermer gebaut. „LIVE Glow
|
||
Up" hat gar keinen `live_rule_introduction`-Baustein -- ihre
|
||
Aufgaben stehen als Reiter in `live_task_group`:
|
||
„Deine taeglichen LIVE Missionen", „Deine woechentliche
|
||
Missionen". Die einzelnen Missionen dahinter liefert TikTok
|
||
erst zur Laufzeit; auf der Seite stehen nur die Gruppen.
|
||
|
||
Sie werden trotzdem genommen -- sie sind das, was DogFather
|
||
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. */
|
||
/* 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 gruppenBaustein?.props?.taskItemList || []) {
|
||
const titel = auf(a?.displayInfo?.title);
|
||
if (!titel || gesehen.has(titel.trim())) continue;
|
||
gesehen.add(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 && aufgabenGruppen.length) {
|
||
for (const g of aufgabenGruppen) aufgaben.push(g.titel);
|
||
aufgabenTitel = aufgabenTitel || "Missionen";
|
||
}
|
||
|
||
/* GIBT ES SIE NICHT, ODER KONNTEN WIR SIE NICHT LESEN?
|
||
Das sind zwei verschiedene Nachrichten. „Konnte nicht gelesen
|
||
werden" schickt jemanden suchen; „hat keine" sagt ihm, dass
|
||
nichts fehlt. */
|
||
if (!aufgaben.length) {
|
||
hinweise.push(regelBaustein || gruppenBaustein
|
||
? "Die Aufgaben konnten nicht gelesen werden — bitte nachtragen."
|
||
: "Diese Kampagne hat keine Aufgabenliste auf der Seite.");
|
||
}
|
||
|
||
/* Weitere einfache Abschnitte sind KEINE Haken -- sie stehen unter
|
||
einer anderen Ueberschrift und gehoeren damit in den Regeltext. */
|
||
const weitereRegeln = einfache.slice(1).map((a) => ({
|
||
titel: auf(a.headerTitle),
|
||
zeilen: (a.ruleBasic || []).map((z) => auf(z?.title)).filter(Boolean),
|
||
})).filter((a) => a.zeilen.length);
|
||
|
||
const geschenkAbschnitte = abschnittListe.filter((a) => a?.type === "gift");
|
||
const geschenke = [];
|
||
let geschenkTitel = null;
|
||
let geschenkHinweis = null;
|
||
for (const a of geschenkAbschnitte) {
|
||
if (!geschenkTitel) geschenkTitel = auf(a.headerTitle);
|
||
if (!geschenkHinweis) geschenkHinweis = auf(a.description);
|
||
for (const karte of a?.ruleGift?.cardInfoList || []) {
|
||
/* ERST DIE UEBERSETZUNG, DANN DER GRUNDNAME. Bei Gipfelstuermer
|
||
ist beides gleich ("Herbstliches Herz"), weil die Kampagne
|
||
deutsch angelegt wurde. Eine Kampagne, die auf englisch
|
||
angelegt und uebersetzt wurde, haette unter `name` den
|
||
englischen Namen -- und der stuende dann mitten im deutschen
|
||
Regeltext. `translations` tragen alle 60 Sprachen bei sich. */
|
||
const uebersetzt = karte?.giftInfo?.translations?.[sprache];
|
||
const name = (typeof uebersetzt === "string" && uebersetzt.trim())
|
||
? uebersetzt.trim() : auf(karte?.giftInfo?.name);
|
||
if (!name) continue;
|
||
geschenke.push({
|
||
id: String(karte?.giftId ?? "").trim() || null,
|
||
name,
|
||
preis: Number.isFinite(Number(karte?.giftInfo?.price))
|
||
? Number(karte.giftInfo.price) : null,
|
||
});
|
||
}
|
||
}
|
||
/* ZWEITE VORLAGE: `live_secondary_gift` statt eines
|
||
Geschenk-Abschnitts in den Regeln. Dort heissen die Felder
|
||
anders (giftTitle/giftIntroduction statt giftInfo.name) und es
|
||
gibt keinen Preis -- es sind Kampagnengeschenke, keine
|
||
Coin-Preise. */
|
||
const zweitGeschenk = erster("live_secondary_gift");
|
||
if (!geschenke.length && zweitGeschenk) {
|
||
const roh = zweitGeschenk.props?.giftInfo;
|
||
const karten = Array.isArray(roh?.giftInfoCard) ? roh.giftInfoCard
|
||
: (Array.isArray(roh) ? roh : []);
|
||
for (const k of karten) {
|
||
const name = auf(k?.giftTitle) || auf(k?.giftInfo?.name);
|
||
if (!name) continue;
|
||
geschenke.push({
|
||
id: String(k?.giftId ?? "").trim() || null,
|
||
name: name.trim(),
|
||
preis: Number.isFinite(Number(k?.giftInfo?.price))
|
||
? Number(k.giftInfo.price) : null,
|
||
hinweis: auf(k?.giftIntroduction) || null,
|
||
});
|
||
}
|
||
if (geschenke.length && !geschenkTitel) {
|
||
geschenkTitel = auf(zweitGeschenk.props?.displayInfo?.titleText) || null;
|
||
}
|
||
if (geschenke.length && !geschenkHinweis) {
|
||
geschenkHinweis = auf(zweitGeschenk.props?.displayInfo?.extraContent) || null;
|
||
}
|
||
}
|
||
if (!geschenke.length) {
|
||
hinweise.push(geschenkAbschnitte.length || zweitGeschenk
|
||
? "Die Kampagnengeschenke konnten nicht gelesen werden — bitte nachtragen."
|
||
: "Diese Kampagne hat keine Geschenkliste auf der Seite.");
|
||
}
|
||
|
||
/* Preise */
|
||
const preisBaustein = erster("live_reward_introduction");
|
||
const preisTitel = preisBaustein ? auf(preisBaustein.props?.title) : null;
|
||
const preise = [];
|
||
for (const gruppe of preisBaustein?.props?.rewardConfig?.list || []) {
|
||
const liga = auf(gruppe?.subTitle);
|
||
const plaetze = [];
|
||
for (const g of gruppe?.rewardGroups || []) {
|
||
const platz = auf(g?.groupTitle);
|
||
const was = (g?.rewardList || [])
|
||
.map((p) => auf(p?.description))
|
||
.filter(Boolean)
|
||
.map((t) => t.trim());
|
||
if (platz && was.length) plaetze.push({ platz: platz.trim(), preise: was });
|
||
}
|
||
if (liga && plaetze.length) preise.push({ liga: liga.trim(), plaetze });
|
||
}
|
||
if (!preise.length) {
|
||
hinweise.push(preisBaustein
|
||
? "Die Preise konnten nicht gelesen werden — bitte nachtragen."
|
||
: "Diese Kampagne hat keine Preisliste auf der Seite.");
|
||
}
|
||
|
||
/* Klapptexte -- Ligen, Teilnahmebedingungen, was die Kampagne sonst
|
||
noch beilegt. Mit ihren EIGENEN Ueberschriften: „Welche Liga bin
|
||
ich?" steht so auf der Seite, und ein selbst erfundenes „LIGEN"
|
||
waere eine Behauptung ueber einen Inhalt, den wir nur ueberflogen
|
||
haben. Ausserdem haengt ein Vergleich auf einen deutschen Titel
|
||
an der Sprache der Seite -- IDs wandern nicht, Klartext schon. */
|
||
const klappBaustein = erster("live_fold_rich_text");
|
||
const klapptexte = [];
|
||
for (const stueck of klappBaustein?.props?.textConfig?.contentConfig?.items || []) {
|
||
const titelKlapp = auf(stueck?.folderTitle);
|
||
const inhalt = aufMit(stueck, "content");
|
||
if (titelKlapp && inhalt) {
|
||
klapptexte.push({ titel: titelKlapp.trim(), text: textAusHtml(inhalt) });
|
||
}
|
||
}
|
||
if (!klapptexte.length) hinweise.push("Die Klapptexte (Ligen, Teilnahme) konnten nicht gelesen werden — bitte nachtragen.");
|
||
|
||
/* Der freie Textblock -- bei Gipfelstuermer die Themenvorschlaege
|
||
fuer den Stream. Steht auf der Seite, also wird er mitgelesen. */
|
||
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");
|
||
/* HIER STEHT ABSICHTLICH KEIN "nur https" (berichtigt 07.10.2026).
|
||
Vorher stand hier `/^https:/`. Das war eine ENTSCHEIDUNG an einer
|
||
Stelle, die nur LESEN soll -- und sie stand damit zum zweiten Mal
|
||
im Haus, denn `bildAdresseErlaubt` in helfer-tiktok.mjs prueft
|
||
Protokoll, Port und private Adressen ohnehin, bevor etwas geholt
|
||
wird.
|
||
|
||
Zwei Fassungen derselben Regel laufen auseinander, und hier tat es
|
||
sofort weh: Keine Pruefung konnte einen nachgebauten Bilddienst
|
||
auf 127.0.0.1 benutzen -- der Leser verwarf die Adresse, bevor die
|
||
Schranke sie je sah. Die Pruefung haette dafuer das echte TikTok
|
||
anfragen muessen.
|
||
|
||
Der Leser nimmt deshalb jede absolute Adresse. Ob sie abgerufen
|
||
werden DARF, entscheidet die Schranke. */
|
||
const bannerUrl = (bildBaustein?.props?.imageUrl || [])
|
||
.map((b) => b?.url || b?.urlSG)
|
||
.find((u) => typeof u === "string" && /^https?:\/\//i.test(u)) || null;
|
||
|
||
/* `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 = [];
|
||
try {
|
||
const roh = aktion.allowed_countries_split;
|
||
const feld = typeof roh === "string" ? JSON.parse(roh) : roh;
|
||
if (Array.isArray(feld)) laender = feld.map((l) => String(l).trim()).filter(Boolean);
|
||
} 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.");
|
||
}
|
||
|
||
return {
|
||
pflicht: { titel, start, ende, id, zone },
|
||
kuer: {
|
||
einleitung, einleitungTitel,
|
||
aufgaben, aufgabenTitel, aufgabenGruppen, weitereRegeln,
|
||
geschenke, geschenkTitel, geschenkHinweis,
|
||
preise, preisTitel,
|
||
klapptexte, freitext,
|
||
rangliste, steckbrief,
|
||
laender, bannerUrl: bannerAdresse,
|
||
},
|
||
hinweise,
|
||
/* ROH MITSPEICHERN (Teil 5 des Bauplans): Wenn in drei Monaten
|
||
auffaellt, dass noch etwas gebraucht wird, steht die Antwort
|
||
schon da -- abgelaufene Kampagnen antworten dann vielleicht
|
||
nicht mehr. Ein AUSZUG, nicht die ganzen 1,1 MB. */
|
||
auszug: {
|
||
gelesen_aus: woher,
|
||
sprache,
|
||
start_ms: startMs,
|
||
ende_ms: endeMs,
|
||
zeitzone: zone,
|
||
zeitzone_versatz: aktion.time_zone ?? null,
|
||
status: aktion.activity_status ?? null,
|
||
status_anzeige: aktion.client_activity_status ?? null,
|
||
kurznummer: aktion.short_id ?? null,
|
||
laender,
|
||
bausteine: [...teile.keys()],
|
||
/* DAS GELESENE SELBST GEHOERT MIT HINEIN (07.10.2026, beim Bauen
|
||
der Route aufgefallen).
|
||
|
||
Der Regeltext einer echten Kampagne ist 7474 Zeichen lang; das
|
||
Feld in der Kachel fasst 6000 (TEXT_MAX in
|
||
workspace-bereiche.js, und die PUT-Route lehnt mehr ab -- eine
|
||
Kachel mit 7474 Zeichen koennte DogFather oeffnen, aber nicht
|
||
speichern). Der Text wird deshalb an einer Abschnittsgrenze
|
||
gekuerzt. Damit dabei nichts VERLOREN geht, steht hier das
|
||
Gelesene in seiner gegliederten Form.
|
||
|
||
Das ist genau der Zweck, den der Bauplan fuer diese Spalte
|
||
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, 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,
|
||
},
|
||
};
|
||
}
|
||
|
||
/* ===================================================================
|
||
DIE TEXTE FUER DIE KACHEL
|
||
|
||
Getrennt von `leseKampagne`, weil es zwei verschiedene Fragen sind:
|
||
„Was steht auf der Seite?" und „Wie schreiben wir es in die
|
||
Kachel?". Das Zusammensetzen laesst sich dadurch aendern, ohne das
|
||
Lesen anzufassen -- und es laesst sich einzeln pruefen.
|
||
=================================================================== */
|
||
|
||
/** Der Text fuer `event_aufgaben`: eine Zeile je Haken.
|
||
*
|
||
* OHNE Aufzaehlungszeichen davor. `punkteAusText()` wuerde es
|
||
* wieder abschneiden -- aber es stuende im Speichertext, und
|
||
* DogFather faende beim Bearbeiten etwas vor, das er nie getippt
|
||
* hat. Ein Textfeld, das beim Speichern umformatiert wird, ist ein
|
||
* Textfeld, dem man nicht traut. */
|
||
export function aufgabenText(kuer) {
|
||
return (kuer?.aufgaben || []).join("\n");
|
||
}
|
||
|
||
/** Der Text fuer `event_regeln`: alles Uebrige, in fester Reihenfolge.
|
||
* Jeder Block nur, wenn es ihn gibt. */
|
||
export function regelnText(kuer, hinweise = []) {
|
||
const k = kuer || {};
|
||
const bloecke = [];
|
||
/* `## ` 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")];
|
||
if (k.geschenkHinweis) kopf.push(k.geschenkHinweis.trim());
|
||
bloecke.push([
|
||
kopf.join(" — "),
|
||
...k.geschenke.map((g) => `· ${g.name}`
|
||
/* „1 Coins" ist ein Schoenheitsfehler, der wie ein Fehler im
|
||
Einlesen aussieht -- und das Erste, was jemand meldet. */
|
||
+ (g.preis !== null ? ` — ${g.preis} Coin${g.preis === 1 ? "" : "s"}` : "")
|
||
+ (g.hinweis ? ` (${g.hinweis})` : "")),
|
||
].join("\n"));
|
||
}
|
||
|
||
for (const w of k.weitereRegeln || []) {
|
||
bloecke.push([gross(w.titel || "Weitere Regeln"),
|
||
...w.zeilen.map((z) => `· ${z}`)].join("\n"));
|
||
}
|
||
|
||
if (k.preise?.length) {
|
||
bloecke.push([
|
||
gross(k.preisTitel || "Preise"),
|
||
/* 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}`);
|
||
}
|
||
|
||
if (k.freitext) bloecke.push(k.freitext);
|
||
|
||
if (k.laender?.length) {
|
||
/* „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
|
||
in einer Antwort, die niemand mehr sieht. Eine Kachel, die
|
||
unvollstaendig ist, soll das selbst sagen.
|
||
|
||
UND ZWAR GANZ OBEN (berichtigt 07.10.2026). Vorher standen die
|
||
Hinweise am Ende. Gemessen: Der Regeltext von "Gipfelstuermer" ist
|
||
7474 Zeichen lang, das Feld fasst 6000 -- es wird also gekuerzt,
|
||
und gekuerzt wird von HINTEN. Die Hinweise waren damit genau das
|
||
Erste, was wegfiel: Eine Kachel, der die Preise fehlten, sagte es
|
||
nicht mehr. Die wichtigste Zeile darf nicht die sein, die als
|
||
erste verschwindet. */
|
||
/* KEIN ANGEHAENGTES „Bitte nachtragen" MEHR (07.10.2026).
|
||
|
||
Seit der Leser die zweite Vorlage kennt, gibt es zwei Sorten
|
||
Hinweis: „konnte nicht gelesen werden" (da ist etwas zu tun)
|
||
und „diese Kampagne hat keine Preisliste" (da ist nichts zu
|
||
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. */
|
||
/* 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].filter(Boolean).join("\n\n").trim();
|
||
}
|
||
|
||
/* ===================================================================
|
||
DER REGELTEXT, GEGLIEDERT (07.10.2026, zweite Runde)
|
||
|
||
Filipe, mit einem Bildschirmfoto des Regelblocks: „ich will das die
|
||
texte perfektionierter werden. man soll die titel in den texten
|
||
besser erkennen und sehen was zu was passt. ich will auch dass man
|
||
sieht welche geschenke bei dem event zaehlen und so."
|
||
|
||
Er hatte recht, und der Fehler lag nicht im Text, sondern in seiner
|
||
ANZEIGE. Im Feld stand die ganze Zeit eine Gliederung -- so sieht
|
||
der echte Inhalt der Kachel „LIVE Glow Up" aus:
|
||
|
||
* Diese Kampagne hat keine Preisliste auf der Seite.
|
||
|
||
SAMMLE DEIN FEUERWERK — Erhalte doppelte Punkte fuer die Rangliste
|
||
· Feuerwerk (Sammle Feuerwerk in deinem LIVE!)
|
||
|
||
INFOS ZUR KAMPAGNE
|
||
LIVE Glow Up ist eine exklusive 4-woechige Wachstum Challenge, ...
|
||
|
||
KAMPAGNENZEITPLAN
|
||
· Woche 1: 5. - 11.10.
|
||
|
||
Die Kachel hat daraus mit `punkteAusText()` EINE flache Liste
|
||
gemacht -- jede Zeile derselbe Strich, alle gleich gross.
|
||
„SAMMLE DEIN FEUERWERK" sah damit genauso aus wie „Feuerwerk",
|
||
„INFOS ZUR KAMPAGNE" wie der Absatz darunter, und die beiden
|
||
Hinweise standen zwischen den Regeln, als waeren sie welche.
|
||
|
||
DESHALB WIRD HIER GELESEN UND NICHT UMGESCHRIEBEN. Das Feld bleibt
|
||
Text: DogFather kann es bearbeiten, und was er tippt, bleibt
|
||
stehen. Die Gliederung entsteht beim Anzeigen aus dem, was schon
|
||
dasteht. Das hat einen zweiten, handfesten Vorteil -- die beiden
|
||
Kampagnen, die am 07.10.2026 bereits in der Datenbank liegen,
|
||
sehen sofort richtig aus. Ohne Umstellung, ohne erneutes Einlesen,
|
||
ohne ein Feld anzufassen, das jemandem gehoert.
|
||
|
||
---------------------------------------------------------------------
|
||
WIE EINE UEBERSCHRIFT ERKANNT WIRD
|
||
|
||
Eine Zeile ist eine Ueberschrift, wenn sie
|
||
- kein Aufzaehlungszeichen und kein `*` vorne hat,
|
||
- mindestens einen Grossbuchstaben und KEINEN Kleinbuchstaben
|
||
enthaelt (`\p{Lu}` ja, `\p{Ll}` nein),
|
||
- hoechstens 80 Zeichen lang ist,
|
||
- nicht mit `.`, `!` oder `?` endet.
|
||
Dazu `## Titel` fuer den Fall, dass jemand von Hand gliedern will.
|
||
|
||
`\p{Ll}` UND NICHT `text === text.toUpperCase()`: Bei „GROSSE"
|
||
waere beides gleich, bei einem Wort mit „ß" aber nicht --
|
||
`"ß".toUpperCase()` ist `"SS"`, und eine Ueberschrift mit ß waere
|
||
damit nie eine. Die Frage ist ohnehin „stehen hier Kleinbuchstaben",
|
||
nicht „laesst sich das noch vergroessern".
|
||
|
||
DER UNTERTITEL steht hinter einem Gedankenstrich mit Leerzeichen
|
||
(`SAMMLE DEIN FEUERWERK — Erhalte doppelte Punkte`). Geprueft wird
|
||
nur der Teil DAVOR auf Grossschreibung -- sonst waere genau diese
|
||
Zeile, die der Leser selbst so schreibt, keine Ueberschrift.
|
||
|
||
DAS IST EINE HEURISTIK, UND SIE TRAEGT ABSICHTLICH NICHTS.
|
||
Greift sie nicht, erscheint die Zeile als gewoehnlicher Absatz --
|
||
also genau so wie bisher. Es gibt keinen Fall, in dem ein
|
||
Fehlurteil Inhalt verliert, und keinen, in dem etwas davon abhaengt.
|
||
Der eine bekannte Fehlgriff ist eine Zeile aus nichts als
|
||
Laendercodes (`DE, CH, AT`): Die ist gross geschrieben und wuerde
|
||
zur Ueberschrift. `regelnText` schreibt deshalb „Länder: DE, CH,
|
||
AT" -- die Kleinbuchstaben im Wort davor sind die Sicherung, und
|
||
die Gegenprobe in pruef-kampagne-lesen.mjs haelt sie fest.
|
||
|
||
---------------------------------------------------------------------
|
||
WIE EIN GESCHENK ERKANNT WIRD
|
||
|
||
Nicht am Text, sondern an der LISTE. `gliederung(text, { geschenke })`
|
||
bekommt die Geschenke so, wie der Leser sie aus der Seite geholt
|
||
hat -- mit Name, Coinpreis und Hinweis. Eine Aufzaehlungszeile,
|
||
die mit einem dieser Namen beginnt, ist ein Geschenk, und der
|
||
Preis kommt aus der LISTE, nicht aus dem Text.
|
||
|
||
Warum nicht nach „Coins" im Text suchen? Weil „Feuerwerk (Sammle
|
||
Feuerwerk in deinem LIVE!)" gar keinen Preis hat -- es ist ein
|
||
Kampagnengeschenk, kein Coin-Geschenk -- und trotzdem das
|
||
Geschenk ist, das bei diesem Event zaehlt. Genau danach hat
|
||
Filipe gefragt. Die Textsuche nach „— N Coins" bleibt als
|
||
ZWEITER Weg daneben: Sie greift bei von Hand getippten Zeilen,
|
||
fuer die es keine Liste gibt.
|
||
|
||
DIE LAENGSTEN NAMEN ZUERST: Stuenden „Rose" und „Rose Gold" in
|
||
derselben Kampagne, wuerde ein Vergleich von vorne bei „Rose Gold"
|
||
den Eintrag „Rose" treffen und dessen Preis anschreiben.
|
||
=================================================================== */
|
||
|
||
/* `*` fehlt hier ABSICHTLICH, obwohl `PUNKT_ZEICHEN` in
|
||
workspace-bereiche.js es als Aufzaehlungszeichen fuehrt: `* ` ist
|
||
die Hinweismarke, die `regelnText` setzt. Stuende es in beiden
|
||
Mustern, haengte die Entscheidung an der Reihenfolge der Abfragen
|
||
-- und die ist genau die Sorte Abhaengigkeit, die beim naechsten
|
||
Umbau still kippt. */
|
||
const G_AUFZAEHLUNG = /^[ \t]*[-–—•·▪◦‣>+][ \t]*/u;
|
||
const G_HINWEIS = /^[ \t]*\*[ \t]+/u;
|
||
const G_RAUTE = /^[ \t]*#{1,6}[ \t]*/u;
|
||
const G_TRENNER = /[ \t]+[—–][ \t]+/u;
|
||
const G_COINS = /[ \t]+[—–][ \t]+([0-9]{1,9})[ \t]+Coins?$/u;
|
||
const G_SATZENDE = /[.!?]$/u;
|
||
const G_KLAMMER = /^\((.*)\)$/u;
|
||
const G_VORSPANN = /^[—–-][ \t]*/u;
|
||
const KOPF_MAX = 80;
|
||
|
||
/** Titel und Untertitel einer Zeile, am Gedankenstrich getrennt. */
|
||
function teileTitel(zeile) {
|
||
const t = String(zeile).trim();
|
||
const m = G_TRENNER.exec(t);
|
||
if (!m) return { titel: t, unter: null };
|
||
return {
|
||
titel: t.slice(0, m.index).trim(),
|
||
unter: t.slice(m.index + m[0].length).trim() || null,
|
||
};
|
||
}
|
||
|
||
/** Ist diese Zeile eine Ueberschrift? Dann Titel und Untertitel. */
|
||
/* ZWEI STUFEN, FLACH GEFUEHRT.
|
||
|
||
`##` sind die Abschnitte, die `regelnText` selbst bildet,
|
||
`###` die Ueberschriften aus einem fremden Text (Klapptexte,
|
||
siehe textAusHtml). Die Liste bleibt trotzdem FLACH: Ein
|
||
Baum waere im Anzeigecode drei Verschachtelungen tief, und
|
||
gewonnen waere nichts, was eine Einrueckung nicht auch
|
||
zeigt. Wer `stufe` nicht beachtet, bekommt die alte,
|
||
gleichrangige Darstellung -- kein Inhalt haengt daran. */
|
||
function kopfzeile(zeile) {
|
||
const t = String(zeile).trim();
|
||
if (!t) return null;
|
||
if (G_RAUTE.test(t)) {
|
||
const rauten = (/^[ \t]*(#{1,6})/u.exec(t) || ["", "##"])[1].length;
|
||
const ohne = t.replace(G_RAUTE, "").trim();
|
||
/* Gedeckelt auf 2 und 3: Mehr Stufen kann die Kachel nicht
|
||
zeigen, und eine vierte waere eine Auskunft, die niemand
|
||
sieht. */
|
||
return ohne ? { ...teileTitel(ohne), stufe: rauten <= 2 ? 2 : 3 } : null;
|
||
}
|
||
if (G_HINWEIS.test(t) || G_AUFZAEHLUNG.test(t)) return null;
|
||
const zerlegt = teileTitel(t);
|
||
const k = zerlegt.titel;
|
||
if (!k || k.length > KOPF_MAX) return null;
|
||
if (!/\p{Lu}/u.test(k)) return null;
|
||
if (/\p{Ll}/u.test(k)) return null;
|
||
if (G_SATZENDE.test(k)) return null;
|
||
return { ...zerlegt, stufe: 2 };
|
||
}
|
||
|
||
/* Die Art eines Abschnitts entscheidet ueber Zeichen und Farbe --
|
||
nicht ueber den Inhalt. Trifft kein Wort, bleibt sie leer und der
|
||
Abschnitt sieht schlicht aus. Deutsche Woerter in einer Tabelle
|
||
sind sonst genau die Sorte Vergleich, vor der CLAUDE.md warnt
|
||
(„IDs wandern nicht, Klartext schon") -- hier traegt sie aber
|
||
nichts: Der Text IST deutsch, weil die Kachel deutsch ist, und
|
||
ein Fehlgriff kostet ein Symbol. */
|
||
const ABSCHNITT_WORTE = [
|
||
[/rangliste|ranking/u, "rangliste"],
|
||
[/zeitplan|termin|phase|woche/u, "zeitplan"],
|
||
[/teilnahme|bedingung|land|länder|laender/u, "teilnahme"],
|
||
[/preis|belohnung|gewinn|reward/u, "preise"],
|
||
[/geschenk|gift/u, "geschenke"],
|
||
[/aufgabe|mission|regel/u, "regeln"],
|
||
[/info|ueber|über/u, "infos"],
|
||
];
|
||
|
||
function abschnittArt(a) {
|
||
if (a.zeilen.some((z) => z.art === "geschenk")) return "geschenke";
|
||
/* `every()` AUF EINEM LEEREN FELD IST `true` -- genau die Falle
|
||
aus CLAUDE.md (01.09.2026). Ein Abschnitt, der nur wegen
|
||
seiner Unterabschnitte stehen bleibt, hat keine Zeilen und
|
||
waere damit ein „Hinweisblock" -- also in der Kachel ein
|
||
stiller, grauer Kasten, obwohl es eine Ueberschrift ist. */
|
||
if (a.zeilen.length && a.zeilen.every((z) => z.art === "hinweis")) return "hinweise";
|
||
const t = (a.titel || "").toLocaleLowerCase("de-DE");
|
||
if (t) for (const [muster, art] of ABSCHNITT_WORTE) if (muster.test(t)) return art;
|
||
return null;
|
||
}
|
||
|
||
/** Zerlegt einen Regeltext in Abschnitte mit Ueberschrift.
|
||
*
|
||
* @param {string} text der Inhalt von `event_regeln`
|
||
* @param {{geschenke?: Array}} [zusatz] die gelesenen Geschenke
|
||
* @returns {Array<{titel: ?string, unter: ?string, art: ?string,
|
||
* zeilen: Array<object>}>}
|
||
*
|
||
* Zeilenarten: `hinweis` (was fehlt), `geschenk` (mit `coins` und
|
||
* `zusatz`), `punkt` (Aufzaehlung), `text` (Absatz).
|
||
*/
|
||
export function gliederung(text, zusatz = {}) {
|
||
const namen = (Array.isArray(zusatz.geschenke) ? zusatz.geschenke : [])
|
||
.filter((g) => g && typeof g.name === "string" && g.name.trim())
|
||
.map((g) => ({
|
||
name: g.name.trim(),
|
||
/* `Number(null)` IST 0, UND 0 IST ENDLICH (gemessen
|
||
07.10.2026, beim ersten Lauf gegen den echten
|
||
Datenbankeintrag). Hier stand
|
||
`Number.isFinite(Number(g.preis)) ? ... : null` -- und
|
||
„Feuerwerk", ein Kampagnengeschenk OHNE Coinpreis, bekam
|
||
dadurch „0 Coins" angeschrieben. Das ist keine
|
||
Kleinigkeit: „0 Coins" ist eine Preisangabe, und zwar
|
||
eine falsche. Erst auf „nichts da" pruefen, dann
|
||
rechnen. */
|
||
preis: (g.preis === null || g.preis === undefined || g.preis === "")
|
||
? null
|
||
: (Number.isFinite(Number(g.preis)) ? Number(g.preis) : null),
|
||
hinweis: typeof g.hinweis === "string" ? g.hinweis.trim() : null,
|
||
}))
|
||
.sort((a, b) => b.name.length - a.name.length);
|
||
|
||
const abschnitte = [];
|
||
let jetzt = null;
|
||
const neu = (titel, unter, stufe = 2) => {
|
||
jetzt = {
|
||
titel: titel || null, unter: unter || null,
|
||
stufe, art: null, zeilen: [],
|
||
};
|
||
abschnitte.push(jetzt);
|
||
};
|
||
|
||
for (const rohzeile of String(text || "").split(/\r?\n/)) {
|
||
const zeile = rohzeile.trim();
|
||
if (!zeile) continue;
|
||
|
||
const kopf = kopfzeile(zeile);
|
||
if (kopf) { neu(kopf.titel, kopf.unter, kopf.stufe); continue; }
|
||
|
||
/* Steht etwas VOR der ersten Ueberschrift -- wie die Hinweise --,
|
||
bekommt es einen Abschnitt ohne Titel. Der wird beim Anzeigen
|
||
ohne Ueberschrift ausgegeben, nicht mit einer erfundenen. */
|
||
if (!jetzt) neu(null, null);
|
||
|
||
if (G_HINWEIS.test(zeile)) {
|
||
jetzt.zeilen.push({ art: "hinweis", text: zeile.replace(G_HINWEIS, "").trim() });
|
||
continue;
|
||
}
|
||
|
||
if (G_AUFZAEHLUNG.test(zeile)) {
|
||
const nur = zeile.replace(G_AUFZAEHLUNG, "").trim();
|
||
/* Erst den Coinpreis abtrennen, DANN den Namen vergleichen:
|
||
sonst endet „300 Coins" als Zusatztext hinter dem Geschenk
|
||
und steht doppelt da -- einmal als Text, einmal als Zahl. */
|
||
const c = G_COINS.exec(nur);
|
||
const ohneCoins = c ? nur.slice(0, c.index).trim() : nur;
|
||
const coinsAusText = c ? Number(c[1]) : null;
|
||
|
||
const treffer = namen.find((g) => ohneCoins === g.name
|
||
|| ohneCoins.startsWith(`${g.name} `)
|
||
|| ohneCoins.startsWith(`${g.name}(`));
|
||
if (treffer) {
|
||
const rest = ohneCoins.slice(treffer.name.length).trim()
|
||
.replace(G_VORSPANN, "").trim().replace(G_KLAMMER, "$1").trim();
|
||
jetzt.zeilen.push({
|
||
art: "geschenk",
|
||
text: treffer.name,
|
||
coins: treffer.preis ?? coinsAusText,
|
||
zusatz: treffer.hinweis || rest || null,
|
||
});
|
||
continue;
|
||
}
|
||
jetzt.zeilen.push(coinsAusText !== null
|
||
? { art: "geschenk", text: ohneCoins, coins: coinsAusText, zusatz: null }
|
||
: { art: "punkt", text: nur });
|
||
continue;
|
||
}
|
||
|
||
jetzt.zeilen.push({ art: "text", text: zeile });
|
||
}
|
||
|
||
/* EINE FRAGE MIT ANTWORT DARUNTER IST EINE UEBERSCHRIFT
|
||
(07.10.2026).
|
||
|
||
Der Teilnahmeblock von „Gipfelstuermer" ist nach Fragen
|
||
gegliedert, und zwar durchgehend:
|
||
|
||
Um was geht es bei Gipfelstürmer?
|
||
Gipfelstürmer ist eine TikTok LIVE Kampagne, in welcher ...
|
||
Wer kann bei Gipfelstürmer teilnehmen?
|
||
Diese Kampagne findet von 04.10.2025 ... statt.
|
||
Wie kann ich Punkte sammeln?
|
||
...
|
||
|
||
Acht solche Fragen in rund 4000 Zeichen. Im Quelltext stehen
|
||
sie NICHT als Ueberschrift -- sie sind der erste Satz eines
|
||
Absatzes, getrennt durch `<br>` --, also kann `textAusHtml`
|
||
sie nicht als eine erkennen. Flach ausgegeben sahen sie wie
|
||
Fliesstext aus, und der ganze Block war eine Wand.
|
||
|
||
DIE REGEL: eine Textzeile, die mit `?` endet, hoechstens 90
|
||
Zeichen hat und auf die noch etwas folgt. Eine Frage, auf die
|
||
NICHTS folgt, ist keine Ueberschrift, sondern eine Frage --
|
||
„Hast du Fragen?" am Ende eines Blocks bleibt deshalb eine
|
||
gewoehnliche Zeile, solange darunter nichts steht.
|
||
|
||
DAS GESCHIEHT IM ANZEIGEN, NICHT IM TEXT. Im Feld aendert sich
|
||
nichts; wer die Regel fuer falsch haelt, bekommt eine Zeile
|
||
in Fettschrift zu sehen und keinen fehlenden Inhalt. Das ist
|
||
der Grund, warum sie ueberhaupt erlaubt ist: Sie raet, und
|
||
was sie raet, traegt nichts. */
|
||
for (const a of abschnitte) {
|
||
for (let i = 0; i < a.zeilen.length - 1; i += 1) {
|
||
const z = a.zeilen[i];
|
||
if (z.art !== "text" || z.text.length > 90 || !z.text.endsWith("?")) continue;
|
||
if (a.zeilen[i + 1].art !== "text" && a.zeilen[i + 1].art !== "punkt") continue;
|
||
z.art = "frage";
|
||
}
|
||
}
|
||
|
||
/* EIN ABSCHNITT OHNE ZEILEN FAELLT WEG -- ABER NICHT, WENN ER
|
||
UNTERABSCHNITTE HAT (berichtigt 07.10.2026, beim ersten Lauf
|
||
gegen „Gipfelstuermer").
|
||
|
||
Die Regel „ohne Zeilen, also weg" ist richtig fuer den Fall,
|
||
fuer den sie gedacht war: jemand loescht beim Bearbeiten den
|
||
Inhalt und laesst die Ueberschrift stehen. Gemessen hat sie
|
||
aber etwas anderes getroffen -- den Klapptext „WELCHE LIGA BIN
|
||
ICH?". Unter ihm steht keine einzige eigene Zeile, sondern
|
||
sechs Unterabschnitte („1. Liga", „2. Liga", ...). Die
|
||
Ueberschrift verschwand, und die sechs Ligen hingen danach
|
||
ohne Zusammenhang zwischen Rangliste und
|
||
Teilnahmebedingungen.
|
||
|
||
Ein Abschnitt bleibt deshalb, wenn ihm ein TIEFERER folgt.
|
||
Geprueft wird der unmittelbar naechste: Dazwischen kann
|
||
nichts liegen, denn jede Zeile gehoert zum vorherigen
|
||
Abschnitt. */
|
||
const fertig = abschnitte.filter((a, i) => a.zeilen.length
|
||
|| (abschnitte[i + 1] && abschnitte[i + 1].stufe > a.stufe));
|
||
for (const a of fertig) a.art = abschnittArt(a);
|
||
return fertig;
|
||
}
|