/* ===================================================================== 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 = /", 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+;|[0-9a-f]+;/gi, (m) => { const klein = m.toLowerCase(); if (ZEICHEN.has(klein)) return ZEICHEN.get(klein); if (klein.startsWith("")) { 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 `
Du kannst an dieser Liga teilnehmen, wenn ...
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 `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 `