Files
dogfather-universe/server/pruef-tiktok-datei.mjs
T
DogFatherGitandClaude Opus 5 027c372afd Die TikTok-Datei des Creators -- ohne Entwicklerkonto
Filipe: "tiktok entwicklerkonto hab ich nicht und werd ich nicht
bekommen."

Damit sind zwei der drei Wege aus dem Plan vom 10.09. erledigt: Login
Kit und die Datenportabilitaets-API haengen beide an einer App auf
developers.tiktok.com. Sie sind nicht verschoben, sie sind weg.

DER DRITTE BRAUCHT KEINES. Jeder TikTok-Nutzer kann seine eigenen Daten
selbst herunterladen (Profil -> Einstellungen -> Konto -> Deine Daten
herunterladen, Format JSON). Die Datei enthaelt laut TikToks eigener
Datentypen-Liste einen Abschnitt "TikTok LIVE" mit dem Go-LIVE-Verlauf.
Der Creator gibt sie her, mit eigenen Haenden -- legitimer wird es
nicht, und es ist jetzt der einzige verbliebene Weg an TikTok-Daten.

ICH KENNE DAS FORMAT NICHT -- UND BAUE TROTZDEM.
TikTok dokumentiert, DASS es den Verlauf gibt, nicht wie die Schluessel
heissen. Der naheliegende Weg waere, Namen zu raten. Das waere die
schlechteste Loesung: Er fiele bei der ersten echten Datei auseinander,
und zwar STILL -- "0 Eintraege gefunden" sieht aus wie "war nicht live".

Deshalb wird gesucht statt geraten. Der Leser geht die Datei durch,
sammelt ALLE Listen, deren Eintraege ein Datum tragen (gemessen am
INHALT, nicht am Feldnamen), schlaegt eine Zuordnung vor und legt sie
zur Auswahl vor -- mit Anzahl, Zeitraum, Beispielzeile und dem, was
dabei herauskaeme. Bestaetigt wird von Hand. Damit ist der Leser
unabhaengig davon, wie die Felder heissen.

DIE EINHEIT IST DIE FALLE. "83" kann Sekunden, Minuten oder Stunden
sein. Geraten wird NICHT aus der Zahl, sondern aus dem Schluesselnamen
-- und wo der schweigt, aus der Form ("01:23:45" ist eindeutig).
Schweigen beide, kommt null zurueck. Eine Dauer, die um Faktor 60
danebenliegt, sieht richtig aus und ist es nicht.

MEHRERE LIVES AN EINEM TAG SIND EIN TAG: Dauer und Diamanten addiert,
bei den Zuschauern gewinnt die hoehere Spitze -- ein Durchschnitt aus
zwei Streams waere eine Zahl, die es nie gegeben hat.

DIE DATEI UEBERSCHREIBT NICHTS. Anders als der Backstage-Import, der
die Wahrheit der Agentur bringt, ist diese Datei die ZWEITE Quelle. Wo
schon eine Zahl steht -- von Hand oder aus Backstage --, bleibt sie
stehen (COALESCE statt REPLACE). Sonst wuerde ein Dateiupload
stillschweigend die offiziellen Zahlen ersetzen, und niemand wuesste
hinterher, welche gilt.

UND SIE WIRD NICHT GESPEICHERT. Gelesen, ausgewertet, verworfen. In
derselben Datei stehen Direktnachrichten, Such- und Ansehverlauf; die
haben auf diesem Server nichts zu suchen. "Income and Wallet" bleibt
ebenfalls liegen: Diamanten sind Leistung, Auszahlungen sind Gehalt.

GEPRUEFT OHNE ECHTE DATEI -- MIT DREI ERFUNDENEN (47 Pruefungen):
  A  englisch, flach, Dauer in Sekunden
  B  deutsch, verschachtelt, Dauer als "01:23:45"
  C  Sekundenstempel, Dauer in Minuten, andere Namen
  +  eine Datei ganz ohne Verlauf -> muss abgelehnt werden

Eine Pruefung gegen EINE ausgedachte Form wuerde nur beweisen, dass
mein Leser meine eigene Erfindung liest. Drei verschiedene zeigen, dass
er das Prinzip kann und nicht eine Form.

EIN EIGENER FEHLER, von der Pruefung gefunden: Der Nachrichtenverlauf
in Form A hatte nur EINEN Eintrag und fiel damit unter die
Mindestgroesse von zwei. Die Pruefung "der LIVE-Verlauf steht oben"
hatte danach nur einen Kandidaten und bewies gar nichts -- eine
Rangfolge laesst sich nur an mindestens zwei Dingen zeigen. Jetzt sind
es drei Nachrichten, und die Rangfolge ist wirklich gemessen.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-11 12:01:49 +02:00

296 lines
14 KiB
JavaScript

/* =====================================================================
DIE TIKTOK-DATEI EINES CREATORS — und warum sie ohne Beispieldatei
trotzdem prüfbar ist.
Filipe, 11.09.2026: "tiktok entwicklerkonto hab ich nicht und werd
ich nicht bekommen." Damit sind Login Kit und Datenportabilitäts-API
erledigt. Übrig bleibt der Weg, der keines braucht: Der Creator lädt
seine eigenen Daten herunter und gibt die Datei her.
---------------------------------------------------------------------
DAS PRÜFPROBLEM
Ich habe keine echte Datei. TikTok dokumentiert, DASS es einen
Go-LIVE-Verlauf gibt, nicht wie die Schlüssel heissen. Eine Prüfung
gegen EINE ausgedachte Form würde nur beweisen, dass mein Leser meine
eigene Erfindung liest.
DESHALB DREI FORMEN, absichtlich verschieden:
A englisch, flach, Dauer in Sekunden
B deutsch, verschachtelt, Dauer als "01:23:45"
C Zeitstempel als Sekundenzahl, Dauer in Minuten, andere Namen
Besteht der Leser alle drei, hat er nicht eine Form gelernt, sondern
das Prinzip: Listen mit Datum finden, Felder vorschlagen, den
Menschen bestätigen lassen. Und dazu eine vierte Datei, die KEINEN
Verlauf enthält -- sie muss abgelehnt werden, sonst hiesse "es
funktioniert" nur "es sagt immer ja".
Aufruf: node server/pruef-tiktok-datei.mjs
===================================================================== */
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { spawn } from "node:child_process";
import { DatabaseSync } from "node:sqlite";
import { scryptSync, randomBytes } from "node:crypto";
import { notbremse } from "./helfer-notbremse.mjs";
import { tagAus, minutenAus, kandidatenFinden, listeAmPfad, zuTagen } from "./workspace-tiktok-datei.js";
notbremse(240_000, "pruef-tiktok-datei");
const HIER = dirname(fileURLToPath(import.meta.url));
const PORT = 4391;
const BASIS = `http://127.0.0.1:${PORT}`;
const ordner = mkdtempSync(join(tmpdir(), "ws-ttdatei-"));
const DB = join(ordner, "workspace.db");
const jetzt = new Date().toISOString();
let gemacht = 0, fehler = 0;
const ok = (b, text) => {
gemacht++;
if (b) console.log(` ok ${text}`);
else { fehler++; console.log(` FEHL ${text}`); }
};
/* =======================================================================
TEIL 1: Die Bausteine allein -- ohne Server, ohne Browser
======================================================================= */
console.log("=== Datum lesen ===");
ok(tagAus("2026-09-10 21:14:03") === "2026-09-10", "Text mit Uhrzeit");
ok(tagAus("2026-09-10T19:14:03.000Z") === "2026-09-10", "ISO");
ok(tagAus("10.09.2026") === "2026-09-10", "deutsches Datum");
ok(tagAus(1757538843) === new Date(1757538843000).toISOString().slice(0, 10),
"Sekundenstempel");
ok(tagAus(1757538843000) === new Date(1757538843000).toISOString().slice(0, 10),
"Millisekundenstempel");
/* GEGENPROBEN: Was KEIN Datum ist, darf auch keines werden. */
ok(tagAus("Just Chatting") === null, "ein Text ist kein Datum");
ok(tagAus(42) === null, "eine kleine Zahl ist kein Datum (Jahr 1970)");
ok(tagAus("1999-01-01") === null, "vor 2016 gab es TikTok nicht");
ok(tagAus(null) === null, "und nichts ist nichts");
console.log("\n=== Dauer lesen -- die Einheit ist die Falle ===");
ok(minutenAus("01:23:45") === 83.75, `"01:23:45" sind 83,75 Minuten (${minutenAus("01:23:45")})`);
ok(minutenAus("23:45") === 23.75, `"23:45" sind 23,75 Minuten (${minutenAus("23:45")})`);
ok(minutenAus(5025, "duration_seconds") === 83.75, "5025 Sekunden sind 83,75 Minuten");
ok(minutenAus(84, "dauer_minuten") === 84, "84 Minuten bleiben 84");
ok(minutenAus(2, "hours_live") === 120, "2 Stunden sind 120 Minuten");
ok(minutenAus("2h 15m") === 135, `"2h 15m" sind 135 Minuten (${minutenAus("2h 15m")})`);
/* DIE WICHTIGSTE GEGENPROBE: Sagt der Name nichts und die Form nichts,
wird NICHTS geraten. Eine Dauer, die um Faktor 60 danebenliegt, sieht
richtig aus und ist es nicht. */
ok(minutenAus(83, "wert") === null, "eine nackte Zahl ohne Einheit ergibt null, nicht 83");
/* =======================================================================
TEIL 2: Drei Dateiformen
======================================================================= */
const FORM_A = { // englisch, flach, Sekunden
Profile: { username: "nova" },
"TikTok Live": {
"Go Live History": [
{ start_time: "2026-09-08 20:00:00", duration_seconds: 5400, total_diamonds: "1200", peak_viewers: 310 },
{ start_time: "2026-09-08 23:30:00", duration_seconds: 1800, total_diamonds: "300", peak_viewers: 120 },
{ start_time: "2026-09-09 21:00:00", duration_seconds: 7200, total_diamonds: "2450", peak_viewers: 512 },
],
},
/* DREI Nachrichten, nicht eine. Beim ersten Anlauf stand hier EINE,
und damit fiel der Verlauf unter die Mindestgrösse von zwei
Einträgen -- die Prüfung "der LIVE-Verlauf steht oben" hatte danach
nur einen Kandidaten und bewies gar nichts. Eine Rangfolge lässt
sich nur an mindestens zwei Dingen zeigen. */
"Direct Messages": {
ChatHistory: [
{ Date: "2026-09-01 10:00:00", Content: "hi" },
{ Date: "2026-09-02 11:00:00", Content: "und?" },
{ Date: "2026-09-03 12:00:00", Content: "bis dann" },
],
},
};
const FORM_B = { // deutsch, verschachtelt, hh:mm:ss
Konto: { Nutzername: "kiro" },
Aktivität: {
LIVE: {
Verlauf: [
{ Datum: "08.09.2026", Dauer: "01:30:00", Diamanten: "1.200", "Höchste Zuschauer": "310" },
{ Datum: "09.09.2026", Dauer: "02:00:00", Diamanten: "2.450", "Höchste Zuschauer": "512" },
],
},
},
};
const FORM_C = { // Stempel + Minuten, andere Namen
live_sessions: [
{ started_at: 1757361600, live_minutes: 90, gift_count: 1200, audience_peak: 310 },
{ started_at: 1757448000, live_minutes: 120, gift_count: 2450, audience_peak: 512 },
],
};
const FORM_LEER = { // kein Verlauf
Profile: { username: "lumi" },
Settings: { language: "de", theme: "dark" },
};
console.log("\n=== Drei verschiedene Dateiformen -- und eine ohne Verlauf ===");
for (const [name, datei, erwarteteTage] of [["A englisch/Sekunden", FORM_A, 2],
["B deutsch/hh:mm:ss", FORM_B, 2], ["C Stempel/Minuten", FORM_C, 2]]) {
const k = kandidatenFinden(datei);
ok(k.length >= 1, `${name}: eine Liste gefunden (${k.length})`);
const beste = k[0];
const liste = listeAmPfad(datei, beste.pfad);
ok(Array.isArray(liste) && liste.length >= 2,
`${name}: die Liste ist wieder auffindbar (${liste?.length} Einträge)`);
const { tage } = zuTagen(liste, beste.vorschlag);
ok(tage.length === erwarteteTage,
`${name}: ${tage.length} Tage (erwartet ${erwarteteTage})`);
/* Form A hat ZWEI Lives am 8. September -- sie müssen zu EINEM Tag
mit 120 Minuten zusammenfallen. Das ist der Fall, an dem sich
zeigt, ob addiert statt überschrieben wird. */
if (name.startsWith("A")) {
const achter = tage.find((t) => t.tag === "2026-09-08");
ok(achter?.lives === 2, `A: der 8. hat zwei Lives (${achter?.lives})`);
ok(achter?.dauer_min === 120, `A: und zusammen 120 Minuten (${achter?.dauer_min})`);
ok(achter?.diamanten === 1500, `A: und 1500 Diamanten (${achter?.diamanten})`);
ok(achter?.zuschauer_max === 310, `A: Spitze ist die höhere der beiden (${achter?.zuschauer_max})`);
}
if (name.startsWith("B")) {
const t = tage.find((x) => x.tag === "2026-09-09");
ok(t?.dauer_min === 120, `B: "02:00:00" wurde zu 120 Minuten (${t?.dauer_min})`);
ok(t?.diamanten === 2450, `B: "2.450" wurde zu 2450 (${t?.diamanten})`);
}
}
{
const k = kandidatenFinden(FORM_LEER);
ok(k.length === 0, `eine Datei ohne Verlauf ergibt keine Kandidaten (${k.length})`);
}
{
/* UND DER VERLAUF MUSS OBEN STEHEN. Form A enthält auch einen
Nachrichtenverlauf mit Datum -- der ist ebenfalls ein gültiger
Kandidat, darf aber nicht der erste Vorschlag sein. */
const k = kandidatenFinden(FORM_A);
ok(k.length >= 2, `A: auch die Nachrichten werden als Liste erkannt (${k.length} Kandidaten)`);
ok(/live/i.test(k[0].pfad), `A: vorgeschlagen wird aber der LIVE-Verlauf ("${k[0].pfad}")`);
}
/* =======================================================================
TEIL 3: Der ganze Weg über den Server
======================================================================= */
let kind = null;
async function starten() {
kind = spawn(process.execPath, [join(HIER, "index.js")], {
env: { ...process.env, WORKSPACE_DB: DB, PORT: String(PORT),
SITE_ACCESS_SECRET: "lokaler-test",
SITE_PUBLIC_LAUNCH_AT: "2020-01-01T00:00:00+01:00" },
stdio: ["ignore", "pipe", "pipe"] });
let aus = "";
kind.stdout.on("data", (d) => { aus += d; });
kind.stderr.on("data", (d) => { aus += d; });
for (let i = 0; i < 80; i++) {
await new Promise((r) => setTimeout(r, 250));
if (kind.exitCode !== null) break;
try { const a = await fetch(BASIS + "/workspace/", { redirect: "manual" });
if (a.status < 500) return; } catch { /* noch nicht oben */ }
}
throw new Error("Server startete nicht:\n" + aus.slice(-1500));
}
await starten();
kind.kill();
await new Promise((r) => setTimeout(r, 600));
let idNova;
{
const d = new DatabaseSync(DB);
const anlegen = (name, rolle, code) => {
const salz = randomBytes(16).toString("hex");
const hash = scryptSync(code, salz, 64,
{ N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex");
d.prepare("INSERT INTO personen (name, rolle, code_hash, code_salt, code_n, aktiv, erstellt) VALUES (?,?,?,?,?,1,?)")
.run(name, rolle, hash, salz, 32768, jetzt);
return d.prepare("SELECT last_insert_rowid() AS id").get().id;
};
anlegen("Filipe", "admin", "CODE-DOGI-0001");
idNova = anlegen("Nova", "creator", "CODE-NOVA-0001");
d.close();
}
await starten();
const anmelden = async (rolle, code) => {
const a = await fetch(BASIS + "/workspace/api/anmelden", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ rolle, code }) });
const keks = (a.headers.getSetCookie?.() || []).map((k) => k.split(";")[0]).join("; ");
if (!keks) throw new Error(`Anmeldung ${rolle} fehlgeschlagen (${a.status})`);
return keks;
};
const keksDogi = await anmelden("admin", "CODE-DOGI-0001");
const keksNova = await anmelden("creator", "CODE-NOVA-0001");
const ruf = async (weg, keks, koerper) => {
const a = await fetch(BASIS + weg, { method: "POST",
headers: { Cookie: keks, "Content-Type": "application/json", Origin: BASIS },
body: JSON.stringify(koerper) });
let daten = null; try { daten = await a.json(); } catch { /* leer erlaubt */ }
return { status: a.status, daten };
};
console.log("\n=== Über den Server ===");
let vorschlag = null;
{
const { status, daten } = await ruf(`/workspace/api/leistung/${idNova}/tiktok-datei/vorschau`,
keksDogi, { inhalt: FORM_A });
ok(status === 200, `die Vorschau liest die Datei (HTTP ${status})`);
ok(daten?.kandidaten?.length >= 1, `${daten?.kandidaten?.length} Kandidaten`);
vorschlag = daten?.kandidaten?.[0];
ok(vorschlag?.tage_gesamt === 2, `der beste ergäbe 2 Tage (${vorschlag?.tage_gesamt})`);
ok(!!vorschlag?.vorschlag?.dauer, `mit erkannter Dauerspalte (${vorschlag?.vorschlag?.dauer})`);
}
{
const { status, daten } = await ruf(`/workspace/api/leistung/${idNova}/tiktok-datei`,
keksDogi, { inhalt: FORM_A, pfad: vorschlag.pfad, zuordnung: vorschlag.vorschlag });
ok(status === 200, `die Übernahme läuft (HTTP ${status})`);
ok(daten?.geschrieben === 2, `zwei Tage geschrieben (${daten?.geschrieben})`);
const d = new DatabaseSync(DB);
const z = d.prepare("SELECT tag, dauer_min, diamanten, gueltiger_tag, quelle FROM leistung WHERE creator_id = ? ORDER BY tag").all(idNova);
ok(z.length === 2, `zwei Zeilen in der Datenbank (${z.length})`);
ok(z[0].dauer_min === 120 && z[0].diamanten === 1500,
`der 8. trägt beide Lives zusammen (${z[0].dauer_min} Min, ${z[0].diamanten} Dia)`);
ok(z.every((x) => x.gueltiger_tag === 1), "beide als gültiger Tag gezählt");
ok(z.every((x) => x.quelle === "import"), "Quelle 'import'");
d.close();
}
{
/* DIE ENTSCHEIDENDE REGEL: Eine vorhandene Zahl wird NICHT
überschrieben. Die Datei kommt vom Creator und ist die zweite
Quelle -- Backstage und die Hand gelten mehr. */
const d = new DatabaseSync(DB);
d.prepare("UPDATE leistung SET diamanten = 9999 WHERE creator_id = ? AND tag = '2026-09-08'").run(idNova);
d.close();
await ruf(`/workspace/api/leistung/${idNova}/tiktok-datei`, keksDogi,
{ inhalt: FORM_A, pfad: vorschlag.pfad, zuordnung: vorschlag.vorschlag });
const d2 = new DatabaseSync(DB);
const z = d2.prepare("SELECT diamanten FROM leistung WHERE creator_id = ? AND tag = '2026-09-08'").get(idNova);
ok(z?.diamanten === 9999, `eine vorhandene Zahl bleibt stehen (${z?.diamanten})`);
d2.close();
}
{
const { status, daten } = await ruf(`/workspace/api/leistung/${idNova}/tiktok-datei/vorschau`,
keksDogi, { inhalt: FORM_LEER });
ok(status === 400 && /JSON|Liste/i.test(daten?.fehler || ""),
`eine Datei ohne Verlauf wird abgelehnt ("${(daten?.fehler || "").slice(0, 50)}…")`);
}
{
const { status } = await ruf(`/workspace/api/leistung/${idNova}/tiktok-datei`,
keksNova, { inhalt: FORM_A, pfad: vorschlag.pfad, zuordnung: vorschlag.vorschlag });
ok(status === 403,
`ein Creator kann sich seine Zahlen nicht selbst eintragen (HTTP ${status})`);
}
kind.kill();
await new Promise((r) => setTimeout(r, 400));
try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Zugabe */ }
console.log(`\n${gemacht} Pruefungen`);
console.log(fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`);
process.exit(fehler ? 1 : 0);