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 = /]*\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) { + throw new KampagneUnlesbar( + "__MODERN_ROUTER_DATA__ fehlt in der Seite", "aufbau"); + } + const anfang = treffer.index + treffer[0].length; + const ende = text.indexOf("", 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 `
  1. Liga + *
`. 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. */ +export function textAusHtml(html) { + let text = String(html || ""); + if (!text) return ""; + + /* Nummerierte Aufzaehlungen: jede `
  • ` 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(/]*)>([\s\S]*?)<\/ol>/gi, (ganz, merkmale, inhalt) => { + const m = /\bstart\s*=\s*["']?(\d+)/i.exec(merkmale); + let n = m ? parseInt(m[1], 10) : 1; + return inhalt.replace(/]*>/gi, () => `\n${n++}. `); + }); + text = text.replace(/]*>/gi, "\n· "); + + text = text + .replace(//gi, "\n") + /* `
  • ` setzt KEINEN Umbruch -- den hat `
  • ` schon gesetzt. + Beides zusammen ergibt eine Leerzeile zwischen zwei Punkten, und + eine Aufzaehlung mit Luecken sieht nach einem Fehler im Einlesen + aus. Bei `

    ` 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(//gi, "") + .replace(//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 + `Liga `. */ + .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("Der Einleitungstext konnte nicht gelesen werden."); + + /* 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()); + const aufgabenTitel = ersteEinfach ? auf(ersteEinfach.headerTitle) : null; + if (!aufgaben.length) hinweise.push("Die Aufgaben konnten nicht gelesen werden."); + + /* 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, + }); + } + } + if (!geschenke.length) hinweise.push("Die Kampagnengeschenke konnten nicht gelesen werden."); + + /* 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("Die Preise konnten nicht gelesen werden."); + + /* 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."); + + /* 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; + + /* Banner: `props.imageUrl[0].url`, NICHT `props.url` und nicht + `pageInfo.topImageInfo` -- die zeigen auf das Vorlagenbild. */ + const bildBaustein = erster("live_top_image"); + const bannerUrl = (bildBaustein?.props?.imageUrl || []) + .map((b) => b?.url || b?.urlSG) + .find((u) => typeof u === "string" && /^https:\/\//i.test(u)) || null; + if (!bannerUrl) hinweise.push("Das Banner konnte nicht gelesen werden."); + + /* 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."); + + 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, weitereRegeln, + geschenke, geschenkTitel, geschenkHinweis, + preise, preisTitel, + klapptexte, freitext, + laender, bannerUrl, + }, + 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()], + 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 = []; + 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"}` : "")), + ].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"), + ...k.preise.map((p) => `${p.liga}: ` + + p.plaetze.map((s) => `${s.platz} = ${s.preise.join(", ")}`).join(" · ")), + ].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) { + bloecke.push(`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. */ + for (const h of hinweise || []) bloecke.push(`* ${h} Bitte nachtragen.`); + + return bloecke.join("\n\n").trim(); +} diff --git a/server/mess-fokus.mjs b/server/mess-fokus.mjs index c31663c4..a61aa01a 100644 --- a/server/mess-fokus.mjs +++ b/server/mess-fokus.mjs @@ -30,8 +30,8 @@ const SUCHE = process.argv[3] || ".kachel"; Sie kannte den dritten Ausgang nicht (EACCES -- das System gibt den Port dauerhaft nicht her) und sagte nicht, wie man nachsieht, wer ihn haelt. Eine Abschrift eines Waechters ist dieselbe Falle wie -import { eigenerPort } from "./helfer-port.mjs"; eine Abschrift einer Spaltenliste. */ +import { eigenerPort } from "./helfer-port.mjs"; const PORT = await eigenerPort(import.meta); const ordner = mkdtempSync(join(tmpdir(), "ws-kontrast-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); diff --git a/server/pruef-kampagne-lesen.mjs b/server/pruef-kampagne-lesen.mjs new file mode 100644 index 00000000..45eab624 --- /dev/null +++ b/server/pruef-kampagne-lesen.mjs @@ -0,0 +1,734 @@ +/* ===================================================================== + EINE KAMPAGNENSEITE LESEN — gegen GESPEICHERTE Seiten (07.10.2026) + + Etappe 1 des Bauplans „Kampagnen-Kachel aus TikTok-Link“. Geprüft + wird `server/kampagne-lesen.mjs`, und zwar an vier echten Seiten, + die am 07.10.2026 von TikTok geholt wurden und unter + `server/pruefdaten/` liegen. Woher sie kommen und was an einer von + ihnen verändert wurde, steht dort in `LIESMICH.md`. + + KEIN SERVER, KEIN PORT, KEIN NETZ, KEINE DATENBANK. Diese Prüfung + startet nichts und fragt niemanden. Sie liest vier Dateien und + rechnet. Deshalb läuft sie in Sekunden statt in Minuten, und + deshalb kann sie nicht rot werden, weil TikTok einen schlechten + Nachmittag hat. + + --------------------------------------------------------------------- + DER DRITTE AUSGANG + + Fehlt eine der Prüfdateien oder lässt sie sich nicht entpacken, dann + ist diese Prüfung nicht DURCHFÜHRBAR — und das muss SCHEITERN, nicht + schweigen (CLAUDE.md, 03.09.2026). Sie endet dann mit Rückgabewert 2 + und den Worten KONNTE NICHT NACHSEHEN, mit Grund und Anleitung. + + Ein übersprungener Abschnitt wäre das Schlimmste von allem: Die Zahl + der Prüfungen sänke still, der Lauf bliebe grün, und ab da wäre das + Auslesen dauerhaft ungeprüft. + + --------------------------------------------------------------------- + DIE PRÜFDATEIEN SIND MIT IHRER PRÜFSUMME FESTGENAGELT + + Eine Prüfung, deren Eingabe sich ändern kann, beweist nichts. Hier + steht deshalb die SHA-256-Summe des ENTPACKTEN Inhalts. Wird eine + Datei bewusst erneuert, wird diese Prüfung rot und nennt die neue + Summe — dann ist die Summe hier nachzutragen, mit einem Satz dazu, + WARUM die Datei neu ist. + + Aufrufen mit: node server/pruef-kampagne-lesen.mjs + ===================================================================== */ +import { readFileSync, existsSync, mkdtempSync, rmSync, readdirSync } from "node:fs"; +import { brotliDecompressSync } from "node:zlib"; +import { createHash } from "node:crypto"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { tmpdir } from "node:os"; +import { notbremse } from "./helfer-notbremse.mjs"; + +/* Auch eine Prüfung ohne Netz braucht die Notbremse: `index.js` hängt + globale Auffangnetze ein, und ein Absturz würde sonst nur notiert. + Eine Prüfung, die HÄNGT, ist schlimmer als eine, die scheitert. */ +notbremse(60_000, "pruef-kampagne-lesen"); + +const HIER = dirname(fileURLToPath(import.meta.url)); +const DATEN = join(HIER, "pruefdaten"); + +let fehler = 0; +let geprueft = 0; +const melde = (t) => console.log(t); +const ok = (b, t) => { + geprueft++; + console.log((b ? " ok " : " FEHL ") + t); + if (!b) fehler++; +}; + +/** Der dritte Ausgang: nicht „in Ordnung“, nicht „nicht in Ordnung“, + * sondern „konnte nicht nachsehen“ — mit Grund und Anleitung. */ +function nichtNachsehbar(grund, rat) { + console.log(""); + console.log("KONNTE NICHT NACHSEHEN"); + console.log(" Grund: " + grund); + console.log(" Was tun: " + rat); + console.log(""); + console.log(" Das ist KEIN Befund — es heißt nur, dass gerade niemand"); + console.log(" nachsehen konnte. Es ist aber auch kein Erfolg."); + process.exit(2); +} + +/* ===================================================================== + 0. DIE VORAUSSETZUNG + ===================================================================== */ +melde("=== 0. Sind die gespeicherten Seiten da und unverändert? ==="); + +/* Die Summen stammen vom 07.10.2026, dem Tag, an dem die Seiten geholt + wurden. Erste 16 Stellen der SHA-256 des entpackten Inhalts. */ +const SEITEN = { + gipfel: { + datei: "kampagne-gipfelstuermer.html.br", + sha: "f8486096ff4e1b96", gross: 1_109_700, + was: "Gipfelstürmer, läuft, unverändert", + }, + beendet: { + datei: "kampagne-beendet.html.br", + sha: "e068e2c9bcfc37de", gross: 961_831, + was: "Sonne im Gepäck, beendet, unverändert", + }, + ohnePreise: { + datei: "kampagne-ohne-preise.html.br", + sha: "28e6a24ff3f6b726", gross: 986_560, + was: "Stern Stunde, Preisbaustein entfernt", + }, + kaputt: { + datei: "kampagne-kaputt.html", packt: false, + sha: "cf40f347d49d3e03", gross: 369, + was: "Seite ohne Datenpaket, selbst geschrieben", + }, +}; + +const seite = {}; +for (const [name, s] of Object.entries(SEITEN)) { + const pfad = join(DATEN, s.datei); + if (!existsSync(pfad)) { + nichtNachsehbar( + `die Prüfdatei ${s.datei} fehlt in ${DATEN}`, + "Sie gehört ins Verzeichnis und ist in Gitea versioniert — " + + "`git status server/pruefdaten/` zeigt, ob sie nur lokal fehlt."); + } + let roh; + try { + roh = s.packt === false + ? readFileSync(pfad) + : brotliDecompressSync(readFileSync(pfad)); + } catch (f) { + nichtNachsehbar( + `${s.datei} lässt sich nicht entpacken (${f.message})`, + "Die Datei ist beschädigt. Aus Gitea neu holen: " + + `git checkout -- server/pruefdaten/${s.datei}`); + } + const sha = createHash("sha256").update(roh).digest("hex").slice(0, 16); + ok(roh.length === s.gross && sha === s.sha, + `${s.datei}: ${roh.length} Bytes, sha ${sha} — ${s.was}` + + (sha === s.sha ? "" : ` (ERWARTET ${s.sha}/${s.gross})`)); + seite[name] = roh.toString("utf8"); +} + +/* Und: es liegt nichts Unerklärtes dort. Eine Prüfdatei, die niemand + benutzt, ist entweder ein Rest oder eine vergessene Absicht. */ +{ + const erwartet = new Set([...Object.values(SEITEN).map((s) => s.datei), "LIESMICH.md"]); + const gefunden = readdirSync(DATEN); + const fremd = gefunden.filter((n) => !erwartet.has(n)); + ok(gefunden.length === erwartet.size && fremd.length === 0, + `${gefunden.length} Dateien im Ordner, alle erklärt` + + (fremd.length ? ` — unerklärt: ${fremd.join(", ")}` : "")); +} + +const { + leseKampagne, regelnText, aufgabenText, tagInZone, textAusHtml, + KampagneUnlesbar, datenAusSeite, +} = await import("./kampagne-lesen.mjs"); + +/* ===================================================================== + 1. DER KALENDERTAG — Fallstrick 1 und 2 des Bauplans + ===================================================================== */ +melde(""); +melde("=== 1. Ein Tag in der Zone der Kampagne, nicht in UTC ==="); + +const START_GIPFEL = 1_791_064_800_000; /* so steht es in der Seite */ + +ok(tagInZone(START_GIPFEL, "Europe/Berlin") === "2026-10-04", + `Beginn von Gipfelstürmer: ${tagInZone(START_GIPFEL, "Europe/Berlin")}`); + +/* GEGENPROBE: Der bequeme Weg liefert den VORTAG. Steht hier, damit + niemand die Zeile oben für Zierde hält. */ +const naiv = new Date(START_GIPFEL).toISOString().slice(0, 10); +ok(naiv === "2026-10-03" && naiv !== tagInZone(START_GIPFEL, "Europe/Berlin"), + `Gegenprobe: toISOString() sagt ${naiv} — einen Tag zu früh`); + +ok(tagInZone(START_GIPFEL, "UTC") === "2026-10-03", + "die Zone entscheidet wirklich (UTC ergibt den 03.)"); + +/* WINTERZEIT: Der in der Seite gespeicherte Versatz „+02:00“ gilt nur + bis zum 25.10.2026. Eine Kampagne im November, damit gerechnet, + liegt über Mitternacht um einen TAG daneben. */ +{ + const spaet = Date.parse("2026-11-15T23:30:00+01:00"); /* Berliner Winterzeit */ + const mitZone = tagInZone(spaet, "Europe/Berlin"); + const mitVersatz = new Date(spaet + 2 * 3600_000).toISOString().slice(0, 10); + ok(mitZone === "2026-11-15", + `im November rechnet die Zone richtig: ${mitZone}`); + ok(mitVersatz === "2026-11-16" && mitVersatz !== mitZone, + `Gegenprobe: mit dem gespeicherten +02:00 wäre es der ${mitVersatz}`); +} + +ok(tagInZone(START_GIPFEL, "Gibt/EsNicht") === null, + "eine unbekannte Zone ergibt NICHTS — kein stilles Ausweichen auf UTC"); +ok(tagInZone("keine Zahl", "Europe/Berlin") === null, + "und ein Nicht-Zeitpunkt ebenfalls nichts"); + +/* ===================================================================== + 2. DIE VOLLSTÄNDIGE SEITE + ===================================================================== */ +melde(""); +melde("=== 2. Gipfelstürmer — alles gelesen ==="); + +const g = leseKampagne(seite.gipfel); + +ok(g.pflicht.titel === "Gipfelstürmer", `Titel: ${g.pflicht.titel}`); +ok(g.pflicht.id === "7691493175936699143", `Nummer: ${g.pflicht.id}`); +ok(g.pflicht.zone === "Europe/Berlin", `Zone: ${g.pflicht.zone}`); +ok(g.pflicht.start === "2026-10-04", `Beginn: ${g.pflicht.start}`); +ok(g.pflicht.ende === "2026-10-07", `Ende: ${g.pflicht.ende}`); + +/* Die Zahl gehört in die BEDINGUNG, nicht nur in den Meldetext. */ +ok(g.hinweise.length === 0, + `keine Lücke zu melden (${g.hinweise.length} Hinweise)` + + (g.hinweise.length ? ": " + g.hinweise.join(" | ") : "")); + +ok(g.auszug.gelesen_aus === "activityInfo", + `Gerüst kam aus ${g.auszug.gelesen_aus}`); +ok(g.auszug.woerterbuch_eintraege === 135, + `Wörterbuch: ${g.auszug.woerterbuch_eintraege} Einträge`); +ok(g.auszug.platzhalter_ohne_eintrag === 0, + `jeder Platzhalter im Gerüst hatte seinen Text (${g.auszug.platzhalter_ohne_eintrag} ohne)`); +ok(g.auszug.bausteine.length === 13 && g.auszug.bausteine.includes("live_rule_introduction"), + `${g.auszug.bausteine.length} Bausteine am Namen erkannt`); + +/* ---- Die Aufgaben, wörtlich -------------------------------------- */ +const ERWARTETE_AUFGABEN = [ + "1 Diamant = 1 Punkt", + "Dreifache Punkte für Kampagnengeschenke", + "Tägliche \"Gipfelzeit\" (Bonuszeit) von 20-22 Uhr", + "Gehe täglich mindestens 25 min LIVE um 10.000 Extrapunkte zu erhalten", + "Gewinn ein neues Fanclub Mitglied für 100 Extrapunkte", +]; +ok(g.kuer.aufgaben.length === 5 + && ERWARTETE_AUFGABEN.every((t, i) => g.kuer.aufgaben[i] === t), + `5 Aufgaben, wörtlich wie auf der Seite`); +ok(g.kuer.aufgabenTitel === "Wie gewinne ich?", + `ihre Überschrift kommt von der Seite: „${g.kuer.aufgabenTitel}“`); + +/* Kein Aufzählungszeichen davor: `punkteAusText` würde es wieder + abschneiden, aber es stünde im Speichertext, und DogFather fände + beim Bearbeiten etwas vor, das er nie getippt hat. */ +const AUFZAEHLZEICHEN = /^[\s]*[-–—•·*▪◦‣>+]/u; +ok(g.kuer.aufgaben.length > 0 && !g.kuer.aufgaben.some((z) => AUFZAEHLZEICHEN.test(z)), + "keine Zeile beginnt mit einem Aufzählungszeichen"); + +/* ---- Die Geschenke ----------------------------------------------- */ +ok(g.kuer.geschenke.length === 5, `5 Kampagnengeschenke`); +ok(g.kuer.geschenke[0].name === "Herbstliches Herz" + && g.kuer.geschenke[0].preis === 1 + && g.kuer.geschenke[0].id === "12877", + `das erste: ${g.kuer.geschenke[0].name}, ${g.kuer.geschenke[0].preis} Coin, #${g.kuer.geschenke[0].id}`); +ok(g.kuer.geschenke[4].name === "Ahorn-Welt" && g.kuer.geschenke[4].preis === 12000, + `das teuerste: ${g.kuer.geschenke[4].name}, ${g.kuer.geschenke[4].preis} Coins`); + +/* ---- Die Preise -------------------------------------------------- */ +ok(g.kuer.preise.length === 6, `6 Preisgruppen`); +ok(g.kuer.preise[0].liga === "Superstars" + && g.kuer.preise[0].plaetze[0].platz === "Top 1" + && g.kuer.preise[0].plaetze[0].preise[0] === "60.000 Diamanten", + `${g.kuer.preise[0].liga} / ${g.kuer.preise[0].plaetze[0].platz}` + + ` = ${g.kuer.preise[0].plaetze[0].preise[0]}`); +{ + const plaetze = g.kuer.preise.reduce((s, p) => s + p.plaetze.length, 0); + /* 41, NACHGERECHNET: Superstars 3 + 1. Liga 6 + 2. bis 5. Liga je 8. + Hier stand zuerst 46 -- geraten, nicht gezaehlt. Die Prueferin war + richtig, die Erwartung falsch. */ + ok(plaetze === 41, `${plaetze} Plätze über alle Ligen`); +} + +/* ---- Länder und Banner ------------------------------------------- */ +ok(g.kuer.laender.join(",") === "DE,CH,AT,LI,LU", `Länder: ${g.kuer.laender.join(", ")}`); + +/* DAS BANNER IST NICHT DAS VORLAGENBILD. `pageInfo.topImageInfo.url` + zeigt bei Gipfelstürmer auf einen goldenen Löwen aus einer + Nahost-Kampagne vom August 2024 — sichtbar ist er nirgends. */ +{ + const wert = datenAusSeite(seite.gipfel); + const vorlage = wert.globalData.pageInfo.topImageInfo?.url; + ok(typeof g.kuer.bannerUrl === "string" && g.kuer.bannerUrl.startsWith("https://"), + `Banner: ${g.kuer.bannerUrl.slice(0, 72)}…`); + ok(!!vorlage && g.kuer.bannerUrl !== vorlage, + "und es ist NICHT pageInfo.topImageInfo.url (das Vorlagenbild)"); + ok(wert.globalData.pageInfo.pageVisualEnName === "MENA Black Gold PK", + `Gegenprobe: die Vorlage heißt „${wert.globalData.pageInfo.pageVisualEnName}“` + + " — genau das stünde sonst in der Kachel"); +} + +/* ===================================================================== + 3. DER WICHTIGSTE NACHWEIS — das Schema, nicht das Wörterbuch + ===================================================================== */ +melde(""); +melde("=== 3. „1 schenkende Person“ darf NICHT in der Kachel stehen ==="); + +{ + const wert = datenAusSeite(seite.gipfel); + const woerterbuch = wert.sourceTextMap; + const imWoerterbuch = Object.values(woerterbuch) + .filter((t) => typeof t === "string" && t.includes("schenkende Person")); + + /* GEGENPROBE ZUERST: Wer das Wörterbuch durchliest, FINDET den Satz. + Ohne diese Zeile wäre die nächste nur die Feststellung, dass ein + Wort irgendwo nicht vorkommt — und das wäre auch wahr, wenn der + ganze Leser nichts täte. */ + ok(imWoerterbuch.length === 1, + `Gegenprobe: im Wörterbuch steht er sehr wohl — „${imWoerterbuch[0]?.trim()}“`); + + /* Und er steht an KEINER Stelle im Seitengerüst. */ + const geruest = JSON.parse(wert.globalData.activityInfo.ac_schema_with_interaction_rules); + const kopie = { ...geruest }; + delete kopie.sourceTextMap; + const imGeruest = new Set(JSON.stringify(kopie).match(/\$\$__activity_[^"]*?__\$\$/g) || []); + const nurImWoerterbuch = Object.keys(woerterbuch).filter((k) => !imGeruest.has(k)); + ok(imGeruest.size === 134 && Object.keys(woerterbuch).length === 135 + && nurImWoerterbuch.length === 1 + && woerterbuch[nurImWoerterbuch[0]].includes("schenkende Person"), + `${imGeruest.size} Platzhalter im Gerüst, ${Object.keys(woerterbuch).length} im Wörterbuch` + + ` — der eine Überzählige ist genau dieser Satz`); + + /* Und nun das Ergebnis: nirgends in der Kachel. */ + const allesLesbare = JSON.stringify(g.kuer) + "\n" + regelnText(g.kuer, g.hinweise) + + "\n" + aufgabenText(g.kuer) + "\n" + JSON.stringify(g.pflicht); + ok(allesLesbare.length > 1000 && !allesLesbare.includes("schenkende"), + `in ${allesLesbare.length} Zeichen Kachelinhalt kommt er nicht vor`); +} + +/* ===================================================================== + 4. DIE TEXTE FÜR DIE KACHEL + ===================================================================== */ +melde(""); +melde("=== 4. Was in event_aufgaben und event_regeln landet ==="); + +{ + const auf = aufgabenText(g.kuer); + const zeilen = auf.split("\n").filter((z) => z.trim()); + ok(zeilen.length === 5 && auf === ERWARTETE_AUFGABEN.join("\n"), + `event_aufgaben: ${zeilen.length} Zeilen, eine je Haken`); + + /* MIT DER ECHTEN ZERLEGUNG DES HAUSES, nicht mit einer Abschrift. + Dafür muss `workspace-bereiche.js` eingeführt werden, und das + braucht einen Datenbankpfad — deshalb ein Wegwerfordner. Es wird + nichts geschrieben; dass dort hinterher keine Datenbank liegt, + wird gleich nachgewiesen. */ + const wegwerf = mkdtempSync(join(tmpdir(), "ws-kampagne-lesen-")); + process.env.WORKSPACE_DB = join(wegwerf, "workspace.db"); + process.env.SITE_ACCESS_SECRET = "lokaler-test"; + const { punkteAusText } = await import("./workspace-bereiche.js"); + + const haken = punkteAusText(auf); + ok(haken.length === 5 && haken.every((h, i) => h.text === ERWARTETE_AUFGABEN[i]), + `punkteAusText macht daraus ${haken.length} Haken, wörtlich gleich`); + + /* GEGENPROBE: eine Zeile weniger ergibt einen Haken weniger. Sonst + bewiese die Zeile darüber nur, dass irgendeine Zahl 5 ist. */ + const weniger = punkteAusText(ERWARTETE_AUFGABEN.slice(0, 4).join("\n")); + ok(weniger.length === 4, `Gegenprobe: vier Zeilen ergeben ${weniger.length} Haken`); + + /* GEGENPROBE: ein vorangestelltes Aufzählungszeichen würde + abgeschnitten — deshalb setzt der Leser keines. */ + const mitPunkt = punkteAusText("· " + ERWARTETE_AUFGABEN[0]); + ok(mitPunkt.length === 1 && mitPunkt[0].text === ERWARTETE_AUFGABEN[0], + "Gegenprobe: ein Aufzaehlungszeichen davor wuerde beim Anzeigen" + + " verschwinden -- deshalb setzt der Leser keines"); + + const db = join(wegwerf, "workspace.db"); + ok(!existsSync(db), + "und es ist dabei KEINE Datenbank entstanden — Etappe 1 schreibt nichts"); + try { rmSync(wegwerf, { recursive: true, force: true }); } catch { /* Rest */ } +} + +{ + const r = regelnText(g.kuer, g.hinweise); + ok(r.length > 3000, `event_regeln: ${r.length} Zeichen`); + + /* Die Reihenfolge ist fest — Geschenke, Preise, Klapptexte, + Freitext, Teilnahme. Nicht die Reihenfolge, in der etwas gelesen + wurde, sondern die, in der es jemand braucht. */ + /* „TEILNAHME" steht auch am Anfang von „TEILNAHMEBEDINGUNGEN" -- + `indexOf` faende dann zweimal dieselbe Stelle und die Reihenfolge + waere nie aufsteigend. Der letzte Abschnitt wird deshalb an seiner + eigenen Zeile erkannt, nicht an seinem Anfang. */ + const reihe = ["KAMPAGNENGESCHENKE", "BELOHNUNGEN", "WELCHE LIGA BIN ICH?", + "TEILNAHMEBEDINGUNGEN", "TEILNAHME\nLänder:"]; + const stellen = reihe.map((w) => r.indexOf(w)); + ok(stellen.every((s) => s >= 0) + && stellen.every((s, i) => i === 0 || s > stellen[i - 1]), + `alle fünf Abschnitte da und in dieser Reihenfolge: ${reihe.join(" → ")}`); + + ok(r.includes("· Herbstliches Herz — 1 Coin\n"), + "Einzahl bei genau einem Coin („1 Coin“, nicht „1 Coins“)"); + ok(r.includes("Ahorn-Welt — 12000 Coins"), "und Mehrzahl darüber"); + ok(r.includes("Superstars: Top 1 = 60.000 Diamanten · Top 2 = 55.000 Diamanten"), + "die Preise stehen in einer Zeile je Liga"); + ok(r.includes("Länder: DE, CH, AT, LI, LU"), "die Länder stehen drin"); + + /* DIE LIGANUMMER STECKT IM MERKMAL, NICHT IM TEXT. Auf der Seite + steht `
    1. Liga
    `. Wer nur + die Marken wegwirft, bekommt viermal das Wort „Liga“. */ + ok(r.includes("1. Liga") && r.includes("2. Liga") && r.includes("5. Liga"), + "die Ligen sind durchnummeriert (1. bis 5.)"); + { + const rohHtml = "
    1. Liga 
    "; + ok(textAusHtml(rohHtml) === "3. Liga", + `Gegenprobe am Baustein selbst: ${JSON.stringify(textAusHtml(rohHtml))}`); + ok(rohHtml.replace(/<[^>]+>/g, "").trim() === "Liga ", + "Gegenprobe: bloßes Markenentfernen verliert die Nummer"); + } + + ok(!/<[a-z/][^>]*>/i.test(r), "keine einzige HTML-Marke ist übrig"); + ok(!r.includes(" ") && !r.includes("&"), + "und keine unaufgelöste Zeichenfolge"); + ok(!r.includes("$$__activity_"), "und kein unaufgelöster Platzhalter"); +} + +/* ===================================================================== + 5. TEXT AUS HTML, einzeln + ===================================================================== */ +melde(""); +melde("=== 5. Der HTML-zu-Text-Wandler für sich ==="); + +ok(textAusHtml("

    eins

    zwei

    ") === "eins\n\nzwei", "Absätze trennen"); +ok(textAusHtml("a
    b") === "a\nb", "ein Umbruch bricht um"); +ok(textAusHtml("
    • x
    • y
    ") === "· x\n· y", "Punkte bekommen ihr Zeichen"); +ok(textAusHtml("
    1. a
    2. b
    ") === "7. a\n8. b", + "nummerierte Listen zählen von `start` weiter"); +ok(textAusHtml("Tom & Jerry <3 €") === "Tom & Jerry <3 €", + "Zeichenfolgen werden zurückübersetzt"); +ok(textAusHtml("

    gut

    ") === "gut", "Skripte fallen weg"); +ok(textAusHtml("") === "" && textAusHtml(null) === "" && textAusHtml(undefined) === "", + "nichts hinein ergibt nichts heraus"); +ok(textAusHtml("

    a

    \n\n\n\n

    b

    ") === "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 @@ + + +TikTok LIVE + + +
    + + + diff --git a/server/pruefdaten/kampagne-ohne-preise.html.br b/server/pruefdaten/kampagne-ohne-preise.html.br new file mode 100644 index 00000000..8f715d11 Binary files /dev/null and b/server/pruefdaten/kampagne-ohne-preise.html.br differ diff --git a/server/workspace-video.js b/server/workspace-video.js index e5f668d0..4abdffe7 100644 --- a/server/workspace-video.js +++ b/server/workspace-video.js @@ -52,17 +52,17 @@ import { import { darfSchreiben, sofortFreigeben } from "./workspace-treff.js"; import { sichtbar } from "./workspace-bereiche.js"; import { bildArtVon } from "./workspace-dateien.js"; +import { FRIST_MS, istTikTok, holeMitFrist } from "./helfer-tiktok.mjs"; export const videoRouter = express.Router(); const ORDNER = join(DATEN_ORDNER, "dateien"); try { mkdirSync(ORDNER, { recursive: true }); } catch { /* gibt es schon */ } -/* 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. */ -const FRIST_MS = 8000; +/* Die Grenze fuer ein heruntergeladenes Cover. Die FRIST fuer den + Weg nach draussen steht seit dem 07.10.2026 in helfer-tiktok.mjs -- + sie gilt jetzt auch fuer die Kampagnenseite, und zwei Fassungen + derselben Zahl laufen irgendwann auseinander. */ const COVER_MAX = 4 * 1024 * 1024; function angemeldet(req, res, next) { @@ -73,59 +73,6 @@ function angemeldet(req, res, next) { } videoRouter.use("/workspace/api/bereich", angemeldet); -/** 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 && (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; } -} - -/** Mit Frist holen. Ohne sie haengt die Anfrage am fremden Dienst. */ -/** 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. 8 Sekunden gelten damit fuer alles zusammen. */ -async function holeMitFrist(adresse, kopf = {}) { - const abbruch = new AbortController(); - const uhr = setTimeout(() => abbruch.abort(), FRIST_MS); - 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); } -} - /* WOHIN GEFRAGT WIRD. Im Betrieb TikTok; in einer Pruefung ein nachgebauter Dienst auf 127.0.0.1.