Files
dogfather-universe/server/kampagne-lesen.mjs
T
DogFatherGitandClaude Opus 5 10e24b0c93 Kampagnen-Kachel aus einem TikTok-Link -- Etappe 2 bis 4
Der Auftrag ist damit fertig: Im Agentur-Bereich gibt es ein Feld, in
das die ganze Einladungsnachricht eingefuegt wird -- und daraus
entsteht eine Agentur-Event-Kachel mit Titel, Zeitraum, Banner,
Aufgaben zum Abhaken, Regeln, Ligen und Preisen. Gemessen 38 ms vom
Einfuegen bis zur Kachel.

ETAPPE 2 -- DER WEG NACH DRAUSSEN (helfer-tiktok.mjs)
  linkAusText holt die Adresse aus dem Fliesstext, damit niemand sie
  heraussuchen muss. folgeUmleitung geht Sprung fuer Sprung
  (redirect "manual", hoechstens fuenf) und schickt JEDE
  Zwischenadresse durch istTikTok -- bisher folgte holeMitFrist den
  Umleitungen selbst, und istTikTok hatte nur die EINGABE gesehen.
  Das ist die einzige Stelle im Haus, an der unser Server eine
  fremdbestimmte Adresse abruft.

  holeSeite zieht die Grenze von 3 MB BEIM LESEN, nicht hinterher: Wer
  arrayBuffer() abwartet und dann die Laenge ansieht, hat die 50 MB
  schon im Speicher. Frist 12 s statt 8 -- gemessen 750 ms allein beim
  Ursprungsserver, dazu 1,1 MB Uebertragung.

  bildAdresseErlaubt kam beim Bauen dazu, nicht aus dem Bauplan: Die
  Banneradresse steht in der Seite, die TikTok ausliefert, ist also
  FREMDBESTIMMT. Ohne Schranke wuerde unser Server abrufen, was dort
  steht -- auch http://127.0.0.1:4100/ oder 169.254.169.254. Elf
  Faelle durchgemessen.

ETAPPE 3 -- DIE ROUTE (workspace-kampagne.js, 5 Spalten, 1 Index)
  Rechte aus istLeitung, nicht neu erfunden. Bremse 20 je Stunde, VOR
  dem ersten Abruf nach draussen -- eine Bremse hinter dem Abruf
  bremst den fremden Server nicht. Dublettenpruefung zweimal: im Code
  (faengt den Normalfall) und als eindeutiger Index (faengt den
  Wettlauf zweier gleichzeitiger Aufrufe).

  'kampagne_schon_da' zaehlt bei der Bremse MIT (gefunden von der
  Pruefung): Zuerst zaehlten nur Erfolg und Fehlschlag -- wer denselben
  Link wieder und wieder einfuegt, bekommt jedes Mal "schon da" und
  loeste jedes Mal einen Abruf aus, ohne gezaehlt zu werden. Gemessen:
  21 Versuche, 0 gebremst.

  Der Regeltext wird SICHTBAR gekuerzt, an einer Abschnittsgrenze.
  Gipfelstuermer ergibt 7474 Zeichen, das Feld fasst 6000 (TEXT_MAX),
  und die PUT-Route lehnt mehr ab: Ungekuerzt entstuende eine Kachel,
  die DogFather OEFFNEN, aber nicht SPEICHERN kann -- er aendert ein
  Wort und bekommt eine Fehlermeldung ueber etwas, das er nie getippt
  hat. Verloren geht nichts: kampagne_daten traegt das Gelesene
  gegliedert.

ETAPPE 4 -- DIE OBERFLAECHE
  Ein Textbereich (die Einladung ist mehrzeilig), der Knopf gibt sich
  nach 15 s von selbst frei, die Meldung steht neben dem Feld und
  nennt den Grund im Klartext. Ob jemand darf, sagt der Server ueber
  darf_kampagne_einlesen in /api/ich -- keine Rollenliste im Browser.

  Gemessen auf 412 und 1280 px (bild-kampagne.mjs, 20 Messungen):
  Knopf 44 px, Kontrast 7,68:1, nichts liegt auf dem Knopf, kein
  Ueberlauf, keine Bewegung bei prefers-reduced-motion. Die
  Antwortzeile steht auf .9rem statt der hausweiten .76rem -- dort
  erscheint, was der Server geantwortet hat, und eine Fehlermeldung in
  12 px liest niemand zweimal. Die hausweite Klasse bleibt unberuehrt.

VIERZEHN SABOTAGEN, VIERZEHN TREFFER
  Sieben an kampagne-lesen.mjs (Etappe 1) und sieben an Route und Weg:
  Umleitung ungeprueft folgen, Tag nach Serverzeit, Dublettenpruefung
  weglassen, dem fremden Server den Bildtyp glauben, Bremse
  abschalten, Bremse wieder blind fuer Dubletten, jeden einlesen
  lassen. Jede wurde bemerkt, jede im erwarteten Abschnitt.

  EINE BLIEB ZUERST BLIND, und das war lehrreich: Nimmt man die
  Dublettenpruefung aus dem Code, faengt der INDEX es auf -- die
  Antwort ist dieselbe, die Pruefung blieb gruen. Kein Schaden, aber
  ein blinder Fleck. Jetzt unterscheidet die Pruefung am
  Protokolltext, WELCHE der beiden Sicherungen gegriffen hat.

VIER VORBESTEHENDE BEFUNDE MIT ERLEDIGT
  willkommen.html hatte kein theme-color, kein Manifest, kein
  apple-touch-icon. Drei Zeilen sind billiger und haltbarer als eine
  Ausnahme in der Pruefung.

  workspace-anleitung.js bildete den Tag "ab wann geht mehr" aus UTC.
  Wer zwischen 00:00 und 02:00 beitritt, bekam einen Termin, der einen
  Tag zu frueh war. workspace.js hat dafuer jetzt tagLokalVon(zeitpunkt,
  versatz) -- und tagLokal ist nur noch ein Aufruf davon. Es gibt also
  WENIGER Rechenwege als vorher, nicht mehr.

  pruef-anleitung.mjs legte Testdaten auf den UTC-Tag und waere
  zwischen 00:00 und 02:00 rot gewesen, ohne dass etwas kaputt ist.

  mess-anleitung-crew.mjs rechnete seinen zweiten Port als PORT + 1.
  Das sieht aus wie eine Ableitung und ist keine: portNummer
  ueberspringt gesperrte Nummern, und dann gehoert PORT + 1 der
  naechsten DATEI.

  pruef-struktur ist damit zum ersten Mal vollstaendig gruen.

GEPRUEFT (alle gruen, keine Zahl gesunken)
  pruef-kampagne 96 (neu) · pruef-kampagne-lesen 108 · pruef-video 74 ·
  pruef-eventkarte 94 · pruef-agentur 62 · pruef-anleitung 196 ·
  pruef-portnummern 41 (war 40 mit einem Fehler) · pruef-struktur,
  pruef-zeichen 8, pruef-ports 10, pruef-buehnen-fest 13,
  pruef-css-klassen 39, pruef-haus-trennung 107, pruef-lesbarkeit,
  pruef-tippziele, pruef-deutsche-texte 12, pruef-formulare,
  pruef-fingermass 5, pruef-workspace-seiten, pruef-tempo-workspace

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-07 17:14:26 +02:00

704 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =====================================================================
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 = /<script\b[^>]*\bid=["']__MODERN_ROUTER_DATA__["'][^>]*>/i;
/** Holt das Datenpaket aus dem HTML. Wirft, wenn es nicht da ist. */
export function datenAusSeite(html) {
const text = String(html || "");
const treffer = DATENBLOCK.exec(text);
if (!treffer) {
throw new KampagneUnlesbar(
"__MODERN_ROUTER_DATA__ fehlt in der Seite", "aufbau");
}
const anfang = treffer.index + treffer[0].length;
const ende = text.indexOf("</script>", anfang);
if (ende < 0) {
throw new KampagneUnlesbar(
"__MODERN_ROUTER_DATA__ ist nicht geschlossen", "aufbau");
}
let daten;
try {
daten = JSON.parse(text.slice(anfang, ende));
} catch (f) {
throw new KampagneUnlesbar(
`__MODERN_ROUTER_DATA__ ist kein lesbares JSON (${f.message})`, "aufbau");
}
const wert = daten?.loaderData?.home_page?.COSMIC_D_K?.value;
if (!wert || typeof wert !== "object") {
throw new KampagneUnlesbar(
"loaderData.home_page.COSMIC_D_K.value fehlt im Datenpaket", "aufbau");
}
return wert;
}
/** Das Seitengeruest -- aus der bevorzugten Quelle, sonst aus der
* zweiten. Gibt auch zurueck, WOHER es kam; das steht spaeter im
* Protokoll und erspart das Raten, wenn eine Seite anders liest. */
export function geruestVon(wert) {
const ausAktion = wert?.globalData?.activityInfo?.ac_schema_with_interaction_rules;
if (typeof ausAktion === "string" && ausAktion.trim()) {
try { return { geruest: JSON.parse(ausAktion), woher: "activityInfo" }; }
catch { /* dann die zweite Quelle versuchen, nicht gleich aufgeben */ }
}
const zweite = wert?.schema;
if (typeof zweite === "string" && zweite.trim()) {
try { return { geruest: JSON.parse(zweite), woher: "value.schema" }; }
catch { /* beides unlesbar -- unten */ }
}
if (zweite && typeof zweite === "object") {
return { geruest: zweite, woher: "value.schema" };
}
throw new KampagneUnlesbar(
"das Seitengeruest fehlt -- weder activityInfo."
+ "ac_schema_with_interaction_rules noch value.schema sind lesbar",
"aufbau");
}
/* ===================================================================
BAUSTEINE FINDEN — am Namen, nie an der Position
=================================================================== */
const BAUSTEIN_VORSATZ = "ttlive-component-";
/** Der Name eines Bausteins ohne Herkunft und ohne die Endung, die
* eine beendete Kampagne anhaengt. */
function bausteinName(typ) {
if (typeof typ !== "string") return null;
const i = typ.indexOf(BAUSTEIN_VORSATZ);
if (i < 0) return null;
return typ.slice(i + BAUSTEIN_VORSATZ.length).replace(/_rep_remove$/, "");
}
/** Alle Bausteine der Seite, nach ihrem Namen. Mehrere gleichen
* Namens bleiben alle erhalten -- „den ersten nehmen" waere wieder
* eine Entscheidung ueber die Position. */
export function bausteine(geruest) {
const nach = new Map();
(function lauf(knoten, tiefe) {
if (!knoten || typeof knoten !== "object" || tiefe > 20) return;
const name = bausteinName(knoten.type);
if (name) {
if (!nach.has(name)) nach.set(name, []);
nach.get(name).push(knoten);
}
for (const wert of Object.values(knoten)) {
if (Array.isArray(wert)) { for (const x of wert) lauf(x, tiefe + 1); }
else if (wert && typeof wert === "object") lauf(wert, tiefe + 1);
}
})(geruest, 0);
return nach;
}
/* ===================================================================
TEXT AUS HTML — flach, aber nicht nachlaessig
=================================================================== */
const ZEICHEN = new Map([
["&nbsp;", " "], ["&amp;", "&"], ["&lt;", "<"], ["&gt;", ">"],
["&quot;", "\""], ["&#39;", "'"], ["&apos;", "'"], ["&mdash;", "—"],
["&ndash;", "–"], ["&hellip;", "…"], ["&bull;", "·"], ["&euro;", "€"],
]);
/** Benannte und numerische Zeichen zurueckuebersetzen. */
function zeichenZurueck(text) {
return String(text)
.replace(/&[a-z]+;|&#\d+;|&#x[0-9a-f]+;/gi, (m) => {
const klein = m.toLowerCase();
if (ZEICHEN.has(klein)) return ZEICHEN.get(klein);
if (klein.startsWith("&#x")) {
const n = parseInt(klein.slice(3, -1), 16);
return Number.isFinite(n) ? String.fromCodePoint(n) : m;
}
if (klein.startsWith("&#")) {
const n = parseInt(klein.slice(2, -1), 10);
return Number.isFinite(n) ? String.fromCodePoint(n) : m;
}
return m;
});
}
/** HTML zu Text.
*
* WARUM NICHT EINFACH ALLE MARKEN WEGWERFEN: Die Ligentabelle von
* Gipfelstuermer steht als `<ol start="2"><li><strong>Liga</strong>
* </li></ol>`. Wer nur die Marken entfernt, bekommt viermal das Wort
* „Liga" -- die Nummer steckt im `start`-Merkmal, nicht im Text. Aus
* „1. Liga", „2. Liga", „3. Liga" wuerde eine unbrauchbare Liste
* gleicher Zeilen, und niemand koennte sagen, welche Liga gemeint
* ist. Deshalb werden Nummern gesetzt, nicht verworfen. */
export function textAusHtml(html) {
let text = String(html || "");
if (!text) return "";
/* Nummerierte Aufzaehlungen: jede `<li>` bekommt ihre Zahl, von
`start` an gezaehlt. Verschachtelte Listen kommen auf der Seite
nicht vor; kaemen sie, wuerde die innere einfach weiterzaehlen --
das ist falsch, aber harmlos und sichtbar, nicht still. */
text = text.replace(/<ol\b([^>]*)>([\s\S]*?)<\/ol>/gi, (ganz, merkmale, inhalt) => {
const m = /\bstart\s*=\s*["']?(\d+)/i.exec(merkmale);
let n = m ? parseInt(m[1], 10) : 1;
return inhalt.replace(/<li\b[^>]*>/gi, () => `\n${n++}. `);
});
text = text.replace(/<li\b[^>]*>/gi, "\n· ");
text = text
.replace(/<br\s*\/?>/gi, "\n")
/* `</li>` setzt KEINEN Umbruch -- den hat `<li>` schon gesetzt.
Beides zusammen ergibt eine Leerzeile zwischen zwei Punkten, und
eine Aufzaehlung mit Luecken sieht nach einem Fehler im Einlesen
aus. Bei `</p>` ist es umgekehrt richtig: Zwei Absaetze SOLLEN
durch eine Leerzeile getrennt sein. */
.replace(/<\/(p|div|h[1-6]|ul|ol|tr|blockquote)\s*>/gi, "\n")
.replace(/<\/li\s*>/gi, "")
.replace(/<(p|div|h[1-6]|ul|ol|table|tr)\b[^>]*>/gi, "\n")
.replace(/<\/?(td|th)\b[^>]*>/gi, " ")
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<[^>]+>/g, "");
return zeichenZurueck(text)
/* Das geschuetzte Leerzeichen (U+00A0) AUSGESCHRIEBEN. Als Zeichen
getippt waere es im Quelltext von einem normalen Leerzeichen nicht
zu unterscheiden -- und ein unsichtbares Zeichen in einem regulaeren
Ausdruck ist eine Falle, die dieses Haus schon einmal einen Abend
gekostet hat. Auf der Seite steht es als `&nbsp;` in jedem
`<strong>Liga&nbsp;</strong>`. */
.replace(/\u00a0/g, " ")
.split("\n")
.map((z) => z.replace(/[ \t]+/g, " ").trim())
.join("\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
/* ===================================================================
LESEN
=================================================================== */
const PLATZHALTER = /^\$\$__activity_/;
/** Baut die Nachschlagefunktion fuer EINE Seite.
*
* Sie schlaegt NACH, sie durchsucht nicht. Ein Platzhalter ohne
* Eintrag gibt `null` zurueck und wird gezaehlt -- so faellt es auf,
* wenn TikTok die Woerterbuecher trennt, statt dass stillschweigend
* ein Platzhalter in der Kachel landet. */
function nachschlagen(wert, geruest) {
const woerterbuch = {
...(geruest?.sourceTextMap || {}),
...(wert?.sourceTextMap || {}),
};
const sprache = wert?.globalData?.currentLang
|| wert?.globalData?.language || "de-DE";
const ohneEintrag = [];
/** Ein Wert, wie er in der Kachel stehen soll. */
const auf = (roh) => {
if (typeof roh !== "string") return null;
if (!PLATZHALTER.test(roh)) return roh.trim() ? roh : null;
if (Object.prototype.hasOwnProperty.call(woerterbuch, roh)) {
const text = woerterbuch[roh];
return typeof text === "string" && text.trim() ? text : null;
}
ohneEintrag.push(roh.slice(0, 120));
return null;
};
/** Erst den Platzhalter, dann den fertigen Text in der Sprache.
* Manche Bausteine tragen beides -- `multiLingualContent` ist dann
* dieselbe Aussage, nur schon aufgeloest. */
const aufMit = (knoten, feld) => {
const direkt = auf(knoten?.[feld]);
if (direkt) return direkt;
const viele = knoten?.multiLingualContent;
if (viele && typeof viele === "object") {
const text = viele[sprache] ?? Object.values(viele)[0];
if (typeof text === "string" && text.trim()) return text;
}
return null;
};
return { auf, aufMit, sprache, woerterbuch, ohneEintrag };
}
/** Liest eine gespeicherte oder geholte TikTok-Kampagnenseite.
* @param {string} html die ganze Seite als Text
* @returns {{pflicht: object, kuer: object, hinweise: string[], auszug: object}}
*/
export function leseKampagne(html) {
const wert = datenAusSeite(html);
const { geruest, woher } = geruestVon(wert);
const { auf, aufMit, sprache, woerterbuch, ohneEintrag } = nachschlagen(wert, geruest);
const teile = bausteine(geruest);
const erster = (name) => (teile.get(name) || [])[0] || null;
const global = wert.globalData || {};
const aktion = global.activityInfo || {};
const seite = global.pageInfo || {};
const hinweise = [];
/* ---- PFLICHT ---------------------------------------------------- */
const id = String(aktion.activity_id || global.activityId || "").trim();
const zone = String(aktion.tz_location_name || "").trim();
const titel = String(seite.title || "").trim();
const fehlt = [];
if (!id) fehlt.push("die Kampagnennummer (activity_id)");
if (!zone) {
/* KEIN Ausweichen auf UTC und keines auf `time_zone`. Siehe oben:
ein fester Versatz ist nach der Zeitumstellung falsch, und ein
falsches Datum sieht aus wie ein Datum. */
fehlt.push("die Zeitzone der Kampagne (tz_location_name)");
}
if (!titel) fehlt.push("der Titel (pageInfo.title)");
/* Beide Zeitstempel in Millisekunden. Eine Zahl, die zu klein ist,
waere eine Sekundenangabe -- und damit das Jahr 1970. */
const zeitpunkt = (roh, name) => {
const n = Number(roh);
if (!Number.isFinite(n) || n < 1_000_000_000_000) {
fehlt.push(name);
return null;
}
return n;
};
const startMs = zeitpunkt(aktion.start_time, "der Beginn (start_time)");
const endeMs = zeitpunkt(aktion.end_time, "das Ende (end_time)");
const start = startMs !== null && zone ? tagInZone(startMs, zone) : null;
const ende = endeMs !== null && zone ? tagInZone(endeMs, zone) : null;
if (zone && startMs !== null && !start) fehlt.push(`die Zone „${zone}" ist unbekannt`);
if (fehlt.length) {
throw new KampagneUnlesbar(
`gelesen, aber ohne ${fehlt.join(" und ohne ")} -- daraus wird keine Kachel`,
"pflicht");
}
/* ---- NEBENSACHEN ------------------------------------------------ */
/* Einleitung */
const einleitungBaustein = erster("live_campaign_intro");
const einleitung = einleitungBaustein
? aufMit(einleitungBaustein.props || {}, "content") : null;
const einleitungTitel = einleitungBaustein
? auf(einleitungBaustein.props?.title) : null;
if (!einleitung) hinweise.push("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");
/* HIER STEHT ABSICHTLICH KEIN "nur https" (berichtigt 07.10.2026).
Vorher stand hier `/^https:/`. Das war eine ENTSCHEIDUNG an einer
Stelle, die nur LESEN soll -- und sie stand damit zum zweiten Mal
im Haus, denn `bildAdresseErlaubt` in helfer-tiktok.mjs prueft
Protokoll, Port und private Adressen ohnehin, bevor etwas geholt
wird.
Zwei Fassungen derselben Regel laufen auseinander, und hier tat es
sofort weh: Keine Pruefung konnte einen nachgebauten Bilddienst
auf 127.0.0.1 benutzen -- der Leser verwarf die Adresse, bevor die
Schranke sie je sah. Die Pruefung haette dafuer das echte TikTok
anfragen muessen.
Der Leser nimmt deshalb jede absolute Adresse. Ob sie abgerufen
werden DARF, entscheidet die Schranke. */
const bannerUrl = (bildBaustein?.props?.imageUrl || [])
.map((b) => b?.url || b?.urlSG)
.find((u) => typeof u === "string" && /^https?:\/\//i.test(u)) || null;
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()],
/* DAS GELESENE SELBST GEHOERT MIT HINEIN (07.10.2026, beim Bauen
der Route aufgefallen).
Der Regeltext einer echten Kampagne ist 7474 Zeichen lang; das
Feld in der Kachel fasst 6000 (TEXT_MAX in
workspace-bereiche.js, und die PUT-Route lehnt mehr ab -- eine
Kachel mit 7474 Zeichen koennte DogFather oeffnen, aber nicht
speichern). Der Text wird deshalb an einer Abschnittsgrenze
gekuerzt. Damit dabei nichts VERLOREN geht, steht hier das
Gelesene in seiner gegliederten Form.
Das ist genau der Zweck, den der Bauplan fuer diese Spalte
nennt: Wenn in drei Monaten auffaellt, dass die Ligen auch
gebraucht werden, steht die Antwort schon da -- und eine
abgelaufene Kampagne antwortet dann vielleicht nicht mehr. */
einleitung, aufgaben, geschenke, preise, klapptexte,
banner_url: bannerUrl,
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.
UND ZWAR GANZ OBEN (berichtigt 07.10.2026). Vorher standen die
Hinweise am Ende. Gemessen: Der Regeltext von "Gipfelstuermer" ist
7474 Zeichen lang, das Feld fasst 6000 -- es wird also gekuerzt,
und gekuerzt wird von HINTEN. Die Hinweise waren damit genau das
Erste, was wegfiel: Eine Kachel, der die Preise fehlten, sagte es
nicht mehr. Die wichtigste Zeile darf nicht die sein, die als
erste verschwindet. */
const vorweg = (hinweise || []).map((h) => `* ${h} Bitte nachtragen.`);
return [...vorweg, ...bloecke].join("\n\n").trim();
}