From 027c372afd3f22e8962371880519ea7130f81015 Mon Sep 17 00:00:00 2001 From: Dogfather Date: Fri, 11 Sep 2026 12:01:49 +0200 Subject: [PATCH] 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 --- server/pruef-tiktok-datei.mjs | 295 ++++++++++++++++++++++++++++++ server/workspace-leistung.js | 134 ++++++++++++++ server/workspace-tiktok-datei.js | 263 ++++++++++++++++++++++++++ workspace/app.webmanifest | 8 +- workspace/assets/css/leistung.css | 32 ++++ workspace/assets/js/leistung.js | 114 ++++++++++++ workspace/aufgaben.html | 24 +-- workspace/automation.html | 26 +-- workspace/bereich.html | 34 ++-- workspace/calls.html | 28 +-- workspace/chat.html | 26 +-- workspace/content.html | 30 +-- workspace/crew-index.html | 10 +- workspace/crew.webmanifest | 8 +- workspace/dateien.html | 26 +-- workspace/index.html | 8 +- workspace/kalender.html | 28 +-- workspace/leistung.html | 69 +++++-- workspace/personen.html | 26 +-- workspace/profil.html | 28 +-- workspace/report.html | 28 +-- workspace/scouting.html | 26 +-- workspace/start.html | 24 +-- workspace/startcheck.html | 28 +-- workspace/steckbrief.html | 26 +-- workspace/team.html | 26 +-- workspace/teamlage.html | 24 +-- workspace/uebersicht.html | 26 +-- workspace/wissen.html | 26 +-- 29 files changed, 1166 insertions(+), 285 deletions(-) create mode 100644 server/pruef-tiktok-datei.mjs create mode 100644 server/workspace-tiktok-datei.js 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 @@ - - - - - + + + + + diff --git a/workspace/automation.html b/workspace/automation.html index 51c6c4b2..7cb0a290 100644 --- a/workspace/automation.html +++ b/workspace/automation.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -147,10 +147,10 @@ - - - - - + + + + + diff --git a/workspace/bereich.html b/workspace/bereich.html index d1e66b9e..2a4c60dd 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -181,14 +181,14 @@ - - - - - - - - - + + + + + + + + + diff --git a/workspace/calls.html b/workspace/calls.html index c18d208e..6a62b645 100644 --- a/workspace/calls.html +++ b/workspace/calls.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -89,11 +89,11 @@ - - - - - - + + + + + + diff --git a/workspace/chat.html b/workspace/chat.html index 7b6721fa..de6b4c0d 100644 --- a/workspace/chat.html +++ b/workspace/chat.html @@ -12,26 +12,26 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -289,10 +289,10 @@ - - - - - + + + + + diff --git a/workspace/content.html b/workspace/content.html index e09ddfaa..72266fc3 100644 --- a/workspace/content.html +++ b/workspace/content.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -179,12 +179,12 @@ - - - - - - - + + + + + + + diff --git a/workspace/crew-index.html b/workspace/crew-index.html index 24398c4d..ace99cb3 100644 --- a/workspace/crew-index.html +++ b/workspace/crew-index.html @@ -30,15 +30,15 @@ - - - + + + - + @@ -252,6 +252,6 @@ - + diff --git a/workspace/crew.webmanifest b/workspace/crew.webmanifest index 18aa36d8..ed61a754 100644 --- a/workspace/crew.webmanifest +++ b/workspace/crew.webmanifest @@ -13,25 +13,25 @@ "theme_color": "#06090f", "icons": [ { - "src": "/assets/img/app-symbole/crew-192.png?v=202609111126", + "src": "/assets/img/app-symbole/crew-192.png?v=202609111201", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-512.png?v=202609111126", + "src": "/assets/img/app-symbole/crew-512.png?v=202609111201", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609111126", + "src": "/assets/img/app-symbole/crew-192-maskable.png?v=202609111201", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609111126", + "src": "/assets/img/app-symbole/crew-512-maskable.png?v=202609111201", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/dateien.html b/workspace/dateien.html index 266d9811..55c463b4 100644 --- a/workspace/dateien.html +++ b/workspace/dateien.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -108,10 +108,10 @@ - - - - - + + + + + diff --git a/workspace/index.html b/workspace/index.html index 7d89406d..eea51bb2 100644 --- a/workspace/index.html +++ b/workspace/index.html @@ -30,9 +30,9 @@ - - - + + + @@ -401,6 +401,6 @@ - + diff --git a/workspace/kalender.html b/workspace/kalender.html index 51afb6ee..6ea7667d 100644 --- a/workspace/kalender.html +++ b/workspace/kalender.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -377,11 +377,11 @@ - - - - - - + + + + + + diff --git a/workspace/leistung.html b/workspace/leistung.html index f953fc5c..f0459bba 100644 --- a/workspace/leistung.html +++ b/workspace/leistung.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -115,6 +115,12 @@ + +
@@ -225,6 +231,43 @@ jede Zeile zu einer anderen Person gehört – die Zuordnung ist die eigentliche Arbeit, und die will man sehen, bevor geschrieben wird. ==================================================================== --> + + +
+

TikTok-Datei einlesen

+

+ Die Datei, die der Creator sich selbst bei TikTok herunterlädt: + Profil → Einstellungen → Konto → Deine Daten herunterladen, + Format JSON, „Alles auswählen“. TikTok braucht dafür + ein bis vier Tage. Die Datei wird hier gelesen und nicht + gespeichert – in ihr stehen auch Nachrichten und Verläufe, + die hier nichts zu suchen haben. +

+ +
+ + +
+ + + + + +
+ + +
+
+
+

Backstage-Tabelle einfügen

@@ -282,10 +325,10 @@
- - - - - + + + + + diff --git a/workspace/personen.html b/workspace/personen.html index 3db7e41d..3293d2c4 100644 --- a/workspace/personen.html +++ b/workspace/personen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -396,10 +396,10 @@ - - - - - + + + + + diff --git a/workspace/profil.html b/workspace/profil.html index 6d3ed6e9..b7df5bca 100644 --- a/workspace/profil.html +++ b/workspace/profil.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -214,11 +214,11 @@ - - - - - - + + + + + + diff --git a/workspace/report.html b/workspace/report.html index 0173b403..0b46b296 100644 --- a/workspace/report.html +++ b/workspace/report.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -106,11 +106,11 @@ - - - - - - + + + + + + diff --git a/workspace/scouting.html b/workspace/scouting.html index 5412f2bb..f5c5addb 100644 --- a/workspace/scouting.html +++ b/workspace/scouting.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -173,10 +173,10 @@ - - - - - + + + + + diff --git a/workspace/start.html b/workspace/start.html index c4d4c016..0ca3b056 100644 --- a/workspace/start.html +++ b/workspace/start.html @@ -12,21 +12,21 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - + + - + @@ -674,10 +674,10 @@

- - - - - + + + + + diff --git a/workspace/startcheck.html b/workspace/startcheck.html index cc1f728f..e817344c 100644 --- a/workspace/startcheck.html +++ b/workspace/startcheck.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -85,11 +85,11 @@ - - - - - - + + + + + + diff --git a/workspace/steckbrief.html b/workspace/steckbrief.html index 1d054ab0..61af8913 100644 --- a/workspace/steckbrief.html +++ b/workspace/steckbrief.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -126,10 +126,10 @@ - - - - - + + + + + diff --git a/workspace/team.html b/workspace/team.html index 8a456012..bb8a49a9 100644 --- a/workspace/team.html +++ b/workspace/team.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + @@ -110,10 +110,10 @@ - - - - - + + + + + diff --git a/workspace/teamlage.html b/workspace/teamlage.html index 0ce46a79..070194e7 100644 --- a/workspace/teamlage.html +++ b/workspace/teamlage.html @@ -7,20 +7,20 @@ - - - - - - - + + + + + + + - + - - - - + + + + diff --git a/workspace/uebersicht.html b/workspace/uebersicht.html index 46a32583..29999326 100644 --- a/workspace/uebersicht.html +++ b/workspace/uebersicht.html @@ -12,22 +12,22 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - + + + + - - - + + + - + @@ -149,11 +149,11 @@ - + - - - - + + + + diff --git a/workspace/wissen.html b/workspace/wissen.html index 91eb0ad9..6ec0e70b 100644 --- a/workspace/wissen.html +++ b/workspace/wissen.html @@ -12,20 +12,20 @@ Ton wie die Kopfleiste darunter (#06090f), damit Leiste und Seite eine Flaeche sind statt zweier. --> - - - - - - - + + + + + + + - + @@ -190,10 +190,10 @@ - - - - - + + + + +