Files
dogfather-universe/server/workspace-tiktok-datei.js
T
DogFatherGitandClaude Opus 5 027c372afd 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 <[email protected]>
2026-09-11 12:01:49 +02:00

264 lines
12 KiB
JavaScript

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