/* ===================================================================== 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);