diff --git a/.gitattributes b/.gitattributes index f3d49dc4..0e10c16b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -37,3 +37,18 @@ # Vorsorge, keine Reparatur: Ein Haken, der still nicht laeuft, ist # genau die Sicherung, die aussieht, als waere sie da. tools/git-haken/* text eol=lf + +# Pruefdaten sind BYTEGENAU und werden von keiner Regel angefasst. +# +# `server/pruef-kampagne-lesen.mjs` nagelt jede Datei dort mit ihrer +# SHA-256-Summe fest -- eine Pruefung, deren Eingabe sich aendern kann, +# beweist nichts. Ohne diese Zeile wuerde git `kampagne-kaputt.html` +# beim Auschecken auf CRLF umschreiben (core.autocrlf=true auf dem +# Entwicklungsrechner): 369 Bytes werden 378, die Summe stimmt nicht +# mehr, und die Pruefung meldet einen Schaden, den es nicht gibt. +# +# Gemessen am 07.10.2026 beim `git add` -- git hat es selbst angesagt +# ("LF will be replaced by CRLF the next time Git touches it"), und +# genau das waere die Sorte Fehlalarm, nach der man die Pruefung +# abschaltet. +server/pruefdaten/** -text diff --git a/server/helfer-tiktok.mjs b/server/helfer-tiktok.mjs new file mode 100644 index 00000000..cb97b6af --- /dev/null +++ b/server/helfer-tiktok.mjs @@ -0,0 +1,102 @@ +/* ===================================================================== + DER WEG NACH DRAUSSEN ZU TIKTOK — gemeinsam genutzt (07.10.2026) + + Diese Datei ist ein VERSCHIEBEN, keine Neuerfindung. `istTikTok` und + `holeMitFrist` standen bis heute in `workspace-video.js` und haben + sich dort bewährt — inklusive der Reparatur vom 05.10.2026, bei der + auffiel, dass die Frist nur den Anfang der Anfrage bewachte. + + Ab jetzt holt auch die Kampagnen-Kachel eine Seite von TikTok. Zwei + Fassungen derselben Regel wären der Fehler, den CLAUDE.md am + 11.09.2026 beschreibt: eine abgeschriebene Liste, die auseinander- + läuft. Gerade bei `istTikTok` wäre das gefährlich — es ist die eine + Stelle, die entscheidet, welchen fremden Rechner unser Server + überhaupt anfragt. Die zweite Fassung würde beim nächsten Fund + vergessen. + + --------------------------------------------------------------------- + WAS SICH BEIM VERSCHIEBEN GEÄNDERT HAT — und nur das + + 1. `holeMitFrist` nimmt die Frist jetzt als Zusatz entgegen, mit + 8000 ms als Vorgabe. Das Video bleibt damit Zeichen für Zeichen + beim alten Verhalten; die Kampagnenseite braucht mehr (1,1 MB + statt einer kleinen Auskunft) und sagt das am Aufrufort, nicht + über eine zweite Konstante irgendwo. + + 2. `istTikTok` erkennt die Prüfumgebung jetzt an BEIDEN + Umstellschaltern — `TIKTOK_OEMBED_BASIS` (Video) und + `TIKTOK_KAMPAGNE_BASIS` (Kampagne). Ohne das zweite müsste die + Kampagnenprüfung die echte Regel umgehen und prüfte damit eine + andere als die, die im Betrieb gilt. + + Sonst nichts. Kein neues Verhalten, keine neue Grenze, keine neue + Entscheidung. Wer hier etwas vermisst (Umleitungen Station für + Station, Größengrenze beim Lesen), findet es in Etappe 2 — nicht + hier, und nicht halb. + + --------------------------------------------------------------------- + KEINE DATENBANK, KEIN EXPRESS + + Absichtlich: Diese Datei lässt sich in einer Prüfung einzeln + einbinden, ohne dass ein Server hochfährt. Dasselbe Argument wie bei + `helfer-tag.mjs`. + ===================================================================== */ + +/* GRENZEN FÜR DEN WEG NACH DRAUSSEN. Ein fremder Dienst, der nicht + antwortet, darf unsere Anfrage nicht mit sich ziehen -- genau daran + ist in diesem Haus schon ein ganzer Pruefdurchlauf drei Stunden lang + haengengeblieben. */ +export const FRIST_MS = 8000; + +/** Ist das ueberhaupt eine TikTok-Adresse? Keine Zeichenkettensuche, + * sondern der Rechnername -- "tiktok.com.beispiel.de" enthaelt + * "tiktok.com" und gehoert jemand anderem. */ +export function istTikTok(roh) { + try { + const u = new URL(String(roh)); + const h = u.hostname.toLowerCase(); + /* NUR IN EINER PRUEFUNG: Dann liegt der nachgebaute Dienst auf + 127.0.0.1, und die Adresse des "Videos" auch. Ohne diese Zeile + muesste die Pruefung die echte Regel umgehen -- und pruefte + damit eine andere als die, die im Betrieb gilt. */ + if ((process.env.TIKTOK_OEMBED_BASIS || process.env.TIKTOK_KAMPAGNE_BASIS) + && (h === "127.0.0.1" || h === "localhost")) { + return true; + } + if (u.protocol !== "https:") return false; + return h === "tiktok.com" || h.endsWith(".tiktok.com"); + } catch { return false; } +} + +/** Holt etwas von draussen -- MIT Frist ueber den ganzen Vorgang. + * + * DIE ALTE FASSUNG HAT NUR DEN ANFANG BEWACHT (behoben 05.10.2026). + * + * Sie gab die Antwort zurueck, sobald die Kopfzeilen da waren, und + * raeumte im `finally` die Uhr weg. Was danach kam -- `a.json()` beim + * Abruf der Videodaten, `a.arrayBuffer()` beim Vorschaubild -- lief + * OHNE jede Frist. Bleibt der fremde Server mitten im Koerper + * stehen, wartet unsere Anfrage fuer immer. + * + * Genau das sieht man als "der Knopf bleibt auf `wird geholt ...` + * stehen": keine Fehlermeldung, kein Eintrag, kein Protokolleintrag + * -- weil der Vorgang nie endet. Und es braucht keine Aenderung bei + * uns, um anzufangen: Es haengt am fremden Netz. "Es ging doch immer + * bis jetzt" ist bei so einem Fehler die Regel, nicht die Ausnahme. + * + * Der Koerper wird deshalb GELESEN, solange die Uhr laeuft, und erst + * danach abgeraeumt. Die Frist gilt damit fuer alles zusammen. */ +export async function holeMitFrist(adresse, kopf = {}, { frist = FRIST_MS } = {}) { + const abbruch = new AbortController(); + const uhr = setTimeout(() => abbruch.abort(), frist); + try { + const a = await fetch(adresse, { signal: abbruch.signal, headers: kopf, redirect: "follow" }); + /* Bei einem Fehlschlag interessiert der Koerper nicht -- aber er + muss trotzdem weg, sonst bleibt die Verbindung offen. */ + const roh = Buffer.from(await a.arrayBuffer()); + return { + ok: a.ok, status: a.status, headers: a.headers, roh, + text: () => roh.toString("utf8"), + }; + } finally { clearTimeout(uhr); } +} diff --git a/server/kampagne-lesen.mjs b/server/kampagne-lesen.mjs new file mode 100644 index 00000000..3cb6f9a5 --- /dev/null +++ b/server/kampagne-lesen.mjs @@ -0,0 +1,663 @@ +/* ===================================================================== + 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. */ +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 `
gut
") === "gut", "Skripte fallen weg"); +ok(textAusHtml("") === "" && textAusHtml(null) === "" && textAusHtml(undefined) === "", + "nichts hinein ergibt nichts heraus"); +ok(textAusHtml("a
\n\n\n\nb
") === "a\n\nb", + "mehr als eine Leerzeile wird eine"); + +/* ===================================================================== + 6. DIE BEENDETE KAMPAGNE — der Fall, den der Bauplan nicht kannte + ===================================================================== */ +melde(""); +melde("=== 6. Eine abgelaufene Kampagne hat kein Interaktionsschema ==="); + +{ + const wert = datenAusSeite(seite.beendet); + /* GEGENPROBE/VORAUSSETZUNG: Das Feld ist wirklich weg. Sonst würde + der Rückfallweg gar nicht geprüft, und die Zeilen danach wären + grün, ohne etwas zu messen. */ + ok(!("ac_schema_with_interaction_rules" in wert.globalData.activityInfo), + "activityInfo.ac_schema_with_interaction_rules fehlt in dieser Seite"); + ok(wert.globalData.activityInfo.activity_status === 16, + `ihr Zustand ist „beendet“ (${wert.globalData.activityInfo.activity_status})`); + ok(!!wert.schema, "aber value.schema ist da"); + + const b = leseKampagne(seite.beendet); + ok(b.auszug.gelesen_aus === "value.schema", + `gelesen wurde aus ${b.auszug.gelesen_aus}`); + ok(b.pflicht.titel === "Sonne im Gepäck", `Titel: ${b.pflicht.titel}`); + ok(b.pflicht.id === "7657456323453405959", `Nummer: ${b.pflicht.id}`); + ok(b.pflicht.start === "2026-07-03" && b.pflicht.ende === "2026-07-07", + `Zeitraum: ${b.pflicht.start} bis ${b.pflicht.ende}`); + ok(b.hinweise.length === 0, + `und vollständig gelesen (${b.hinweise.length} Hinweise)`); + + /* Die Bausteine heißen dort `…_rep_remove`. Dass der Name trotzdem + erkannt wird, ist die eigentliche Leistung. */ + ok(JSON.stringify(wert.schema).includes("_rep_remove"), + "ihre Bausteinnamen tragen die Endung _rep_remove"); + ok(b.auszug.bausteine.length === 10 + && b.auszug.bausteine.every((n) => !n.endsWith("_rep_remove")), + `${b.auszug.bausteine.length} Bausteine erkannt, Endung abgeschnitten`); + ok(b.kuer.aufgaben.length > 0 && b.kuer.preise.length > 0 && !!b.kuer.bannerUrl, + `Aufgaben (${b.kuer.aufgaben.length}), Preise (${b.kuer.preise.length}) und Banner da`); +} + +/* ===================================================================== + 7. OHNE PREISE — 201 mit Hinweis, nicht 502 + ===================================================================== */ +melde(""); +melde("=== 7. Eine Kampagne ohne Preise ist unvollständig, nicht unlesbar ==="); + +{ + const p = leseKampagne(seite.ohnePreise); + ok(p.pflicht.titel === "Stern Stunde" && !!p.pflicht.start && !!p.pflicht.ende, + `sie wird gelesen: ${p.pflicht.titel}, ${p.pflicht.start} bis ${p.pflicht.ende}`); + ok(p.kuer.preise.length === 0, `keine Preise gelesen (${p.kuer.preise.length})`); + ok(p.hinweise.some((h) => h.includes("Preise")), + `und das steht als Hinweis da: „${p.hinweise.find((h) => h.includes("Preise"))}“`); + ok(p.hinweise.length === 1, + `genau ein Hinweis, nicht mehr (${p.hinweise.length}) — nur die Preise fehlen`); + ok(p.kuer.aufgaben.length > 0 && p.kuer.geschenke.length > 0 && !!p.kuer.bannerUrl, + `alles andere ist da: ${p.kuer.aufgaben.length} Aufgaben,` + + ` ${p.kuer.geschenke.length} Geschenke, Banner ja`); + ok(regelnText(p.kuer, p.hinweise).includes("Bitte nachtragen"), + "die Kachel sagt selbst, dass sie unvollständig ist"); + ok(!regelnText(p.kuer, p.hinweise).includes("BELOHNUNGEN"), + "und behauptet keinen Preisabschnitt, den es nicht gibt"); + + /* DER MILDERE FALL: Baustein da, Liste leer. Hier aus Gipfelstürmer + im Speicher hergestellt — beide Wege müssen denselben Hinweis + ergeben, sonst hängt das Verhalten daran, WIE TikTok nichts + konfiguriert hat. */ + const leer = umbauen(seite.gipfel, (w) => { + const s = JSON.parse(w.globalData.activityInfo.ac_schema_with_interaction_rules); + let getroffen = 0; + (function lauf(k, t = 0) { + if (!k || typeof k !== "object" || t > 20) return; + if (typeof k.type === "string" && k.type.includes("live_reward_introduction") + && k.props?.rewardConfig) { k.props.rewardConfig.list = []; getroffen++; } + for (const v of Object.values(k)) { + if (Array.isArray(v)) { for (const x of v) lauf(x, t + 1); } + else if (v && typeof v === "object") lauf(v, t + 1); + } + })(s); + if (getroffen !== 1) throw new Error(`erwartete 1 Preisbaustein, fand ${getroffen}`); + w.globalData.activityInfo.ac_schema_with_interaction_rules = JSON.stringify(s); + }); + const l = leseKampagne(leer); + ok(l.kuer.preise.length === 0 && l.hinweise.some((h) => h.includes("Preise")), + "auch ein leerer Preisbaustein ergibt denselben Hinweis"); + ok(l.kuer.aufgaben.length === 5, + `und der Rest bleibt unberührt (${l.kuer.aufgaben.length} Aufgaben)`); +} + +/* ===================================================================== + 8. DER DRITTE AUSGANG DES LESERS — Aufbau unbekannt + ===================================================================== */ +melde(""); +melde("=== 8. Wenn TikTok die Seite umbaut ==="); + +/** Macht das Datenpaket auf, lässt daran ändern, schreibt es zurück. + * Steht hier und nicht im Leser: Der Leser liest, er baut nicht. */ +function umbauen(html, aendern) { + const marke = html.indexOf("id=\"__MODERN_ROUTER_DATA__\""); + const a = html.indexOf(">", marke) + 1; + const e = html.indexOf("", a); + const daten = JSON.parse(html.slice(a, e)); + aendern(daten.loaderData.home_page.COSMIC_D_K.value, daten); + return html.slice(0, a) + JSON.stringify(daten) + html.slice(e); +} + +/** Fängt den Fehler und gibt ihn zurück — oder `null`, wenn keiner kam. */ +function faengt(tun) { + try { tun(); return null; } catch (f) { return f; } +} + +{ + const f = faengt(() => leseKampagne(seite.kaputt)); + ok(f instanceof KampagneUnlesbar, `es gibt einen Fehler, und zwar ${f?.name}`); + ok(f?.art === "aufbau", `Sorte „${f?.art}“ — die Seite, nicht das Feld`); + ok(/__MODERN_ROUTER_DATA__/.test(f?.grund || ""), + `der Grund nennt, was fehlt: „${f?.grund}“`); + + /* GEGENPROBE: Die vollständige Seite wirft NICHT. Ohne diese Zeile + wäre eine Lesefunktion, die immer wirft, hier grün. */ + ok(faengt(() => leseKampagne(seite.gipfel)) === null, + "Gegenprobe: die vollständige Seite wirft nicht"); + + /* Die kaputte Seite enthält bewusst ein ANDERES JSON-Skript und eine + Zeile mit der Kampagnennummer. Wer „das erste Skript“ nimmt oder + „irgendwo die Nummer“, wird hier grün und im Betrieb falsch. */ + ok(seite.kaputt.includes("__NEXT_DATA__") + && seite.kaputt.includes("7691493175936699143"), + "sie enthält einen Lockvogel: ein anderes JSON und die Nummer"); + + /* Paket da, aber ohne unseren Ast. */ + const ohneAst = seite.gipfel.replace(/"COSMIC_D_K"/, "\"COSMIC_ANDERS\""); + const f2 = faengt(() => leseKampagne(ohneAst)); + ok(f2?.art === "aufbau" && /COSMIC_D_K/.test(f2?.grund || ""), + `ein umbenannter Ast wird benannt: „${f2?.grund}“`); + + /* Paket da, aber kein JSON. */ + const kaputtesJson = seite.gipfel.replace( + "id=\"__MODERN_ROUTER_DATA__\">{", "id=\"__MODERN_ROUTER_DATA__\">{{"); + const f3 = faengt(() => leseKampagne(kaputtesJson)); + ok(f3?.art === "aufbau" && /kein lesbares JSON/.test(f3?.grund || ""), + `unlesbares JSON wird als solches gemeldet: „${(f3?.grund || "").slice(0, 70)}…“`); + + /* Paket und Ast da, aber beide Gerüstquellen weg. */ + const ohneGeruest = umbauen(seite.gipfel, (w) => { + delete w.globalData.activityInfo.ac_schema_with_interaction_rules; + delete w.schema; + }); + const f4 = faengt(() => leseKampagne(ohneGeruest)); + ok(f4?.art === "aufbau" && /Seitengeruest fehlt/.test(f4?.grund || ""), + `fehlt BEIDES, sagt der Grund das: „${(f4?.grund || "").slice(0, 60)}…“`); + + /* Und: die zweite Quelle rettet die erste. */ + const nurZweite = umbauen(seite.gipfel, (w) => { + w.schema = w.globalData.activityInfo.ac_schema_with_interaction_rules; + delete w.globalData.activityInfo.ac_schema_with_interaction_rules; + }); + const z = leseKampagne(nurZweite); + ok(z.auszug.gelesen_aus === "value.schema" && z.kuer.aufgaben.length === 5, + "fällt die erste Quelle weg, liest die zweite dasselbe"); +} + +/* ===================================================================== + 9. DER ZWEITE AUSGANG — Pflichtfeld fehlt, KEINE Kachel + ===================================================================== */ +melde(""); +melde("=== 9. Ohne Zeitraum entsteht keine Kachel ==="); + +{ + const ohneEnde = umbauen(seite.gipfel, (w) => { delete w.globalData.activityInfo.end_time; }); + const f = faengt(() => leseKampagne(ohneEnde)); + ok(f instanceof KampagneUnlesbar && f.art === "pflicht", + `ohne Enddatum: ${f?.name}, Sorte „${f?.art}“`); + ok(/Ende/.test(f?.grund || ""), `und der Grund sagt welches Feld: „${f?.grund}“`); + + const ohneStart = umbauen(seite.gipfel, (w) => { w.globalData.activityInfo.start_time = null; }); + ok(faengt(() => leseKampagne(ohneStart))?.art === "pflicht", "ohne Beginn ebenso"); + + const ohneTitel = umbauen(seite.gipfel, (w) => { w.globalData.pageInfo.title = " "; }); + ok(faengt(() => leseKampagne(ohneTitel))?.art === "pflicht", "ohne Titel ebenso"); + + /* DIE ZONE IST PFLICHT, und sie wird NICHT ersetzt. `time_zone` + bleibt in der Seite stehen — wer darauf ausweicht, rechnet nach + dem 25.10.2026 falsch. */ + const ohneZone = umbauen(seite.gipfel, (w) => { + delete w.globalData.activityInfo.tz_location_name; + }); + const fz = faengt(() => leseKampagne(ohneZone)); + ok(fz?.art === "pflicht" && /tz_location_name/.test(fz?.grund || ""), + `ohne Zeitzone: „${fz?.grund}“`); + ok(datenAusSeite(ohneZone).globalData.activityInfo.time_zone === "+02:00", + "Gegenprobe: der feste Versatz +02:00 stand bereit und wurde NICHT genommen"); + + /* Sekunden statt Millisekunden wären das Jahr 1970 — und ein Datum + von 1970 sieht aus wie ein Datum. */ + const sekunden = umbauen(seite.gipfel, (w) => { + w.globalData.activityInfo.end_time = Math.floor(1_791_410_399_000 / 1000); + }); + const fs = faengt(() => leseKampagne(sekunden)); + ok(fs?.art === "pflicht", + "ein Zeitstempel in Sekunden wird abgelehnt, nicht ins Jahr 1970 gerechnet"); + + /* Und zwei fehlende Felder nennt er BEIDE. */ + const zwei = umbauen(seite.gipfel, (w) => { + delete w.globalData.activityInfo.end_time; + w.globalData.pageInfo.title = ""; + }); + const f2 = faengt(() => leseKampagne(zwei)); + ok(/Ende/.test(f2?.grund || "") && /Titel/.test(f2?.grund || ""), + `fehlen zwei, nennt er zwei: „${f2?.grund}“`); +} + +/* ===================================================================== + 10. EIN PLATZHALTER OHNE TEXT FÄLLT AUF + ===================================================================== */ +melde(""); +melde("=== 10. Ein Wörterbuch mit Lücken wird gemeldet, nicht verschwiegen ==="); + +{ + const luecke = umbauen(seite.gipfel, (w) => { + const s = JSON.parse(w.globalData.activityInfo.ac_schema_with_interaction_rules); + /* Beide Wörterbücher anfassen -- der Leser mischt sie. */ + /* AUS BEIDEN WOERTERBUECHERN DIESELBEN Schluessel. + Beim ersten Versuch wurden sechs Eintraege nur aus + `value.sourceTextMap` geloescht -- und nichts fehlte, weil der + Leser beide mischt und das Woerterbuch IM SCHEMA einspringt. + Das ist richtiges Verhalten und hier gleich mitbewiesen: Erst + wenn der Schluessel in BEIDEN fehlt, fehlt der Text. */ + const treffer = Object.keys(w.sourceTextMap || {}) + .filter((k) => k.includes("rewardIntroFormData")).slice(0, 6); + if (treffer.length < 2) throw new Error("keine Preis-Einträge zum Löschen gefunden"); + for (const k of treffer) { delete w.sourceTextMap[k]; delete (s.sourceTextMap || {})[k]; } + const raus = treffer.length; + void raus; + w.globalData.activityInfo.ac_schema_with_interaction_rules = JSON.stringify(s); + }); + const l = leseKampagne(luecke); + ok(l.auszug.platzhalter_ohne_eintrag > 0, + `${l.auszug.platzhalter_ohne_eintrag} Platzhalter ohne Text — und das steht im Auszug`); + ok(l.hinweise.some((h) => h.includes("Wörterbuch")), + `und als Hinweis: „${l.hinweise.find((h) => h.includes("Wörterbuch"))}“`); + const text = regelnText(l.kuer, l.hinweise) + aufgabenText(l.kuer); + ok(!text.includes("$$__activity_"), + "und kein Platzhalter steht dafür in der Kachel"); +} + +/* ===================================================================== + 11. DER LESER LIEST NUR — nachgewiesen am Quelltext + ===================================================================== */ +melde(""); +melde("=== 11. Kein Netz, keine Datenbank, keine Uhr — im Quelltext ==="); + +{ + const quelle = readFileSync(join(HIER, "kampagne-lesen.mjs"), "utf8"); + /* Kommentare weg: Der Kopf der Datei SPRICHT über fetch und + Datenbank, und ein Wort in einer Begründung ist kein Aufruf. */ + const code = quelle + .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")) + .replace(/^\s*\/\/.*$/gm, ""); + + const verboten = [ + [/\bfetch\s*\(/, "fetch("], + [/node:sqlite/, "node:sqlite"], + [/node:fs|node:http|node:https|node:net|node:dns|node:child_process/, "ein Zugriff nach draußen"], + [/require\s*\(/, "require("], + [/\bexpress\b/, "express"], + [/Date\s*\.\s*now\s*\(/, "Date.now()"], + [/new\s+Date\s*\(\s*\)/, "new Date() ohne Argument"], + ]; + const gefunden = verboten.filter(([muster]) => muster.test(code)).map(([, n]) => n); + ok(code.length > 5000 && gefunden.length === 0, + `${code.length} Zeichen Code geprüft, nichts davon verboten` + + (gefunden.length ? ` — gefunden: ${gefunden.join(", ")}` : "")); + + const einfuhren = (code.match(/^import\s/gm) || []).length; + ok(einfuhren === 0, + `die Datei führt NICHTS ein (${einfuhren}) — sie hängt an nichts`); + + /* GEGENPROBE: Derselbe Prüfer findet `fetch(` dort, wo es hingehört. + Ohne sie wäre die Zeile oben auch grün, wenn das Muster nie passt. */ + const helfer = readFileSync(join(HIER, "helfer-tiktok.mjs"), "utf8") + .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")); + ok(/\bfetch\s*\(/.test(helfer), + "Gegenprobe: derselbe Prüfer findet fetch( in helfer-tiktok.mjs"); +} + +/* ===================================================================== + 12. ZWEIMAL LESEN GIBT ZWEIMAL DASSELBE + ===================================================================== */ +melde(""); +melde("=== 12. Gleiche Eingabe, gleiches Ergebnis ==="); + +{ + const a = JSON.stringify(leseKampagne(seite.gipfel)); + const b = JSON.stringify(leseKampagne(seite.gipfel)); + ok(a.length > 2000 && a === b, + `zwei Läufe, ${a.length} Zeichen, Zeichen für Zeichen gleich`); + + /* Und alle vier Seiten zusammen, damit keine davon still ausfällt. */ + let gelesen = 0; + let geworfen = 0; + for (const [name, text] of Object.entries(seite)) { + try { leseKampagne(text); gelesen++; } + catch { geworfen++; void name; } + } + ok(gelesen === 3 && geworfen === 1, + `${gelesen} der vier Seiten lesen sich, ${geworfen} wirft (die kaputte)`); +} + +melde(""); +melde(`${geprueft} Pruefungen, ${fehler} Fehler`); +melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG"); +process.exit(fehler ? 1 : 0); diff --git a/server/pruef-struktur.mjs b/server/pruef-struktur.mjs index 3011708b..a8fe39d5 100644 --- a/server/pruef-struktur.mjs +++ b/server/pruef-struktur.mjs @@ -1190,7 +1190,28 @@ console.log(`\n=== Ruft ein Modul etwas, das es nie eingefuehrt hat? ===`); return raus; }; - const quellen = ["workspace.js", "helfer-zeit.mjs", "helfer-tag.mjs"]; + /* ABGELEITET, NICHT AUFGEZAEHLT -- nachgetragen am 07.10.2026. + + Der Kommentar oben verspricht das seit immer; die Zeile darunter + hielt es nicht ein. Hier standen drei Namen von Hand, und damit + war der Wachposten so weit blind, wie die Liste alt war: Nur + Namen aus workspace.js, helfer-zeit.mjs und helfer-tag.mjs + konnten ueberhaupt auffallen. + + GEMESSEN beim Verschieben von istTikTok (07.10.2026): Die alte + Liste kannte 98 Namen und sah 508 Aufrufe an -- die abgeleitete + kennt 148 und sieht 1025. In der Luecke dazwischen stand ein + echter Fehler: mess-fokus.mjs rief eigenerPort() auf, und die + Einfuhr dazu lag INNERHALB eines Blockkommentars. Die Datei + brach beim Start mit ReferenceError ab -- seit dem 01.10.2026, + ohne dass es jemandem auffiel, weil sie von Hand gestartet wird. + Genau der Fall aus dem Kommentar oben: die Datei faellt erst um, + wenn jemand sie braucht. + + Die Liste ist jetzt keine mehr. Wer einen neuen Helfer anlegt, + ist ohne eigenes Zutun mitgeprueft. */ + const quellen = ["workspace.js", ...readdirSync(SERVER) + .filter((n) => n.startsWith("helfer-") && n.endsWith(".mjs")).sort()]; const hausNamen = new Map(); // Name -> woher for (const q of quellen) { for (const n of exporte(readFileSync(join(SERVER, q), "utf8"))) hausNamen.set(n, q); @@ -1369,7 +1390,18 @@ console.log(`\n=== Wird der TAG in Ortszeit gebildet -- ueberall? ===`); LISTE, DIE NIEMAND PFLEGT. Deshalb wird nachgesehen, dass jede ausgenommene Datei das Muster WIRKLICH enthaelt — raeumt es jemand weg, ist die Ausnahme veraltet und faellt auf. */ - const AUSNAHMEN_UTC = new Set(["pruef-struktur.mjs", "pruef-tagesgrenze.mjs"]); + const AUSNAHMEN_UTC = new Set(["pruef-struktur.mjs", "pruef-tagesgrenze.mjs", + /* pruef-kampagne-lesen.mjs (07.10.2026): aus demselben Grund wie + pruef-tagesgrenze.mjs. Sie stellt den falschen Weg dem richtigen + gegenueber und WEIST NACH, dass der Unterschied auffaellt: + + start_time 1791064800000 + toISOString().slice(0, 10) -> "2026-10-03" falsch + tagInZone(…, "Europe/Berlin")-> "2026-10-04" richtig + + Die beiden Zeilen sind die Gegenproben selbst. Ohne sie bewiese + die Pruefung nur, dass eine Zahl gleich einer anderen ist. */ + "pruef-kampagne-lesen.mjs"]); { const schal = [...AUSNAHMEN_UTC].filter((n) => { const t = readFileSync(join(SERVER, n), "utf8"); diff --git a/server/pruefdaten/LIESMICH.md b/server/pruefdaten/LIESMICH.md new file mode 100644 index 00000000..d605ad85 --- /dev/null +++ b/server/pruefdaten/LIESMICH.md @@ -0,0 +1,98 @@ +# Gespeicherte TikTok-Kampagnenseiten — Herkunft und Eingriffe + +Diese Dateien sind die Eingabe für `server/pruef-kampagne-lesen.mjs`. +Sie liegen hier, damit das Auslesen einer Kampagnenseite **ohne Netz, ohne Server +und ohne Datenbank** geprüft werden kann. Eine Prüfung, die dafür das echte TikTok +braucht, misst fremde Verfügbarkeit statt unseren Code und wird irgendwann rot, +ohne dass etwas kaputt ist. + +## Woher sie kommen + +Alle vier wurden am **07.10.2026** von `www.tiktok.com` geholt, **unangemeldet**. +Das ist nachprüfbar in den Dateien selbst: + + globalData.userInfo = { "uid": "0" } + env.anchor_id, env.user_id = "" + +Es steckt also **keine Person** darin — kein Name, kein Handle, keine Sitzung, kein +Keks. Die 6 Treffer für „nickname" sind Einstellungsnamen +(`hide_user_nickname`) und ein Platzhalter (`{nickname_or_handlename}`), keine echte +Person. Deshalb dürfen sie hier liegen. + +## Warum gepackt (Brotli) und nicht als Klartext + +Eine Seite ist rund 1 MB; gepackt sind es 150 KB. Das Zurückpacken +(`brotliDecompressSync`, in Node eingebaut, keine neue Abhängigkeit) wurde beim +Anlegen jeder Datei **sofort gegengeprüft**: Der Inhalt kommt Byte für Byte +identisch zurück. Eine Ablage, die man nie zurückgespielt hat, ist eine Hoffnung. + +Die Prüfung hält zusätzlich die SHA-256-Summe des **entpackten** Inhalts fest. +Ändert sich eine Datei, wird die Prüfung rot und nennt die neue Summe — das ist +Absicht: Eine Prüfdatei, die sich still ändert, macht jedes grüne Häkchen darüber +wertlos. + +## Die vier Dateien + +| Datei | Kampagne | `activity_id` | Zustand | Eingriff | +|---|---|---|---|---| +| `kampagne-gipfelstuermer.html.br` | Gipfelstürmer | 7691493175936699143 | läuft (`activity_status` 15) | **keiner** | +| `kampagne-beendet.html.br` | Sonne im Gepäck | 7657456323453405959 | beendet (16) | **keiner** | +| `kampagne-ohne-preise.html.br` | Stern Stunde | 7670899782609996552 | beendet (16) | Preisbaustein entfernt, siehe unten | +| `kampagne-kaputt.html` | — | — | — | vollständig selbst geschrieben | + +### `kampagne-gipfelstuermer.html.br` — der vollständige Fall + +Unverändert. Alles, was eine Kachel braucht, ist darin: Titel, Zeitraum, Zone, +5 Aufgaben, 5 Kampagnengeschenke, 6 Preisgruppen, 2 Klapptexte (Ligen und +Teilnahmebedingungen), Banner, Länder. + +Sie ist außerdem der Beleg für den wichtigsten Satz des Lesers: +**Im Wörterbuch stehen 135 Einträge, im Seitengerüst nur 134 Platzhalter.** Der +Überzählige lautet „1 schenkende Person = 100 Punkte" und gehört zu einer früheren +Fassung der Kampagne. Wer das Wörterbuch durchliest statt im Gerüst +nachzuschlagen, schreibt diese Regel in die Kachel, und das Team richtet seinen +Stream danach aus. + +### `kampagne-beendet.html.br` — der Fall, den der Bauplan nicht kannte + +Unverändert. Diese Seite liefert `activityInfo.ac_schema_with_interaction_rules` +**nicht mehr** — bei einer beendeten Kampagne ist das Feld weg. Das Gerüst steht nur +noch unter `value.schema`, und die Bausteine heißen dort `…_rep_remove`. + +Ohne diese Datei wäre der Rückfallweg ungeprüft, und abgelaufene Kampagnen ließen +sich nicht nachtragen. + +### `kampagne-ohne-preise.html.br` — der einzige Eingriff + +**Es gibt keine echte Kampagne ohne Preise.** Alle vier am 07.10.2026 geholten +Seiten haben sechs Preisgruppen. Dieser Fall muss deshalb hergestellt werden, und +das ist hier offen dokumentiert, statt in einem Skript zu verschwinden: + +Grundlage ist die unveränderte Seite von **Stern Stunde**. Daraus wurde in +`loaderData.home_page.COSMIC_D_K.value` jeder Baustein entfernt, dessen `type` +`live_reward_introduction` enthält — **zwei** Stück, einer in +`activityInfo.ac_schema_with_interaction_rules`, einer in `value.schema`. Danach +wurde das Datenpaket neu geschrieben (`JSON.stringify`); die Seite um das Paket +herum ist unverändert. + +Entfernt wurde der **ganze Baustein**, nicht nur seine Liste. Das ist der härtere +Fall: Dann fehlt auch die Überschrift, und der Leser kann nicht einmal sagen, wie +der Abschnitt hieß. Der mildere Fall (Baustein da, Liste leer) wird in der Prüfung +zusätzlich im Speicher aus Gipfelstürmer hergestellt — beide müssen denselben +Hinweis erzeugen. + +### `kampagne-kaputt.html` — vollständig selbst geschrieben + +369 Bytes, kein echter Abzug. Eine Seite, die aussieht wie eine TikTok-Seite und +das Datenpaket **nicht** enthält. Sie steht für „TikTok hat die Seite umgebaut" und +muss den dritten Ausgang auslösen: `KampagneUnlesbar` mit `art: "aufbau"` und einem +Grund, der `__MODERN_ROUTER_DATA__` nennt — nicht „Fehler 502". + +Sie enthält bewusst ein **anderes** JSON-Skript (`__NEXT_DATA__`) und eine +Zeile, in der `activityId` vorkommt. Wer „das erste Skript" oder „irgendwo die +Nummer" nimmt, wird hier grün und im Betrieb falsch. + +## Keine Testdaten im Live-System + +Diese Dateien werden ausschließlich gelesen. Sie wandern nie in eine Datenbank, und +es entsteht aus ihnen nie eine Kachel auf `workspace.dogfather-universe.com`. diff --git a/server/pruefdaten/kampagne-beendet.html.br b/server/pruefdaten/kampagne-beendet.html.br new file mode 100644 index 00000000..2d6aff0a Binary files /dev/null and b/server/pruefdaten/kampagne-beendet.html.br differ diff --git a/server/pruefdaten/kampagne-gipfelstuermer.html.br b/server/pruefdaten/kampagne-gipfelstuermer.html.br new file mode 100644 index 00000000..d5d6c4a8 Binary files /dev/null and b/server/pruefdaten/kampagne-gipfelstuermer.html.br differ diff --git a/server/pruefdaten/kampagne-kaputt.html b/server/pruefdaten/kampagne-kaputt.html new file mode 100644 index 00000000..aed9f616 --- /dev/null +++ b/server/pruefdaten/kampagne-kaputt.html @@ -0,0 +1,9 @@ + + +