diff --git a/server/pruef-backstage-import.mjs b/server/pruef-backstage-import.mjs index 72897215..fc65afd6 100644 --- a/server/pruef-backstage-import.mjs +++ b/server/pruef-backstage-import.mjs @@ -286,6 +286,30 @@ console.log("\n=== Gegenproben ==="); ok(status === 200, `heute als Tag geht dagegen durch (HTTP ${status})`); } +/* DIE SCHNITTSTELLE ZUR TIKTOK-DATEI GIBT ES NICHT MEHR (11.09.2026). + + Filipe: "ich will keine daten von denen kriegen, nur wir." + + Ein ausgebauter Knopf ist keine Entfernung -- die Schnittstelle + waere weiterhin erreichbar, und wer die Adresse kennt, koennte + weiter eine Creator-Datei hochladen. Gemessen wird deshalb hier, + mit DogFathers Zugang, also dem staerksten, den es gibt: Bekommt + ER 404, bekommt ihn jeder. + + GEGENPROBE DANEBEN: Der Backstage-Weg antwortet weiterhin. Sonst + bewiese das 404 nur, dass ich mich vertippt habe. */ +for (const weg of ["/workspace/api/leistung/1/tiktok-datei", + "/workspace/api/leistung/1/tiktok-datei/vorschau"]) { + const { status } = await ruf(weg, keksDogi, { inhalt: {} }); + ok(status === 404, `${weg.split("/").slice(-2).join("/")} gibt es nicht mehr (HTTP ${status})`); +} +{ + const { status } = await ruf("/workspace/api/leistung/netzwerk-import/vorschau", + keksDogi, { text: TABELLE, tag: gestern }); + ok(status === 200, + `Gegenprobe: der Backstage-Weg antwortet weiterhin (HTTP ${status})`); +} + /* WER SIEHT WEN -- direkt an der Schnittstelle gemessen, bevor der Browser ins Spiel kommt. Wird der Auswahlliste auf der Seite etwas fehlen, steht hier, ob es schon hier fehlte. */ @@ -370,21 +394,29 @@ console.log("\n=== Die Seite ==="); const satz = (id) => seite.$eval(id, (e) => (e.hidden ? "" : e.textContent)) .catch(() => ""); const netzSatz = await satz("#quelle-netz"); - const ttSatz = await satz("#quelle-tt"); - ok(/Backstage/.test(netzSatz) && /TikTok-Datei/.test(ttSatz), - "unter den Knöpfen steht dauerhaft, was die beiden Quellen sind"); + ok(/Backstage/.test(netzSatz), + "unter den Knöpfen steht dauerhaft, woher die Zahlen kommen"); - /* Und der fertige Text für den Creator -- mit den zwei Angaben, an - denen es sonst scheitert: JSON und "alle Daten". */ - await seite.click("#tt-oeffnen"); - await seite.waitForTimeout(300); - const fuerCreator = await seite.$eval("#tt-text", (e) => e.textContent); - ok(/JSON/.test(fuerCreator) && /Alle Daten/i.test(fuerCreator), - "der Text für den Creator nennt JSON und „Alle Daten“"); - ok(/Einstellungen und Datenschutz/.test(fuerCreator) && /Deine Daten herunterladen/.test(fuerCreator), - "und den Weg in der App"); - ok(/nicht gespeichert/.test(fuerCreator), - "und was mit der Datei passiert -- das gehört dem Creator gesagt"); + /* ---- DER WEG UEBER DIE TIKTOK-DATEI IST WEG (11.09.2026) -------- + Filipe: "ich will keine daten von denen kriegen, nur wir." + + GEPRUEFT WIRD DIE ABWESENHEIT, und zwar an DREI Stellen -- sonst + hiesse "der Knopf ist weg" nur, dass er versteckt ist. Ein + versteckter Weg ist ein Weg: Wer die Adresse kennt, benutzt ihn. + Deshalb muss auch die Schnittstelle verschwunden sein. + + Und der Text, der einen Creator um seine Daten bittet, darf + nirgends mehr auf der Seite stehen -- auch nicht in einem + Fenster, das niemand oeffnet. */ + const spuren = await seite.evaluate(() => ({ + knopf: !!document.getElementById("tt-oeffnen"), + fenster: !!document.getElementById("tt-dialog"), + text: document.body.textContent.includes("Deine Daten herunterladen"), + })); + ok(spuren.knopf === false, "der Knopf „TikTok-Datei des Creators\" ist weg"); + ok(spuren.fenster === false, "und sein Fenster auch"); + ok(spuren.text === false, + "und die Bitte an den Creator steht nirgends mehr auf der Seite"); /* ---- DIESELBE SEITE, ANDERE ROLLE (11.09.2026) ----------------- Filipe, mit der Kachel im Bild: "jetzt soll jede rolle diese @@ -434,9 +466,9 @@ console.log("\n=== Die Seite ==="); return { weg: location.pathname, kacheln: 0, - import: h("import-oeffnen"), netz: h("netz-oeffnen"), tt: h("tt-oeffnen"), + import: h("import-oeffnen"), netz: h("netz-oeffnen"), ziele: h("ziele-aendern"), - block: h("quellen-hinweis"), sNetz: h("quelle-netz"), sTt: h("quelle-tt"), + block: h("quellen-hinweis"), sNetz: h("quelle-netz"), /* Wie viele Creator stehen zur Auswahl? Ein Creator darf hier kein Auswahlfeld haben -- es haette nur ihn selbst drin. */ wahl: document.getElementById("creator-wahl")?.hidden === false @@ -461,12 +493,12 @@ console.log("\n=== Die Seite ==="); ok(scoutBild.kachel === true && /leistung\.html$/.test(scoutBild.weg), `der Scout findet die Kachel und kommt auf die Seite (${scoutBild.weg})`); ok(scoutBild.fehler === "", `kein Skriptfehler beim Scout (${scoutBild.fehler || "keiner"})`); - ok(scoutBild.tt === false && scoutBild.import === false && scoutBild.ziele === false, - "der Scout darf eintragen: Datei, TikTok-Datei und Ziele sind da"); + ok(scoutBild.import === false && scoutBild.ziele === false, + "der Scout darf eintragen: eigene Datei und Ziele sind da"); ok(scoutBild.netz === true, "die Backstage-Tabelle bleibt ihm zu -- sie gilt für ALLE Creator"); - ok(scoutBild.sTt === false && scoutBild.sNetz === true, - "und er liest nur die Erklärung zu dem Weg, den er auch hat"); + ok(scoutBild.sNetz === true && scoutBild.block === true, + "die Backstage-Erklärung bekommt er nicht -- den Weg hat er nicht"); ok(scoutBild.aendern > 0, `er kann einen Tag eintragen (${scoutBild.aendern} Knöpfe in der Liste)`); @@ -486,10 +518,10 @@ console.log("\n=== Die Seite ==="); ok(b.kachel === true && /leistung\.html$/.test(b.weg), `${rolle}: findet die Kachel und kommt auf die Seite`); ok(b.fehler === "", `${rolle}: kein Skriptfehler (${b.fehler || "keiner"})`); - ok(b.netz === false && b.tt === false && b.import === false && b.ziele === false, - `${rolle}: alle vier Wege sind da -- auch die Backstage-Tabelle`); - ok(b.sNetz === false && b.sTt === false, - `${rolle}: und beide Erklärungen dazu`); + ok(b.netz === false && b.import === false && b.ziele === false, + `${rolle}: alle drei Wege sind da -- auch die Backstage-Tabelle`); + ok(b.sNetz === false, + `${rolle}: und die Erklärung dazu`); ok(b.wahl >= mindestens, `${rolle}: sieht ${b.wahl} Creator zur Auswahl (mindestens ${mindestens})`); } @@ -508,7 +540,7 @@ console.log("\n=== Die Seite ==="); `der Creator findet die Kachel und kommt auf die Seite (${creatorBild.weg})`); ok(creatorBild.fehler === "", `kein Skriptfehler beim Creator (${creatorBild.fehler || "keiner"})`); ok(creatorBild.import === true && creatorBild.netz === true - && creatorBild.tt === true && creatorBild.ziele === true, + && creatorBild.ziele === true, "der Creator sieht KEINEN einzigen Eintrag-Knopf"); ok(creatorBild.aendern === 0, `auch nicht an den einzelnen Tagen (${creatorBild.aendern} Knöpfe)`); diff --git a/server/pruef-leistung-optik.mjs b/server/pruef-leistung-optik.mjs index 30eba385..a1130402 100644 --- a/server/pruef-leistung-optik.mjs +++ b/server/pruef-leistung-optik.mjs @@ -167,19 +167,31 @@ const chef = await alsRolle("admin", "CODE-DOGI-0001", "start.html"); k.some((x) => x.querySelector(".kachel__name")?.textContent.trim() === "Zahlenzauber")); ok(!erfunden, "eine erfundene Kachel wird nicht gefunden"); - /* GEGENPROBE ZUR REGEL SELBST: Der Manager darf sie NICHT sehen und - NICHT hineinkommen. Ohne diese beiden Zeilen bewiese der Block - oben nur, dass DogFather etwas sieht -- nicht, dass sonst niemand - es sieht. Und genau das war die Anweisung. */ + /* GEDREHT AM 11.09.2026, NICHT GELOESCHT. + + Hier stand das Gegenteil: "beim Manager fehlt sie" und "die Seite + laesst ihn nicht herein". Das war richtig nach dem Wunsch vom + 07.09. ("nimm die kategorie zahlen bei jedem weg"). Filipe hat ihn + am 11.09. aufgehoben: "jetzt soll jede rolle diese kachel sehen. + spicy, dogfather, manager, scout und creator." + + Eine geloeschte Pruefung senkt die Zahl und beweist nichts. Eine + gedrehte haelt fest, dass die Oeffnung eine ENTSCHEIDUNG war -- + und schlaegt an, wenn jemand die Sperre versehentlich wieder + einbaut. + + BEIDE SCHICHTEN, wie vorher: die Kachel (der Weg) UND die Seite + (die Schranke). Nur eine von beiden zu messen, hat am 10.09. schon + einmal eine dritte, unbemerkte Schicht stehen lassen. */ const mgr = await alsRolle("manager", "CODE-MANA-0001", "start.html"); const beiIhm = await mgr.seite.$$eval(".kachel", (k) => k.map((x) => x.querySelector(".kachel__name")?.textContent.trim())); - ok(beiIhm.length > 3 && !beiIhm.includes("Zahlen"), - `beim Manager fehlt sie (${beiIhm.length} Kacheln, keine davon "Zahlen")`); + ok(beiIhm.length > 3 && beiIhm.includes("Zahlen"), + `der Manager sieht die Kachel wieder (${beiIhm.length} Kacheln)`); const versuch = await mgr.seite.evaluate(async (b) => (await fetch(b + "/workspace/leistung.html", { redirect: "manual" })).status, BASIS); - ok(versuch !== 200, `und die Seite selbst laesst ihn nicht herein (HTTP ${versuch})`); + ok(versuch === 200, `und die Seite laesst ihn herein (HTTP ${versuch})`); await mgr.ctx.close(); await chef.seite.goto(`${BASIS}/workspace/leistung.html`, { waitUntil: "networkidle" }); @@ -421,21 +433,47 @@ melde("\n=== Aus Datei einlesen ==="); melde("\n=== Was der Creator sieht ==="); const luna = await alsRolle("creator", "CODE-CREA-0001", "start.html"); { - /* HIER STAND DAS GEGENTEIL, UND ES WAR RICHTIG -- bis Filipe die - Regel geändert hat: "nimm die kategorie zahlen bei jedem weg, nur - die rolle dogfather soll die sehen." Vorher sah Luna ihre eigenen - Zahlen (nur ohne Eintragen-Knopf), jetzt kommt sie gar nicht mehr - hinein. Die Prüfung misst deshalb nicht mehr, WAS sie dort sieht, - sondern DASS sie nicht hineinkommt -- und dass die Schranke am - Server sitzt, nicht bloß in der Oberfläche. */ - const keineKachel = await luna.seite.$$eval(".kachel", (k) => + /* ZWEIMAL GEDREHT, UND BEIDE MALE AUF ANSAGE. + + Ursprünglich sah Luna ihre eigenen Zahlen (ohne Eintragen-Knopf). + Am 07.09. kam sie gar nicht mehr hinein ("nimm die kategorie + zahlen bei jedem weg"). Seit dem 11.09. gilt wieder die erste + Fassung, und zwar wörtlich: "die creator kriegen dan quasi nur + ihre daten zu sehen und der manager scout dogfather oder spicy + können eintragen." + + Gemessen wird deshalb BEIDES -- dass sie hineinkommt UND dass sie + dort nichts eintragen kann. Nur das erste zu prüfen hieße, eine + offene Tür für eine Erlaubnis zu halten. */ + const kacheln = await luna.seite.$$eval(".kachel", (k) => k.map((x) => x.querySelector(".kachel__name")?.textContent.trim())); - ok(keineKachel.length > 2 && !keineKachel.includes("Zahlen"), - `bei Luna fehlt die Kachel "Zahlen" (${keineKachel.length} Kacheln)`); + ok(kacheln.length > 2 && kacheln.includes("Zahlen"), + `Luna sieht die Kachel "Zahlen" wieder (${kacheln.length} Kacheln)`); const rein = await luna.seite.evaluate(async () => (await fetch("/workspace/leistung.html", { credentials: "same-origin" })).redirected); - ok(rein === true, "und die Seite selbst weist sie ab (Umleitung)"); + ok(rein === false, "und die Seite laesst sie herein (keine Umleitung)"); + + /* UND DORT: ihre Zahlen, kein Knopf. Beides zusammen, sonst sagt es + nichts -- "keine Knoepfe" waere auch bei einer leeren Seite wahr. */ + await luna.seite.goto(`${BASIS}/workspace/leistung.html`, { waitUntil: "networkidle" }); + await luna.seite.waitForTimeout(1600); + const lage = await luna.seite.evaluate(() => { + const h = (id) => document.getElementById(id)?.hidden !== false; + return { + knoepfe: ["import-oeffnen", "netz-oeffnen", "ziele-aendern"].filter((i) => !h(i)), + tagKnoepfe: document.querySelectorAll(".tagzeile__knopf").length, + wahl: document.getElementById("creator-wahl")?.hidden === false, + inhalt: (document.getElementById("tagetabelle")?.textContent || "").length, + }; + }); + ok(lage.knoepfe.length === 0 && lage.tagKnoepfe === 0, + `sie kann dort nichts eintragen (${lage.knoepfe.join(", ") || "kein Knopf"}, ` + + `${lage.tagKnoepfe} Tag-Knöpfe)`); + ok(lage.wahl === false, + "und hat kein Auswahlfeld für fremde Creator -- sie sieht nur sich"); + ok(lage.inhalt > 20, + `ihre eigenen Zahlen stehen aber da (${lage.inhalt} Zeichen)`); /* DIE SCHRANKE SITZT AM SERVER, nicht in der Oberfläche. Ein versteckter Knopf ist keine Sicherung. */ diff --git a/server/pruef-tiktok-datei.mjs b/server/pruef-tiktok-datei.mjs deleted file mode 100644 index 545503eb..00000000 --- a/server/pruef-tiktok-datei.mjs +++ /dev/null @@ -1,295 +0,0 @@ -/* ===================================================================== - 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 165bb3ab..2590eabb 100644 --- a/server/workspace-leistung.js +++ b/server/workspace-leistung.js @@ -47,7 +47,6 @@ import { db, protokolliere, echteIp, sitzungLesen, istLeitung, sichtbareCreatorIds, ROLLEN_SORTIERUNG, heuteLokal, } from "./workspace.js"; -import { kandidatenFinden, listeAmPfad, zuTagen } from "./workspace-tiktok-datei.js"; export const leistungRouter = express.Router(); @@ -870,137 +869,28 @@ leistungRouter.post("/workspace/api/leistung/netzwerk-import", gleicheHerkunft, }); /* ===================================================================== - DIE EIGENE TIKTOK-DATEI EINES CREATORS (11.09.2026) + DER WEG UEBER DIE TIKTOK-DATEI IST ENTFERNT (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. + Gebaut am 11.09. vormittags, am selben Tag wieder ausgebaut. Filipe, + mit den drei Knoepfen im Bild: *"ich will nur dass wir manager + scouts, dogfather oder spicy sachen eintragen koennen und nicht die + creator. ich will keine daten von denen kriegen, nur wir."* - Das Suchen und Umrechnen steht in workspace-tiktok-datei.js; hier - stehen nur die beiden Wege und die Rechte. + Der Weg war technisch einwandfrei -- und er war der einzige, bei dem + ein CREATOR uns etwas gibt: seine eigene Datenkopie aus der + TikTok-App. Genau das soll nicht sein. Es bleiben die beiden + Wir-Wege: die eigene Datei und die Backstage-Tabelle der Agentur. - 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. + VOLLSTAENDIG ENTFERNT, nicht nur versteckt: Schnittstellen, Leser + (workspace-tiktok-datei.js), Knopf, Fenster und der fertige Text an + den Creator. Ein Weg, der nur unsichtbar ist, ist weiterhin da -- + wer die Adresse kennt, benutzt ihn. Und ein Text, der einen Creator + um seine Daten bittet, soll in diesem Haus nirgends mehr stehen. + + WAS WIR DAMIT NICHT MEHR WISSEN: nichts, was Backstage nicht auch + sagt. Die Datei war die zweite Quelle (COALESCE), nie die erste. ===================================================================== */ -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, diff --git a/server/workspace-tiktok-datei.js b/server/workspace-tiktok-datei.js deleted file mode 100644 index 1ea8512d..00000000 --- a/server/workspace-tiktok-datei.js +++ /dev/null @@ -1,263 +0,0 @@ -/* ===================================================================== - 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 a2dd22df..91886021 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=202609111404", + "src": "/assets/img/app-symbole/workspace-192.png?v=202609111501", "sizes": "192x192", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-512.png?v=202609111404", + "src": "/assets/img/app-symbole/workspace-512.png?v=202609111501", "sizes": "512x512", "type": "image/png", "purpose": "any" }, { - "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609111404", + "src": "/assets/img/app-symbole/workspace-192-maskable.png?v=202609111501", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609111404", + "src": "/assets/img/app-symbole/workspace-512-maskable.png?v=202609111501", "sizes": "512x512", "type": "image/png", "purpose": "maskable" diff --git a/workspace/assets/css/leistung.css b/workspace/assets/css/leistung.css index 0a5792b2..b8ca5faa 100644 --- a/workspace/assets/css/leistung.css +++ b/workspace/assets/css/leistung.css @@ -317,38 +317,6 @@ 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; -} - /* ===================================================================== DIE ANLEITUNGEN (11.09.2026) @@ -387,17 +355,4 @@ .anleitung li::marker { color: var(--akzent); font-weight: 700; } .anleitung strong { color: var(--text); } -/* Der fertige Text zum Weiterschicken. Als
, damit die Nummerierung
- stehen bleibt -- und mit Umbruch, damit er nicht seitlich herausläuft. */
-.anleitung-text {
- margin: 4px 0 8px;
- max-height: 210px; overflow-y: auto;
- padding: 11px 13px;
- border: 1px solid var(--rand); border-radius: 11px;
- background: rgba(0, 0, 0, .28);
- font-family: inherit; font-size: .81rem; line-height: 1.55;
- color: var(--text-leise);
- white-space: pre-wrap; overflow-wrap: anywhere;
-}
.dialog__unter--still { font-size: .8rem; color: var(--text-still); }
-.dialog__knoepfe--links { justify-content: flex-start; margin: 0 0 10px; }
diff --git a/workspace/assets/js/leistung.js b/workspace/assets/js/leistung.js
index 7ac8ce18..70e2ab9a 100644
--- a/workspace/assets/js/leistung.js
+++ b/workspace/assets/js/leistung.js
@@ -197,24 +197,24 @@
hier geht es darum, niemandem einen Knopf hinzustellen, der
eine Absage bringt. */
['netz-oeffnen', 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', darfEintragen()],
];
for (const [knopf, darf] of KNOEPFE) $(knopf).hidden = !darf;
/* Jeder Erklaersatz haengt an SEINEM Knopf, nicht am Block. Ein
- Scout sieht nur die TikTok-Datei -- ihm die Backstage-Tabelle zu
- erklaeren, an die er gar nicht kommt, ist keine Hilfe, sondern
- eine Absage, die er nicht gesucht hat. Und ein Creator sieht
- keinen von beiden, also auch keinen Satz. Abgeleitet statt
- zweimal aufgeschrieben: kommt ein dritter Weg dazu, stimmt es
- weiter. */
- for (const [satz, knopf] of [['quelle-netz', 'netz-oeffnen'],
- ['quelle-tt', 'tt-oeffnen']]) {
+ Scout, der die Backstage-Tabelle nicht hat, soll sie auch nicht
+ erklaert bekommen -- das waere keine Hilfe, sondern eine Absage,
+ die er nicht gesucht hat. Und ein Creator sieht keinen Knopf,
+ also auch keinen Satz.
+
+ Die Liste hat seit dem 11.09.2026 nur noch einen Eintrag (der
+ Weg ueber die TikTok-Datei des Creators ist entfernt). Sie
+ bleibt trotzdem eine Liste: Der naechste Weg haengt sich dann
+ daneben, statt dass jemand die Regel ein zweites Mal
+ aufschreibt. */
+ for (const [satz, knopf] of [['quelle-netz', 'netz-oeffnen']]) {
$(satz).hidden = $(knopf).hidden;
}
- $('quellen-hinweis').hidden = $('quelle-netz').hidden && $('quelle-tt').hidden;
+ $('quellen-hinweis').hidden = $('quelle-netz').hidden;
if (!z || (!z.diamanten && !z.tage && !z.verweildauer)) {
ziel.append(el('p', 'leistung__leer',
@@ -582,109 +582,6 @@
$('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
===================================================================== */
@@ -797,26 +694,6 @@
$('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);
- /* Den Text fuer den Creator in die Zwischenablage. Kein
- Erfolgshaken ohne Beleg: Geht es schief (kein Recht, alter
- Browser), steht das auch da -- sonst glaubt man, es sei kopiert,
- und schickt eine leere Nachricht. */
- $('tt-text-kopieren').addEventListener('click', async (e) => {
- const knopf = e.currentTarget;
- try {
- await navigator.clipboard.writeText($('tt-text').textContent);
- knopf.textContent = 'Kopiert';
- } catch { knopf.textContent = 'Ging nicht - bitte von Hand markieren'; }
- setTimeout(() => { knopf.textContent = 'Text kopieren'; }, 2600);
- });
- $('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 d522dbc8..8d4b579b 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 @@
-
-
-
-
-
+
+
+
+
+