Eine TikTok-Kampagnenseite lesen -- Etappe 1, nur lesen
Etappe 1 des Bauplans "Kampagnen-Kachel aus TikTok-Link": das Auslesen
einer Kampagnenseite, nachweisbar richtig, ohne dass irgendetwas davon
schon eine Kachel erzeugt. Keine Route, keine Spalte, keine Oberflaeche,
keine Netzanfrage im Betrieb. index.js, workspace.js und
workspace-bereiche.js sind unberuehrt; beide Haeuser verhalten sich
unveraendert.
DER WEG NACH DRAUSSEN WIRD GETEILT
helfer-tiktok.mjs nimmt istTikTok und holeMitFrist aus
workspace-video.js auf. Zwei Fassungen von istTikTok waeren die
abgeschriebene Liste aus CLAUDE.md -- und gerade dort faellt es am
teuersten aus: Es ist die eine Stelle, die entscheidet, welchen
fremden Rechner unser Server anfragt. Die Frist ist jetzt ein
Zusatz mit 8000 ms als Vorgabe; das Video bleibt damit beim alten
Verhalten, die Kampagnenseite braucht mehr. pruef-video: 74
Pruefungen, 0 Fehler.
GEMESSEN, NICHT ABGESCHRIEBEN -- und der Bauplan irrte zweimal
1. pageInfo.title IST der Kampagnenname ("Gipfelstuermer"). Nur das
Kopfbild und der Untertitel kommen aus der Vorlage (einem
goldenen Loewen aus einer Nahost-Kampagne vom August 2024). Das
Banner wird deshalb aus props.imageUrl[0].url genommen.
2. Eine BEENDETE Kampagne liefert
activityInfo.ac_schema_with_interaction_rules gar nicht mehr. Das
Geruest steht dann nur unter value.schema, mit Bausteinnamen auf
_rep_remove. Beide Quellen werden gelesen, die Endung wird
abgeschnitten -- sonst liesse sich keine abgelaufene Kampagne
nachtragen.
DAS SCHEMA IST DIE WAHRHEIT, DAS WOERTERBUCH IST NUR DAS WOERTERBUCH
134 Platzhalter im Seitengeruest, 135 Eintraege im Woerterbuch. Der
eine Ueberzaehlige lautet "1 schenkende Person = 100 Punkte" und
gehoert zu einer frueheren Fassung der Kampagne. Wer das Woerterbuch
durchliest, schreibt eine Regel in die Kachel, die nicht gilt, und
das Team richtet seinen Stream danach aus. Nachgeschlagen wird
deshalb nur, nie durchgelaufen.
VIER ECHTE SEITEN ALS PRUEFDATEN
Am 07.10.2026 unangemeldet geholt (userInfo.uid = "0", anchor_id
leer) -- es steckt keine Person darin. Brotli gepackt, 150 KB je
Seite statt 1 MB, beim Anlegen sofort zurueckgelesen und Byte fuer
Byte verglichen. Jede ist mit ihrer SHA-256 festgenagelt: eine
Pruefung, deren Eingabe sich aendern kann, beweist nichts. Woher sie
kommen und was an EINER von ihnen veraendert wurde, steht in
server/pruefdaten/LIESMICH.md.
.gitattributes: server/pruefdaten/** -text. Ohne das schriebe git
kampagne-kaputt.html beim Auschecken auf CRLF um (core.autocrlf=true),
369 Bytes wuerden 378, und die Pruefung meldete einen Schaden, den es
nicht gibt -- die Sorte Fehlalarm, nach der man eine Pruefung
abschaltet. git hat es beim Hinzufuegen selbst angesagt.
pruef-kampagne-lesen.mjs: 108 Pruefungen, 0 Fehler, ohne Server, ohne
Port, ohne Netz, ohne Datenbank. Drei Ausgaenge: gelesen /
Pflichtfeld fehlt / Aufbau unbekannt -- fehlt eine Pruefdatei, endet
sie mit "KONNTE NICHT NACHSEHEN" und Rueckgabewert 2, nicht mit einem
uebersprungenen Abschnitt.
SIEBEN SABOTAGEN, SIEBEN TREFFER
Woerterbuch durchlesen, Tag mit toISOString bilden, auf time_zone
ausweichen, Vorlagenbild als Banner, _rep_remove stehenlassen, eine
Netzanfrage einschmuggeln, ein Byte in einer Pruefdatei kippen --
jede wurde bemerkt, jede in genau dem Abschnitt, in dem sie erwartet
war. Eine Pruefung, die immer bestaetigt, bestaetigt nichts.
ZWEI FUNDE AM RANDE, BEIDE BEIM MESSEN AUFGEFALLEN
mess-fokus.mjs war seit dem 01.10.2026 KAPUTT: Die Einfuhr von
eigenerPort stand INNERHALB eines Blockkommentars (Zeile 27 oeffnet,
Zeile 34 schliesst). Die Datei brach beim Start mit ReferenceError
ab. Niemandem aufgefallen, weil sie von Hand gestartet wird.
Gefunden hat das pruef-struktur.mjs -- aber erst, nachdem seine
Quellenliste ABGELEITET wird statt aufgezaehlt. Dort standen drei
Namen von Hand, obwohl der Kommentar darueber seit immer
"ABGELEITET, NICHT AUFGEZAEHLT" verspricht. Gemessen: 98 Namen und
508 Aufrufe vorher, 148 und 1025 jetzt. In der Luecke dazwischen lag
genau dieser Fehler.
NICHT ANGEFASST, WEIL AUSSERHALB DIESER ETAPPE (vorbestehend, belegt
mit einem Lauf ohne meine Dateien): pruef-struktur meldet weiterhin
vier Befunde -- willkommen.html ohne apple-touch-icon, ohne Manifest
und ohne theme-color, sowie zwei Stellen, die ihren Kalendertag aus
UTC bilden (workspace-anleitung.js:194, pruef-anleitung.mjs:1124).
Ebenso pruef-portnummern: mess-anleitung-crew.mjs rechnet seine zweite
Portnummer als PORT + 1 statt sie abzuleiten.
pruef-eventkarte 94/0 und pruef-agentur 62/0 -- beide gleich wie vor
dem Umbau.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -37,3 +37,18 @@
|
|||||||
# Vorsorge, keine Reparatur: Ein Haken, der still nicht laeuft, ist
|
# Vorsorge, keine Reparatur: Ein Haken, der still nicht laeuft, ist
|
||||||
# genau die Sicherung, die aussieht, als waere sie da.
|
# genau die Sicherung, die aussieht, als waere sie da.
|
||||||
tools/git-haken/* text eol=lf
|
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
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
}
|
||||||
@@ -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 = /<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([
|
||||||
|
[" ", " "], ["&", "&"], ["<", "<"], [">", ">"],
|
||||||
|
[""", "\""], ["'", "'"], ["'", "'"], ["—", "—"],
|
||||||
|
["–", "–"], ["…", "…"], ["•", "·"], ["€", "€"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** 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 ` ` in jedem
|
||||||
|
`<strong>Liga </strong>`. */
|
||||||
|
.replace(/\u00a0/g, " ")
|
||||||
|
.split("\n")
|
||||||
|
.map((z) => z.replace(/[ \t]+/g, " ").trim())
|
||||||
|
.join("\n")
|
||||||
|
.replace(/\n{3,}/g, "\n\n")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
LESEN
|
||||||
|
=================================================================== */
|
||||||
|
|
||||||
|
const PLATZHALTER = /^\$\$__activity_/;
|
||||||
|
|
||||||
|
/** Baut die Nachschlagefunktion fuer EINE Seite.
|
||||||
|
*
|
||||||
|
* Sie schlaegt NACH, sie durchsucht nicht. Ein Platzhalter ohne
|
||||||
|
* Eintrag gibt `null` zurueck und wird gezaehlt -- so faellt es auf,
|
||||||
|
* wenn TikTok die Woerterbuecher trennt, statt dass stillschweigend
|
||||||
|
* ein Platzhalter in der Kachel landet. */
|
||||||
|
function nachschlagen(wert, geruest) {
|
||||||
|
const woerterbuch = {
|
||||||
|
...(geruest?.sourceTextMap || {}),
|
||||||
|
...(wert?.sourceTextMap || {}),
|
||||||
|
};
|
||||||
|
const sprache = wert?.globalData?.currentLang
|
||||||
|
|| wert?.globalData?.language || "de-DE";
|
||||||
|
const ohneEintrag = [];
|
||||||
|
|
||||||
|
/** Ein Wert, wie er in der Kachel stehen soll. */
|
||||||
|
const auf = (roh) => {
|
||||||
|
if (typeof roh !== "string") return null;
|
||||||
|
if (!PLATZHALTER.test(roh)) return roh.trim() ? roh : null;
|
||||||
|
if (Object.prototype.hasOwnProperty.call(woerterbuch, roh)) {
|
||||||
|
const text = woerterbuch[roh];
|
||||||
|
return typeof text === "string" && text.trim() ? text : null;
|
||||||
|
}
|
||||||
|
ohneEintrag.push(roh.slice(0, 120));
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Erst den Platzhalter, dann den fertigen Text in der Sprache.
|
||||||
|
* Manche Bausteine tragen beides -- `multiLingualContent` ist dann
|
||||||
|
* dieselbe Aussage, nur schon aufgeloest. */
|
||||||
|
const aufMit = (knoten, feld) => {
|
||||||
|
const direkt = auf(knoten?.[feld]);
|
||||||
|
if (direkt) return direkt;
|
||||||
|
const viele = knoten?.multiLingualContent;
|
||||||
|
if (viele && typeof viele === "object") {
|
||||||
|
const text = viele[sprache] ?? Object.values(viele)[0];
|
||||||
|
if (typeof text === "string" && text.trim()) return text;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { auf, aufMit, sprache, woerterbuch, ohneEintrag };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liest eine gespeicherte oder geholte TikTok-Kampagnenseite.
|
||||||
|
* @param {string} html die ganze Seite als Text
|
||||||
|
* @returns {{pflicht: object, kuer: object, hinweise: string[], auszug: object}}
|
||||||
|
*/
|
||||||
|
export function leseKampagne(html) {
|
||||||
|
const wert = datenAusSeite(html);
|
||||||
|
const { geruest, woher } = geruestVon(wert);
|
||||||
|
const { auf, aufMit, sprache, woerterbuch, ohneEintrag } = nachschlagen(wert, geruest);
|
||||||
|
const teile = bausteine(geruest);
|
||||||
|
const erster = (name) => (teile.get(name) || [])[0] || null;
|
||||||
|
|
||||||
|
const global = wert.globalData || {};
|
||||||
|
const aktion = global.activityInfo || {};
|
||||||
|
const seite = global.pageInfo || {};
|
||||||
|
const hinweise = [];
|
||||||
|
|
||||||
|
/* ---- PFLICHT ---------------------------------------------------- */
|
||||||
|
const id = String(aktion.activity_id || global.activityId || "").trim();
|
||||||
|
const zone = String(aktion.tz_location_name || "").trim();
|
||||||
|
const titel = String(seite.title || "").trim();
|
||||||
|
|
||||||
|
const fehlt = [];
|
||||||
|
if (!id) fehlt.push("die Kampagnennummer (activity_id)");
|
||||||
|
if (!zone) {
|
||||||
|
/* KEIN Ausweichen auf UTC und keines auf `time_zone`. Siehe oben:
|
||||||
|
ein fester Versatz ist nach der Zeitumstellung falsch, und ein
|
||||||
|
falsches Datum sieht aus wie ein Datum. */
|
||||||
|
fehlt.push("die Zeitzone der Kampagne (tz_location_name)");
|
||||||
|
}
|
||||||
|
if (!titel) fehlt.push("der Titel (pageInfo.title)");
|
||||||
|
|
||||||
|
/* Beide Zeitstempel in Millisekunden. Eine Zahl, die zu klein ist,
|
||||||
|
waere eine Sekundenangabe -- und damit das Jahr 1970. */
|
||||||
|
const zeitpunkt = (roh, name) => {
|
||||||
|
const n = Number(roh);
|
||||||
|
if (!Number.isFinite(n) || n < 1_000_000_000_000) {
|
||||||
|
fehlt.push(name);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
};
|
||||||
|
const startMs = zeitpunkt(aktion.start_time, "der Beginn (start_time)");
|
||||||
|
const endeMs = zeitpunkt(aktion.end_time, "das Ende (end_time)");
|
||||||
|
|
||||||
|
const start = startMs !== null && zone ? tagInZone(startMs, zone) : null;
|
||||||
|
const ende = endeMs !== null && zone ? tagInZone(endeMs, zone) : null;
|
||||||
|
if (zone && startMs !== null && !start) fehlt.push(`die Zone „${zone}" ist unbekannt`);
|
||||||
|
|
||||||
|
if (fehlt.length) {
|
||||||
|
throw new KampagneUnlesbar(
|
||||||
|
`gelesen, aber ohne ${fehlt.join(" und ohne ")} -- daraus wird keine Kachel`,
|
||||||
|
"pflicht");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- NEBENSACHEN ------------------------------------------------ */
|
||||||
|
|
||||||
|
/* Einleitung */
|
||||||
|
const einleitungBaustein = erster("live_campaign_intro");
|
||||||
|
const einleitung = einleitungBaustein
|
||||||
|
? aufMit(einleitungBaustein.props || {}, "content") : null;
|
||||||
|
const einleitungTitel = einleitungBaustein
|
||||||
|
? auf(einleitungBaustein.props?.title) : null;
|
||||||
|
if (!einleitung) hinweise.push("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();
|
||||||
|
}
|
||||||
@@ -30,8 +30,8 @@ const SUCHE = process.argv[3] || ".kachel";
|
|||||||
Sie kannte den dritten Ausgang nicht (EACCES -- das System gibt den
|
Sie kannte den dritten Ausgang nicht (EACCES -- das System gibt den
|
||||||
Port dauerhaft nicht her) und sagte nicht, wie man nachsieht, wer
|
Port dauerhaft nicht her) und sagte nicht, wie man nachsieht, wer
|
||||||
ihn haelt. Eine Abschrift eines Waechters ist dieselbe Falle wie
|
ihn haelt. Eine Abschrift eines Waechters ist dieselbe Falle wie
|
||||||
import { eigenerPort } from "./helfer-port.mjs";
|
|
||||||
eine Abschrift einer Spaltenliste. */
|
eine Abschrift einer Spaltenliste. */
|
||||||
|
import { eigenerPort } from "./helfer-port.mjs";
|
||||||
const PORT = await eigenerPort(import.meta);
|
const PORT = await eigenerPort(import.meta);
|
||||||
const ordner = mkdtempSync(join(tmpdir(), "ws-kontrast-"));
|
const ordner = mkdtempSync(join(tmpdir(), "ws-kontrast-"));
|
||||||
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
process.env.WORKSPACE_DB = join(ordner, "workspace.db");
|
||||||
|
|||||||
@@ -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 `<ol start="2"><li><strong>Liga</strong></li></ol>`. 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 = "<ol start=\"3\"><li><strong>Liga </strong></li></ol>";
|
||||||
|
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("<p>eins</p><p>zwei</p>") === "eins\n\nzwei", "Absätze trennen");
|
||||||
|
ok(textAusHtml("a<br>b") === "a\nb", "ein Umbruch bricht um");
|
||||||
|
ok(textAusHtml("<ul><li>x</li><li>y</li></ul>") === "· x\n· y", "Punkte bekommen ihr Zeichen");
|
||||||
|
ok(textAusHtml("<ol start=\"7\"><li>a</li><li>b</li></ol>") === "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("<script>boese()</script><p>gut</p>") === "gut", "Skripte fallen weg");
|
||||||
|
ok(textAusHtml("") === "" && textAusHtml(null) === "" && textAusHtml(undefined) === "",
|
||||||
|
"nichts hinein ergibt nichts heraus");
|
||||||
|
ok(textAusHtml("<p>a</p>\n\n\n\n<p>b</p>") === "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("</script>", 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);
|
||||||
@@ -1190,7 +1190,28 @@ console.log(`\n=== Ruft ein Modul etwas, das es nie eingefuehrt hat? ===`);
|
|||||||
return raus;
|
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
|
const hausNamen = new Map(); // Name -> woher
|
||||||
for (const q of quellen) {
|
for (const q of quellen) {
|
||||||
for (const n of exporte(readFileSync(join(SERVER, q), "utf8"))) hausNamen.set(n, q);
|
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
|
LISTE, DIE NIEMAND PFLEGT. Deshalb wird nachgesehen, dass jede
|
||||||
ausgenommene Datei das Muster WIRKLICH enthaelt — raeumt es
|
ausgenommene Datei das Muster WIRKLICH enthaelt — raeumt es
|
||||||
jemand weg, ist die Ausnahme veraltet und faellt auf. */
|
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 schal = [...AUSNAHMEN_UTC].filter((n) => {
|
||||||
const t = readFileSync(join(SERVER, n), "utf8");
|
const t = readFileSync(join(SERVER, n), "utf8");
|
||||||
|
|||||||
@@ -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`.
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,9 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de"><head><meta charset="utf-8">
|
||||||
|
<title>TikTok LIVE</title>
|
||||||
|
<script type="application/json" id="__NEXT_DATA__">{"irgendwas":true}</script>
|
||||||
|
</head><body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<!-- Hier stand bis zum Umbau das Paket __MODERN_ROUTER_DATA__. -->
|
||||||
|
<script>window.__INITIAL_STATE__ = { activityId: "7691493175936699143" };</script>
|
||||||
|
</body></html>
|
||||||
Binary file not shown.
@@ -52,17 +52,17 @@ import {
|
|||||||
import { darfSchreiben, sofortFreigeben } from "./workspace-treff.js";
|
import { darfSchreiben, sofortFreigeben } from "./workspace-treff.js";
|
||||||
import { sichtbar } from "./workspace-bereiche.js";
|
import { sichtbar } from "./workspace-bereiche.js";
|
||||||
import { bildArtVon } from "./workspace-dateien.js";
|
import { bildArtVon } from "./workspace-dateien.js";
|
||||||
|
import { FRIST_MS, istTikTok, holeMitFrist } from "./helfer-tiktok.mjs";
|
||||||
|
|
||||||
export const videoRouter = express.Router();
|
export const videoRouter = express.Router();
|
||||||
|
|
||||||
const ORDNER = join(DATEN_ORDNER, "dateien");
|
const ORDNER = join(DATEN_ORDNER, "dateien");
|
||||||
try { mkdirSync(ORDNER, { recursive: true }); } catch { /* gibt es schon */ }
|
try { mkdirSync(ORDNER, { recursive: true }); } catch { /* gibt es schon */ }
|
||||||
|
|
||||||
/* GRENZEN FÜR DEN WEG NACH DRAUSSEN. Ein fremder Dienst, der nicht
|
/* Die Grenze fuer ein heruntergeladenes Cover. Die FRIST fuer den
|
||||||
antwortet, darf unsere Anfrage nicht mit sich ziehen -- genau daran
|
Weg nach draussen steht seit dem 07.10.2026 in helfer-tiktok.mjs --
|
||||||
ist in diesem Haus schon ein ganzer Pruefdurchlauf drei Stunden lang
|
sie gilt jetzt auch fuer die Kampagnenseite, und zwei Fassungen
|
||||||
haengengeblieben. */
|
derselben Zahl laufen irgendwann auseinander. */
|
||||||
const FRIST_MS = 8000;
|
|
||||||
const COVER_MAX = 4 * 1024 * 1024;
|
const COVER_MAX = 4 * 1024 * 1024;
|
||||||
|
|
||||||
function angemeldet(req, res, next) {
|
function angemeldet(req, res, next) {
|
||||||
@@ -73,59 +73,6 @@ function angemeldet(req, res, next) {
|
|||||||
}
|
}
|
||||||
videoRouter.use("/workspace/api/bereich", angemeldet);
|
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
|
/* WOHIN GEFRAGT WIRD. Im Betrieb TikTok; in einer Pruefung ein
|
||||||
nachgebauter Dienst auf 127.0.0.1.
|
nachgebauter Dienst auf 127.0.0.1.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user