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