diff --git a/server/pruef-tiktok-datei.mjs b/server/pruef-tiktok-datei.mjs new file mode 100644 index 00000000..545503eb --- /dev/null +++ b/server/pruef-tiktok-datei.mjs @@ -0,0 +1,295 @@ +/* ===================================================================== + 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); diff --git a/server/workspace-leistung.js b/server/workspace-leistung.js index 7fe9e6c4..2321b9e3 100644 --- a/server/workspace-leistung.js +++ b/server/workspace-leistung.js @@ -47,6 +47,7 @@ import { db, protokolliere, echteIp, sitzungLesen, istLeitung, istSpicy, sichtbareCreatorIds, ROLLEN_SORTIERUNG, heuteLokal, } from "./workspace.js"; +import { kandidatenFinden, listeAmPfad, zuTagen } from "./workspace-tiktok-datei.js"; export const leistungRouter = express.Router(); @@ -867,6 +868,139 @@ leistungRouter.post("/workspace/api/leistung/netzwerk-import", gleicheHerkunft, } }); +/* ===================================================================== + DIE EIGENE TIKTOK-DATEI EINES CREATORS (11.09.2026) + + Der letzte verbliebene Weg aus dem Plan vom 10.09.: Login Kit und + Datenportabilitäts-API hängen beide an einem TikTok-Entwicklerkonto, + und das wird es nicht geben ("hab ich nicht und werd ich nicht + bekommen"). Dieser hier braucht keines -- der Creator lädt seine + eigenen Daten bei TikTok herunter und gibt die Datei her. + + Das Suchen und Umrechnen steht in workspace-tiktok-datei.js; hier + stehen nur die beiden Wege und die Rechte. + + DIE DATEI WIRD NICHT GESPEICHERT. Sie wird gelesen, ausgewertet und + fällt danach weg. In ihr stehen auch Direktnachrichten und der + Ansehverlauf -- das ist nichts, was auf einem Server liegen soll, + der es nicht braucht. + ===================================================================== */ +leistungRouter.post("/workspace/api/leistung/:creatorId/tiktok-datei/vorschau", + gleicheHerkunft, express.json({ limit: "60mb" }), (req, res) => { + try { + const creatorId = Number(req.params.creatorId); + if (!Number.isInteger(creatorId)) return res.status(400).json({ fehler: "ungueltig" }); + if (!darfEintragen(req.person)) return res.status(403).json({ fehler: "nicht_erlaubt" }); + if (!darfSehen(req.person, creatorId)) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const inhalt = req.body?.inhalt; + if (!inhalt || typeof inhalt !== "object") { + return res.status(400).json({ fehler: "Das ist keine gelesene JSON-Datei." }); + } + const kandidaten = kandidatenFinden(inhalt); + if (!kandidaten.length) { + return res.status(400).json({ + fehler: "In dieser Datei steht keine Liste mit Datumsangaben. " + + "Wurde beim Herunterladen wirklich JSON gewählt (nicht TXT) und " + + "„Alles auswählen“ angehakt?", + }); + } + /* Zu jedem Vorschlag gleich zeigen, was dabei herauskäme -- sonst + muss der Mensch aus Schlüsselnamen raten, welcher der richtige + ist. */ + const mitVorschau = kandidaten.map((k) => { + const { tage } = zuTagen(k.beispiele.length ? listeAmPfad(inhalt, k.pfad) || [] : [], k.vorschlag); + return { + ...k, + tage_gesamt: tage.length, + tage_beispiel: tage.slice(-3), + /* Die Beispielzeilen gekürzt: In einem Vollarchiv hängt an + einem Eintrag manchmal ein ganzer Kommentartext. */ + beispiele: k.beispiele.map((b) => { + const klein = {}; + for (const [s, w] of Object.entries(b)) klein[s] = String(w ?? "").slice(0, 80); + return klein; + }), + }; + }); + res.json({ kandidaten: mitVorschau }); + } catch (fehler) { + console.error("[leistung] TikTok-Datei Vorschau:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + +leistungRouter.post("/workspace/api/leistung/:creatorId/tiktok-datei", + gleicheHerkunft, express.json({ limit: "60mb" }), (req, res) => { + try { + const creatorId = Number(req.params.creatorId); + if (!Number.isInteger(creatorId)) return res.status(400).json({ fehler: "ungueltig" }); + if (!darfEintragen(req.person)) return res.status(403).json({ fehler: "nicht_erlaubt" }); + if (!darfSehen(req.person, creatorId)) return res.status(404).json({ fehler: "nicht_gefunden" }); + + const inhalt = req.body?.inhalt; + const pfad = String(req.body?.pfad || ""); + const zuordnung = req.body?.zuordnung; + if (!inhalt || !pfad || !zuordnung?.tag) { + return res.status(400).json({ fehler: "Es fehlt die Auswahl, was gelesen werden soll." }); + } + const liste = listeAmPfad(inhalt, pfad); + if (!liste) return res.status(400).json({ fehler: "Diese Liste gibt es in der Datei nicht." }); + + const { tage, uebersprungen } = zuTagen(liste, zuordnung); + const heute = heuteLokal(); + const brauchbar = tage.filter((t) => t.tag <= heute + /* Ein Tag ohne eine einzige Zahl ist keine Messung. Dieselbe + Regel wie beim Backstage-Import. */ + && (t.dauer_min !== null || t.diamanten !== null || t.zuschauer_max !== null)); + if (!brauchbar.length) return res.status(400).json({ fehler: "keine_brauchbaren_tage" }); + + const d = db(); + const schreiben = d.prepare(` + INSERT INTO leistung + (creator_id, tag, diamanten, dauer_min, gueltiger_tag, zuschauer_max, + erfasst, erfasst_von, quelle) + VALUES (?,?,?,?,?,?,?,?,'import') + ON CONFLICT(creator_id, tag) DO UPDATE SET + /* NUR FÜLLEN, NICHT ÜBERSCHREIBEN. + + Anders als beim Backstage-Import, der die Wahrheit der + Agentur bringt: Diese Datei kommt vom Creator und ist eine + ZWEITE Quelle. Wo schon eine Zahl steht -- von Hand + eingetragen oder aus Backstage --, bleibt sie stehen. + Andersherum würde ein Dateiupload stillschweigend die + offiziellen Zahlen ersetzen, und niemand wüsste hinterher, + welche gilt. */ + diamanten = COALESCE(leistung.diamanten, excluded.diamanten), + dauer_min = COALESCE(leistung.dauer_min, excluded.dauer_min), + zuschauer_max = COALESCE(leistung.zuschauer_max, excluded.zuschauer_max), + gueltiger_tag = MAX(leistung.gueltiger_tag, excluded.gueltiger_tag)`); + + let geschrieben = 0, ergaenzt = 0; + const vorhanden = d.prepare("SELECT 1 FROM leistung WHERE creator_id = ? AND tag = ?"); + d.exec("BEGIN"); + try { + for (const t of brauchbar) { + const gabEs = !!vorhanden.get(creatorId, t.tag); + schreiben.run(creatorId, t.tag, t.diamanten, t.dauer_min, + (t.dauer_min ?? 0) > 0 ? 1 : 0, t.zuschauer_max, + new Date().toISOString(), req.person.id); + if (gabEs) ergaenzt++; else geschrieben++; + } + d.exec("COMMIT"); + } catch (f) { d.exec("ROLLBACK"); throw f; } + + protokolliere("leistung_tiktok_datei", { + personId: req.person.id, rolle: req.person.rolle, ip: echteIp(req), + detail: `Creator #${creatorId}: ${geschrieben} neu, ${ergaenzt} ergaenzt`.slice(0, 120), + }); + res.json({ geschrieben, ergaenzt, uebersprungen: uebersprungen.length }); + } catch (fehler) { + console.error("[leistung] TikTok-Datei:", fehler?.message); + res.status(503).json({ fehler: "nicht_verfuegbar" }); + } + }); + /** Vorschau: Was WÜRDE passieren? Schreibt nichts. */ leistungRouter.post("/workspace/api/leistung/:creatorId/import/vorschau", gleicheHerkunft, express.json({ limit: "2mb" }), (req, res) => { diff --git a/server/workspace-tiktok-datei.js b/server/workspace-tiktok-datei.js new file mode 100644 index 00000000..1ea8512d --- /dev/null +++ b/server/workspace-tiktok-datei.js @@ -0,0 +1,263 @@ +/* ===================================================================== + workspace-tiktok-datei.js — die eigenen TikTok-Daten eines Creators. + + Filipe, 10.09.2026: "wie sehr wir diese seite hier mit dem backstage + verbinden könnten von tiktok. ohne probleme und ganz schnell." + Am 11.09.2026: "tiktok entwicklerkonto hab ich nicht und werd ich + nicht bekommen." + + --------------------------------------------------------------------- + WARUM ES DIESEN WEG ÜBERHAUPT GIBT + + Die Recherche vom 10.09. hat drei Wege ergeben. Zwei davon sind seit + dem 11.09. erledigt: Login Kit und die Datenportabilitäts-API hängen + beide an einem Entwicklerkonto bei TikTok, und das wird es nicht + geben. + + Der dritte braucht keines. Jeder TikTok-Nutzer kann seine eigenen + Daten selbst herunterladen -- Profil → Einstellungen → Konto → Deine + Daten herunterladen, Format JSON. Die Datei enthält laut TikToks + eigener Datentypen-Liste einen Abschnitt "TikTok LIVE" mit dem + Go-LIVE-Verlauf. Der Creator lädt sie hier hoch, und zwar mit seinen + eigenen Händen -- legitimer wird es nicht. + + --------------------------------------------------------------------- + DAS EIGENTLICHE PROBLEM: ICH KENNE DAS FORMAT NICHT + + Ich habe keine echte Datei gesehen. TikTok dokumentiert, DASS es + "Go LIVE History" gibt, nicht WIE die Schlüssel heissen. Sie können + deutsch oder englisch sein, verschachtelt oder flach, und sie können + sich ändern, ohne dass jemand Bescheid sagt. + + DER NAHELIEGENDE WEG WÄRE, Schlüsselnamen zu raten und einen Leser + darauf zu bauen. Das wäre die schlechteste Lösung: Er würde bei der + ersten echten Datei auseinanderfallen, und zwar STILL -- "0 Einträge + gefunden" sieht aus wie "der Creator war nicht live". + + DESHALB WIRD NICHT GERATEN, SONDERN GESUCHT. Der Leser geht die + Datei durch, sammelt alle Listen von Einträgen, die ein Datum + enthalten, und legt sie zur Auswahl vor -- mit Anzahl, Zeitraum und + Beispielzeile. Der Mensch bestätigt, was davon der LIVE-Verlauf ist. + + Damit ist der Leser unabhängig davon, wie die Felder heissen. Er + schlägt etwas vor (nach Namensmustern), aber er entscheidet nicht. + Und wenn TikTok das Format ändert, ändert sich nur der Vorschlag, + nicht die Brauchbarkeit. + + --------------------------------------------------------------------- + WAS BEWUSST NICHT GELESEN WIRD + + Das Vollarchiv enthält auch "Income and Wallet" -- Transaktionen und + Auszahlungen. Das bleibt liegen. Diamanten sind Leistung, + Auszahlungen sind Gehalt, und diese Seite ist eine Arbeitslage, keine + Gehaltsakte (dieselbe Linie wie bei der Team-Lage). + + Ebenso liegen bleiben: Direktnachrichten, Suchverlauf, Ansehverlauf. + Sie stehen in derselben Datei. Der Leser rührt sie nicht an, und die + Datei selbst wird NICHT gespeichert -- gelesen, ausgewertet, + verworfen. + ===================================================================== */ + +/** Wie tief darf gesucht werden? Ein Vollarchiv ist verschachtelt, aber + * nicht beliebig tief. Die Grenze verhindert, dass eine kaputte Datei + * den Server beschäftigt. */ +const MAX_TIEFE = 8; +/** Mehr als so viele Listen werden nicht zur Auswahl gestellt -- wer + * fünfzig Vorschläge bekommt, liest keinen. */ +const MAX_KANDIDATEN = 8; + +/** Ein Datum aus einem beliebigen Wert. Gibt "JJJJ-MM-TT" oder null. + * + * TikTok schreibt Zeitpunkte üblicherweise als "2026-09-10 21:14:03" + * oder als ISO-Zeichenkette; Sekundenstempel kommen ebenfalls vor. + * Alle drei werden erkannt -- und alles andere ausdrücklich nicht. */ +export function tagAus(wert) { + if (wert === null || wert === undefined) return null; + if (typeof wert === "number") { + /* Sekunden oder Millisekunden? Ein Stempel in Sekunden liegt 2026 + bei rund 1,8 Milliarden, in Millisekunden bei 1,8 Billionen. + Die Grenze dazwischen ist eindeutig. */ + const ms = wert > 1e11 ? wert : wert * 1000; + const d = new Date(ms); + if (Number.isNaN(d.getTime())) return null; + const j = d.getFullYear(); + if (j < 2016 || j > 2100) return null; // TikTok gibt es seit 2016 + return d.toISOString().slice(0, 10); + } + const t = String(wert).trim(); + const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(t); + if (m) { + const j = Number(m[1]); + if (j < 2016 || j > 2100) return null; + return `${m[1]}-${m[2]}-${m[3]}`; + } + const d = /^(\d{1,2})\.(\d{1,2})\.(\d{4})/.exec(t); + if (d) return `${d[3]}-${String(d[2]).padStart(2, "0")}-${String(d[1]).padStart(2, "0")}`; + return null; +} + +/** Eine Dauer in MINUTEN aus einem beliebigen Wert. + * + * DIE EINHEIT IST DIE FALLE. "83" kann 83 Sekunden, 83 Minuten oder + * 83 Stunden heissen. Geraten wird deshalb NICHT aus der Zahl, sondern + * aus dem SCHLÜSSELNAMEN -- und wo der nichts sagt, aus der Form des + * Wertes ("01:23:45" ist eindeutig). + * + * Bleibt beides stumm, wird null zurückgegeben statt einer plausiblen + * Zahl. Eine Dauer, die um den Faktor 60 danebenliegt, sieht richtig + * aus und ist es nicht. */ +export function minutenAus(wert, schluessel = "") { + if (wert === null || wert === undefined || wert === "") return null; + const name = String(schluessel).toLowerCase(); + + const t = String(wert).trim(); + /* "01:23:45" oder "23:45" -- eindeutig, unabhängig vom Namen. */ + const uhr = /^(\d{1,3}):(\d{2})(?::(\d{2}))?$/.exec(t); + if (uhr) { + return uhr[3] !== undefined + ? Number(uhr[1]) * 60 + Number(uhr[2]) + Number(uhr[3]) / 60 + : Number(uhr[1]) + Number(uhr[2]) / 60; + } + /* "2h 15m", "2 Std 15 Min" */ + const hm = /(\d+)\s*(?:h|std|stunde)/i.exec(t); + const mm = /(\d+)\s*(?:m(?!s)|min)/i.exec(t); + if (hm || mm) return (hm ? Number(hm[1]) * 60 : 0) + (mm ? Number(mm[1]) : 0); + + const zahl = Number(String(wert).replace(/[^\d.]/g, "")); + if (!Number.isFinite(zahl)) return null; + if (/millisec|_ms\b|\bms\b/.test(name)) return zahl / 60000; + if (/sekund|second|_s\b|\bsec/.test(name)) return zahl / 60; + if (/minut|minute|_min/.test(name)) return zahl; + if (/stunde|hour/.test(name)) return zahl * 60; + return null; // lieber nichts als falsch +} + +/** Wie gut passt dieser Schlüsselname zu dem, was wir suchen? */ +const MUSTER = { + tag: /date|datum|zeit|time|start|begin/i, + dauer: /duration|dauer|laenge|length|minute|second|sekund|stunde|hour|watch|live.?time|time.?live/i, + diamanten: /diamond|diamant|coin|gift|geschenk|earn|einnahm/i, + zuschauer: /viewer|zuschauer|audience|watcher/i, +}; + +/** Findet in einem beliebigen Objekt alle Listen, die ein Datum tragen. + * + * Rückgabe je Fund: Pfad, Anzahl, Schlüssel, Vorschlag für die + * Zuordnung, Zeitraum und zwei Beispielzeilen. */ +export function kandidatenFinden(wurzel) { + const gefunden = []; + + const gehe = (knoten, pfad, tiefe) => { + if (tiefe > MAX_TIEFE || knoten === null || typeof knoten !== "object") return; + + if (Array.isArray(knoten)) { + const objekte = knoten.filter((e) => e && typeof e === "object" && !Array.isArray(e)); + /* Eine Liste mit weniger als zwei Einträgen ist kein Verlauf. + (Ein einziger LIVE wäre einer -- aber dann lohnt der ganze + Weg nicht, und die Auswahl bliebe voller Rauschen.) */ + if (objekte.length >= 2) { + const schluessel = [...new Set(objekte.flatMap((e) => Object.keys(e)))]; + /* Welcher Schlüssel trägt ein Datum? Nicht nach dem Namen + gesucht, sondern nach dem INHALT -- ein Feld namens "date", + in dem kein Datum steht, hilft niemandem. */ + let tagSchluessel = null; + let beste = 0; + for (const s of schluessel) { + const treffer = objekte.filter((e) => tagAus(e[s])).length; + /* Mindestens die Hälfte der Einträge muss ein lesbares Datum + haben -- sonst ist es ein Feld, das zufällig manchmal wie + eines aussieht. */ + if (treffer > beste && treffer >= objekte.length / 2) { + beste = treffer; tagSchluessel = s; + } + } + if (tagSchluessel) { + const passt = (art) => schluessel.find((s) => MUSTER[art].test(s) && s !== tagSchluessel) || null; + const tage = objekte.map((e) => tagAus(e[tagSchluessel])).filter(Boolean).sort(); + gefunden.push({ + pfad, anzahl: objekte.length, schluessel, + vorschlag: { + tag: tagSchluessel, + dauer: passt("dauer"), + diamanten: passt("diamanten"), + zuschauer: passt("zuschauer"), + }, + von: tage[0] || null, bis: tage[tage.length - 1] || null, + /* LIVE IM PFAD IST EIN STARKES ZEICHEN, aber kein Beweis -- + deshalb nur ein Punkt bei der Sortierung, keine + Vorentscheidung. */ + punkte: (/live/i.test(pfad) ? 10 : 0) + + (passt("dauer") ? 4 : 0) + (passt("diamanten") ? 3 : 0) + + Math.min(3, Math.floor(objekte.length / 10)), + beispiele: objekte.slice(0, 2), + }); + } + } + /* Auch IN der Liste weitersuchen -- aber nur im ersten Eintrag. + Alle durchzugehen kostet bei einem Vollarchiv Minuten und + bringt nichts Neues: Die Einträge einer Liste sind gleich + gebaut. */ + if (knoten[0] && typeof knoten[0] === "object") gehe(knoten[0], `${pfad}[0]`, tiefe + 1); + return; + } + for (const [s, w] of Object.entries(knoten)) { + gehe(w, pfad ? `${pfad}.${s}` : s, tiefe + 1); + } + }; + + gehe(wurzel, "", 0); + gefunden.sort((a, b) => b.punkte - a.punkte || b.anzahl - a.anzahl); + return gefunden.slice(0, MAX_KANDIDATEN); +} + +/** Holt die Liste an einem Pfad wieder heraus. */ +export function listeAmPfad(wurzel, pfad) { + let hier = wurzel; + for (const teil of String(pfad).split(".")) { + if (hier === null || typeof hier !== "object") return null; + const m = /^(.*)\[(\d+)\]$/.exec(teil); + if (m) { + hier = m[1] ? hier[m[1]] : hier; + if (!Array.isArray(hier)) return null; + hier = hier[Number(m[2])]; + } else { + hier = hier[teil]; + } + } + return Array.isArray(hier) ? hier : null; +} + +/** Aus den Einträgen werden TAGE. + * + * MEHRERE LIVES AN EINEM TAG SIND EIN TAG. Die Dauer wird addiert, + * die Diamanten ebenfalls, bei den Zuschauern gewinnt der höchste + * Wert -- ein Durchschnitt aus zwei Streams wäre eine Zahl, die es + * nie gegeben hat. + * + * Ein Tag mit LIVE-Zeit gilt als gültiger Tag. Das ist dieselbe Regel + * wie beim Backstage-Import; sie steht bewusst an beiden Stellen + * gleich und nicht einmal so und einmal anders. */ +export function zuTagen(eintraege, zuordnung) { + const nachTag = new Map(); + const uebersprungen = []; + + eintraege.forEach((e, i) => { + const tag = tagAus(e[zuordnung.tag]); + if (!tag) { uebersprungen.push({ nr: i + 1, grund: "kein lesbares Datum" }); return; } + const minuten = zuordnung.dauer ? minutenAus(e[zuordnung.dauer], zuordnung.dauer) : null; + const diamanten = zuordnung.diamanten ? Number(String(e[zuordnung.diamanten]).replace(/[^\d]/g, "")) : null; + const zuschauer = zuordnung.zuschauer ? Number(String(e[zuordnung.zuschauer]).replace(/[^\d]/g, "")) : null; + + const v = nachTag.get(tag) || { tag, dauer_min: null, diamanten: null, zuschauer_max: null, lives: 0 }; + v.lives++; + if (minuten !== null && Number.isFinite(minuten)) v.dauer_min = (v.dauer_min ?? 0) + minuten; + if (Number.isFinite(diamanten)) v.diamanten = (v.diamanten ?? 0) + diamanten; + if (Number.isFinite(zuschauer)) v.zuschauer_max = Math.max(v.zuschauer_max ?? 0, zuschauer); + nachTag.set(tag, v); + }); + + const tage = [...nachTag.values()] + .map((v) => ({ ...v, dauer_min: v.dauer_min === null ? null : Math.round(v.dauer_min) })) + .sort((a, b) => a.tag.localeCompare(b.tag)); + return { tage, uebersprungen }; +} diff --git a/workspace/app.webmanifest b/workspace/app.webmanifest index 75b7980c..33d95ac3 100644 --- a/workspace/app.webmanifest +++ b/workspace/app.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/workspace-192.png?v=202609111126", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609111201", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609111126", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609111201", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609111126", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609111201", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609111126", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609111201", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/leistung.css b/workspace/assets/css/leistung.css index deea4d8b..0f914675 100644 --- a/workspace/assets/css/leistung.css +++ b/workspace/assets/css/leistung.css @@ -316,3 +316,35 @@ .import-vorschau__zeile[data-art="still"] { color: var(--text-still); font-size: .8rem; } + +/* ---- Die Auswahl in der TikTok-Datei (11.09.2026) ------------------- + Das Format der Datei ist nicht dokumentiert. Der Server schlägt vor, + der Mensch entscheidet -- und eine Entscheidung gehört als sichtbare + Auswahl auf die Seite, nicht in ein Aufklappfeld, in dem man die + erste Zeile für gesetzt hält. */ +.tt-wahl { + display: flex; align-items: flex-start; gap: 10px; + margin: 6px 0; + padding: 9px 11px; + border: 1px solid var(--rand); border-radius: 11px; + background: rgba(255, 255, 255, .02); + cursor: pointer; +} +.tt-wahl:hover { border-color: var(--rand-hell); } +.tt-wahl:has(input:checked) { + border-color: color-mix(in srgb, var(--akzent) 55%, transparent); + background: color-mix(in srgb, var(--akzent) 10%, transparent); +} +.tt-wahl input { margin-top: 3px; flex: none; } +.tt-wahl__text { display: flex; flex-direction: column; gap: 2px; min-width: 0; } +.tt-wahl__text strong { + font-family: ui-monospace, "Cascadia Code", "Consolas", monospace; + font-size: .82rem; color: var(--text); + overflow-wrap: anywhere; +} +.tt-wahl__zahl { font-size: .8rem; color: var(--text-leise); } +.tt-wahl__felder { + font-size: .76rem; color: var(--text-still); + font-family: ui-monospace, "Cascadia Code", "Consolas", monospace; + overflow-wrap: anywhere; +} diff --git a/workspace/assets/js/leistung.js b/workspace/assets/js/leistung.js index d5ee30ee..854ae8da 100644 --- a/workspace/assets/js/leistung.js +++ b/workspace/assets/js/leistung.js @@ -181,6 +181,9 @@ darum, niemandem einen Knopf hinzustellen, der eine Absage bringt. */ $('netz-oeffnen').hidden = !LEITUNG.has(ich?.rolle); + /* Die Datei gehoert EINEM Creator -- deshalb sichtbar fuer alle, + die fuer ihn eintragen duerfen, also auch fuer seinen Scout. */ + $('tt-oeffnen').hidden = !darfEintragen(); if (!z || (!z.diamanten && !z.tage && !z.verweildauer)) { ziel.append(el('p', 'leistung__leer', @@ -548,6 +551,109 @@ $('tag-form').addEventListener('submit', tagSpeichern); $('tag-zu').addEventListener('click', () => $('tag-dialog').close()); $('tag-loeschen').addEventListener('click', tagLoeschen); + /* ===================================================================== + DIE TIKTOK-DATEI DES CREATORS + + Das Format ist nicht dokumentiert. Deshalb wird hier nichts + geraten: Der Server durchsucht die Datei nach Listen mit Datum und + schickt sie zur Auswahl zurück -- mit Anzahl, Zeitraum und dem, + was dabei herauskäme. Bestätigt wird von Hand. + ===================================================================== */ + let ttInhalt = null; /* die gelesene Datei -- bleibt im Browser */ + let ttWahl = null; /* der bestätigte Kandidat */ + + async function ttDateiGewaehlt(ereignis) { + const datei = ereignis.target.files?.[0]; + $('tt-fehler').textContent = ''; + $('tt-vorschau').hidden = true; + $('tt-los').disabled = true; + ttInhalt = null; ttWahl = null; + if (!datei) return; + + /* Ein Vollarchiv kann zweistellige Megabyte haben. Das Lesen + passiert im Browser; auf den Server geht es erst danach -- und + nur einmal, nicht bei jedem Klick. */ + $('tt-fehler').textContent = 'Datei wird gelesen …'; + let text; + try { text = await datei.text(); } catch { $('tt-fehler').textContent = 'Die Datei ließ sich nicht lesen.'; return; } + try { ttInhalt = JSON.parse(text); } catch { + $('tt-fehler').textContent = 'Das ist keine JSON-Datei. Beim Herunterladen bei TikTok ' + + 'muss als Format JSON gewählt werden, nicht TXT.'; + return; + } + $('tt-fehler').textContent = 'Datei wird durchsucht …'; + + const a = await hole(`/workspace/api/leistung/${offenerCreator}/tiktok-datei/vorschau`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ inhalt: ttInhalt }), + }).catch(() => null); + if (!a) { $('tt-fehler').textContent = 'Die Datei konnte nicht geprüft werden.'; return; } + const d = await a.json().catch(() => ({})); + if (!a.ok) { $('tt-fehler').textContent = d.fehler || 'Die Datei wurde nicht verstanden.'; return; } + $('tt-fehler').textContent = ''; + + const v = $('tt-vorschau'); + v.textContent = ''; v.hidden = false; + const zeile = (text2, art) => { + const p = el('p', 'import-vorschau__zeile'); + if (art) p.dataset.art = art; + p.textContent = text2; + v.append(p); + return p; + }; + + zeile(`${d.kandidaten.length} Liste(n) mit Datum gefunden. Welche ist der LIVE-Verlauf?`); + + /* Die Auswahl als echte Radioknöpfe: Es ist eine Entscheidung, und + eine Entscheidung gehört nicht in ein Aufklappfeld, in dem man + die erste Zeile für gesetzt hält. */ + d.kandidaten.forEach((k, i) => { + const box = el('label', 'tt-wahl'); + const r = document.createElement('input'); + r.type = 'radio'; r.name = 'tt-kandidat'; r.value = String(i); + if (i === 0) { r.checked = true; ttWahl = k; } + r.addEventListener('change', () => { ttWahl = k; $('tt-los').disabled = false; }); + const t = el('span', 'tt-wahl__text'); + t.append(el('strong', null, k.pfad || '(oberste Ebene)')); + t.append(el('span', 'tt-wahl__zahl', + ` ${k.anzahl} Einträge · ${k.tage_gesamt} Tage` + + (k.von ? ` · ${k.von} bis ${k.bis}` : ''))); + const felder = ['tag', 'dauer', 'diamanten', 'zuschauer'] + .filter((f) => k.vorschlag[f]) + .map((f) => `${f}: ${k.vorschlag[f]}`).join(' · '); + t.append(el('span', 'tt-wahl__felder', felder || 'nur ein Datum erkannt')); + box.append(r, t); + v.append(box); + }); + + if (ttWahl) $('tt-los').disabled = false; + } + + async function ttUebernehmen(ereignis) { + ereignis.preventDefault(); + if (!ttInhalt || !ttWahl) return; + $('tt-los').disabled = true; + const a = await hole(`/workspace/api/leistung/${offenerCreator}/tiktok-datei`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ inhalt: ttInhalt, pfad: ttWahl.pfad, zuordnung: ttWahl.vorschlag }), + }).catch(() => null); + if (!a) { $('tt-los').disabled = false; return; } + const d = await a.json().catch(() => ({})); + if (!a.ok) { + $('tt-fehler').textContent = d.fehler === 'keine_brauchbaren_tage' + ? 'In dieser Liste stehen keine Tage mit Zahlen.' + : (d.fehler || 'Übernehmen ging nicht.'); + $('tt-los').disabled = false; + return; + } + /* Die Datei loslassen, sobald sie nicht mehr gebraucht wird. */ + ttInhalt = null; ttWahl = null; + $('tt-dialog').close(); + melde(`${d.geschrieben} Tage neu, ${d.ergaenzt} ergänzt`); + setTimeout(() => melde(''), 6000); + await laden(); + } + /* ===================================================================== DIE BACKSTAGE-TABELLE — Vorschau und Übernahme ===================================================================== */ @@ -660,6 +766,14 @@ $('netz-text').focus(); }); $('netz-zu').addEventListener('click', () => $('netz-dialog').close()); + $('tt-oeffnen').addEventListener('click', () => { + $('tt-vorschau').hidden = true; $('tt-los').disabled = true; + $('tt-fehler').textContent = ''; $('tt-datei').value = ''; + $('tt-dialog').showModal(); + }); + $('tt-zu').addEventListener('click', () => $('tt-dialog').close()); + $('tt-datei').addEventListener('change', ttDateiGewaehlt); + $('tt-form').addEventListener('submit', ttUebernehmen); /* Beim EINFÜGEN sofort nachsehen -- nicht erst auf einen Knopf warten. Wer eine Tabelle einfügt, will wissen, ob sie erkannt wurde; ein zweiter Klick dazwischen ist nur eine Gelegenheit, es nicht zu tun. */ diff --git a/workspace/aufgaben.html b/workspace/aufgaben.html index 4c7b1d3a..519ce99e 100644 --- a/workspace/aufgaben.html +++ b/workspace/aufgaben.html @@ -12,19 +12,19 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - + + + + + + - +
@@ -252,10 +252,10 @@ - - - - - + + + + +