Files
dogfather-universe/server/workspace-kampagne.js
T
DogFatherGitandClaude Opus 5 2764aea61c Kampagnen: auswaehlen, wo die Kachel hingehoert -- TikTok oder Agentur
Filipe, mit einem Bild des Einlesekastens: "das sind die von der
agentur selbst. die krieg ich so, perfektioniere das und auch das ich
auswaehlen kann wo es hingehoert, zu tiktok oder der agentur." Dazu
sein echter Link: https://vm.tiktok.com/ZSbACwAnK/

ZWEI FRAGEN, DIE BISHER EINE WAREN. "Woher kam der Link" und "wem
gehoert das Event" wurden beide aus `kampagne_id` beantwortet: Wer
ueber eine Einladung eingelesen wurde, stand links. Die Agentur
schickt ihre eigenen Events aber ebenfalls als TikTok-Link -- damit
war die Zuordnung zwangslaeufig falsch.

Neue Spalte `kampagne_seite` mit DREI Zustaenden, und NULL ist einer
davon: niemand hat gewaehlt -> wie bisher nach der Herkunft. Alle
bestehenden Kacheln behalten dadurch genau ihren Platz, ohne dass
etwas umgestellt werden musste. Gewaehlt wird im Einlesekasten
(Automatisch / TikTok / Agentur, "Automatisch" vorbelegt) und
nachtraeglich an der Kachel ("zur Agentur" / "zu TikTok"). Nur die
Leitung; bei einem Creator wird das Feld stillschweigend entfernt.

GEMESSEN AN SEINEM ECHTEN LINK, mit Gegenprobe:
  vm.tiktok.com/ZSbACwAnK/
    -> www.tiktok.com/tcn/activity?activity_id=7691301069758087175
    -> 233 KB reine Huelle, kein __MODERN_ROUTER_DATA__
  dieselbe Nummer auf dem Weg der oeffentlichen Kampagnenseite
    -> 42 KB, ebenfalls ohne Datenpaket
  eine echte Kampagne auf demselben Weg
    -> 967 KB MIT Datenpaket
Die Kampagne existiert also nicht als oeffentliche Seite: Das Creator
Network baut seine Seiten erst im Browser auf, angemeldet. Von aussen
kann das niemand lesen -- auch kein anderes Programm.

Daraus zwei Dinge:
  - `nummerAusAdresse` liest jetzt BEIDE Schreibweisen (`activityId`
    auf der Kampagnenseite, `activity_id` im Creator Network).
  - `istCreatorNetwork` erkennt solche Links VOR dem Abruf. Statt 502
    "TikTok hat nicht geantwortet" (eine Ursache, die es nicht gibt)
    kommt 422 mit dem, was wirklich los ist -- und mit dem Weg
    daneben: das Event von Hand anlegen, Link in die Beschreibung.

BERICHTIGT: Der PATCH-Weg stand als '/workspace/api/bereich/agentur/'
+ e.id im Browser und war fuer pruef-struktur nicht mehr zuzuordnen
-- jetzt `${bereich}` wie bei den Nachbarn, was nebenbei den zweiten
Ort mit "agentur" im Text wegnimmt. Und "Automatisch" hatte keinen
eigenen Ton und fiel auf `--akzent` zurueck: auf dem Agenturbrett
genau das Gelb der rechten Spalte, die vorbelegte Moeglichkeit sah
also aus wie "Agentur".

GEPRUEFT: 169 (Route, +22), 170 (Kachel, +12), 67 (Agentur), 67
Messungen (Bild), 107 (Haus-Trennung, unveraendert), dazu struktur,
css-klassen, zeichen, deutsche-texte, formulare, tippziele. Drei
Gegenproben: Auswahl verwerfen, Creator-Network durchlassen, Kachel
wieder nur nach Herkunft -- alle drei werden bemerkt, alle Dateien
byte-gleich wiederhergestellt.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-07 23:46:41 +02:00

645 lines
28 KiB
JavaScript

/* =====================================================================
EINE KAMPAGNEN-KACHEL AUS EINEM TIKTOK-LINK (07.10.2026) — Etappe 3
Haus: workspace.dogfather-universe.com (Agentur / Spicy Media).
Team Dogi bleibt unberührt — `hausFuerNeuenEintrag` entscheidet das,
nicht diese Datei.
Der Auftrag in einem Satz: Im Agentur-Bereich gibt es ein Feld, in
das die GANZE Einladungsnachricht eingefügt wird — und daraus
entsteht eine fertige Agentur-Event-Kachel mit Titel, Zeitraum,
Banner, Aufgaben zum Abhaken, Regeln, Ligen und Preisen. Ohne einen
einzigen Handgriff danach.
Eingabe ist genau das, was TikTok schickt, mitsamt dem Fließtext:
Dein Creator Network lädt dich ein, an einer Kampagne
teilzunehmen. Klicke hier, um weitere Informationen zu
erhalten: https://www.tiktok.com/t/ZSbqPsFa8/
---------------------------------------------------------------------
HIER WIRD WENIG ERFUNDEN
Die Kachel gibt es schon: Bereich `agentur`, Art `kampagne`, Tabelle
`eintraege` mit `event_ende`, `event_aufgaben`, `event_regeln`,
Haken über `event_punkte`, Banner über `dateien.eintrag_id`. Diese
Datei füllt vorhandene Felder — sie baut keine neuen.
`workspace-bereiche.js` wird NICHT angefasst. Fiele beim Bauen auf,
dass doch etwas nötig ist, wäre das ein Zeichen, dass die Abbildung
nicht passt — dann lieber die Abbildung ändern als die
Bereichslogik.
Gelesen wird in `kampagne-lesen.mjs` (kein Netz, keine Datenbank),
geholt wird mit `helfer-tiktok.mjs` (kein Schreiben). Hier steht nur
die Reihenfolge, die Rechtefrage und das Schreiben.
---------------------------------------------------------------------
DIE REIHENFOLGE — und warum sie so ist
1. Link aus dem Text holen ohne Netz, ohne Datenbank
2. Bremse VOR dem ersten Abruf nach draußen
3. Umleitung Station für Station jede Zwischenadresse geprüft
4. Kampagnennummer erst aus der Adresse (billig)
5. Dublettenprüfung spart die ganze Seite
6. Seite holen 12 s, 3 MB beim Lesen
7. Lesen drei Ausgänge
8. Banner herunterladen eigene Frist, Byteprüfung
9. Schreiben in EINER Transaktion
10. Protokoll
Die Bremse steht an Stelle 2 und nicht an Stelle 9: Eine Bremse, die
erst nach dem Abruf greift, bremst den fremden Server nicht.
Die Dublettenprüfung steht zweimal da — einmal hier (Stelle 5, fängt
den Normalfall) und einmal als eindeutiger Index in der Datenbank
(fängt den Wettlauf zweier gleichzeitiger Aufrufe). Beides, nicht
eins davon.
---------------------------------------------------------------------
JEDER FEHLSCHLAG WIRD PROTOKOLLIERT, nicht nur der Erfolg
Sonst lässt sich auf „warum kam meine Kampagne nicht an?" nicht
einmal sagen, OB der Versuch den Server erreicht hat. Genau diese
Reihenfolge hat am 06.09.2026 im Shop zwei Stunden gekostet: Die
Frage „warum kommt die Mail nicht an?" setzt voraus, dass „wurde sie
überhaupt abgeschickt?" schon beantwortet ist.
Aufrufen: POST /workspace/api/kampagne/einlesen { text }
===================================================================== */
import express from "express";
import { randomBytes } from "node:crypto";
import { join } from "node:path";
import { mkdirSync, writeFileSync, unlinkSync } from "node:fs";
import {
db, protokolliere, echteIp, sitzungLesen, istLeitung,
DATEN_ORDNER, hausFuerNeuenEintrag } from "./workspace.js";
import { bildArtVon } from "./workspace-dateien.js";
import { EVENT_TEXT_MAX } from "./workspace-bereiche.js";
import {
linkAusText, folgeUmleitung, holeSeite, holeMitFrist, bildAdresseErlaubt,
hostVon, FRIST_SEITE_MS } from "./helfer-tiktok.mjs";
import { leseKampagne, regelnText, aufgabenText, KampagneUnlesbar }
from "./kampagne-lesen.mjs";
export const kampagneRouter = express.Router();
const ORDNER = join(DATEN_ORDNER, "dateien");
try { mkdirSync(ORDNER, { recursive: true }); } catch { /* gibt es schon */ }
/* WIE OFT IN EINER STUNDE? Wir lesen eine Seite, zu der wir eingeladen
wurden, einmal je Link, ausgelöst durch einen Menschen. Zwanzig ist
reichlich für einen Menschen und wenig für ein Skript. Die Zahl
steht hier und nicht in der Route, damit sie an genau einer Stelle
steht -- die Prüfung liest sie mit. Vorbild: BEITRAEGE_JE_STUNDE
in workspace-bereiche.js. */
export const KAMPAGNEN_JE_STUNDE = 20;
/* Das Banner. Dieselbe Grenze wie beim Video-Cover: Was von draußen
kommt, ist von draußen. */
const BANNER_MAX = 4 * 1024 * 1024;
/* Ab wann drängt es? Der Bauplan: „hoch", wenn das Ende weniger als
sieben Tage entfernt ist. Gerechnet, nicht geraten -- und hier, nicht
im Leser: Eine Lesefunktion, die die Wanduhr befragt, liefert zu
verschiedenen Zeiten verschiedene Ergebnisse auf dieselbe Eingabe. */
const DRAENGT_AB_TAGEN = 7;
/* SO VIEL FASST EIN TEXTFELD DER KACHEL. Nicht hier erfunden:
TEXT_MAX in workspace-bereiche.js, und die PUT-Route dort lehnt
mehr mit "Die Regeln sind zu lang." ab.
WARUM DAS UEBERHAUPT EINE FRAGE IST: Der Regeltext von
"Gipfelstuermer" ist gemessen 7474 Zeichen lang -- Geschenke, sechs
Preisgruppen, zwei Klapptexte mit Ligen und Teilnahmebedingungen.
Wer das ungekuerzt hineinschreibt, baut eine Kachel, die DogFather
OEFFNEN, aber nicht SPEICHERN kann: Er aendert ein Wort, drueckt
Speichern und bekommt eine Fehlermeldung ueber etwas, das er nie
getippt hat. Ein Formular, das der Server ablehnt, laesst den
Menschen davor sich selbst fuer den Fehler halten.
SEIT DEM 07.10.2026 STEHT DIE ZAHL NUR NOCH AN EINER STELLE
(`EVENT_TEXT_MAX` in workspace-bereiche.js) und ist dort von
6000 auf 20000 gehoben worden -- der Regeltext von
„Gipfelstuermer" passt jetzt vollstaendig hinein. Abgeschrieben
wird sie nicht: Zwei Fassungen derselben Grenze laufen
auseinander. */
const FELD_MAX = EVENT_TEXT_MAX;
/** Kuerzt an einer ABSCHNITTSGRENZE und sagt, dass gekuerzt wurde.
*
* Mitten im Wort abzuschneiden waere das Schlimmere von zweien: Es
* sieht nicht nach einer Grenze aus, sondern nach einem Fehler beim
* Einlesen -- und danach sucht jemand im Code statt im Textfeld.
*
* Verloren geht nichts: Das Gelesene steht gegliedert in
* kampagne_daten. */
/* AUSGEFUEHRT SEIT DEM 07.10.2026, damit sie pruefbar bleibt.
Seit das Feld 20000 Zeichen fasst, wird der laengste gemessene
Regeltext (7679 Zeichen) nicht mehr gekuerzt -- und damit liefe
diese Funktion im echten Betrieb nie. Eine Funktion, die nie
laeuft, ist eine Funktion, die beim naechsten Umbau still
kaputtgeht: Kommt eine Kampagne mit 25000 Zeichen, soll sie
genau dann richtig arbeiten.
Also wird sie einzeln geprueft, mit einer kleinen Grenze
(pruef-kampagne.mjs, Abschnitt 4). Das ist der Unterschied
zwischen „laeuft gerade nicht" und „ist ungeprueft". */
export function kuerzen(text, max = FELD_MAX) {
const s = String(text || "");
if (s.length <= max) return s;
const fussnote = "\n\n* Der Regeltext war länger als das Feld fasst und endet "
+ "hier. Vollständig steht er in den Rohdaten der Kampagne.";
const platz = max - fussnote.length;
const schnitt = s.lastIndexOf("\n\n", platz);
/* Gibt es keine Abschnittsgrenze, dann die letzte Zeilengrenze --
und erst wenn auch die fehlt, hart. */
const bis = schnitt > platz * 0.4 ? schnitt
: (s.lastIndexOf("\n", platz) > platz * 0.4 ? s.lastIndexOf("\n", platz) : platz);
return s.slice(0, bis).trimEnd() + fussnote;
}
const jetztIso = () => new Date().toISOString();
function angemeldet(req, res, next) {
const person = sitzungLesen(req);
if (!person) return res.status(401).json({ fehler: "nicht_angemeldet" });
req.person = person;
next();
}
kampagneRouter.use("/workspace/api/kampagne", angemeldet);
/** Kurz und für Menschen. Steht im Protokoll, nicht auf dem Bildschirm. */
const kurz = (s, n = 180) => String(s ?? "").slice(0, n);
/** Die Kampagnennummer aus einer Adresse -- oder null.
* `new URL()`, kein regulärer Ausdruck: Ein Muster, das `activityId=`
* irgendwo im Text sucht, findet es auch in einem Pfadstück. */
export function nummerAusAdresse(roh) {
try {
const p = new URL(String(roh)).searchParams;
/* ZWEI SCHREIBWEISEN (07.10.2026). Die Kampagnenseite nennt
es `activityId`, das Creator Network `activity_id` --
gemessen an `tiktok.com/tcn/activity?activity_id=769130...`.
Wer nur die erste kennt, hat bei einem Agenturlink gar keine
Nummer und kann nicht einmal sagen, um welche Kampagne es
geht. Die erste gewinnt, wenn beide dastehen: Sie steht auf
der Seite, die wir wirklich lesen koennen. */
const n = p.get("activityId") || p.get("activity_id");
return n && /^\d{6,25}$/.test(n) ? n : null;
} catch { return null; }
}
/** Ist das eine Seite des Creator Network statt einer Kampagne?
*
* GEMESSEN AM 07.10.2026, mit Gegenprobe: Der Agenturlink
* `vm.tiktok.com/ZSbACwAnK/` fuehrt auf
* `www.tiktok.com/tcn/activity?activity_id=7691301069758087175`
* und liefert 233 KB reine Huelle -- die Daten holt die Seite
* erst im Browser, angemeldet. Dieselbe Nummer auf dem Weg der
* oeffentlichen Kampagnenseite
* (`/falcon/campaign/fusion_cosmic_runtime/home?activityId=...`)
* gibt 42 KB ohne Datenpaket, waehrend eine echte Kampagne dort
* 967 KB MIT Datenpaket liefert. Die Kampagne existiert also
* nicht als oeffentliche Seite.
*
* DAS IST KEIN FEHLER, SONDERN EINE ANDERE SORTE LINK -- und
* dafuer gehoert eine andere Antwort hin als „TikTok hat nicht
* geantwortet". Wer eine Ursache nennt, die es nicht gibt,
* schickt jemanden in die falsche Richtung suchen. */
export function istCreatorNetwork(roh) {
try {
const u = new URL(String(roh));
return u.pathname === "/tcn" || u.pathname.startsWith("/tcn/");
} catch { return false; }
}
/** Wie viele Einlesevorgänge hatte diese Person in der letzten Stunde?
*
* GEZÄHLT WIRD IM PROTOKOLL, nicht in `eintraege`. Ein Versuch, der
* scheitert, erzeugt keinen Eintrag -- wer Einträge zählt, bremst
* genau den nicht, der in einer Schleife hängt und bei jedem Versuch
* eine fremde Seite abruft. Gezählt werden deshalb VERSUCHE, und das
* Protokoll ist die Stelle, an der beide Ausgänge stehen.
*
* UND "kampagne_schon_da" GEHOERT DAZU (nachgetragen 07.10.2026,
* gefunden von der Pruefung). Zuerst standen hier nur "eingelesen"
* und "gescheitert". Damit war die Bremse genau fuer den haeufigsten
* Missbrauchsfall blind: Wer denselben Link wieder und wieder
* einfuegt, bekommt jedes Mal "schon da" -- und loeste jedes Mal
* einen Abruf nach draussen aus, ohne gezaehlt zu werden. Gemessen:
* 21 Versuche, 0 gebremst.
*
* "kampagne_bremse" steht BEWUSST nicht in der Liste: Sonst zaehlte
* die Bremse ihre eigenen Meldungen mit und verlaengerte sich
* selbst -- man kaeme nie wieder heraus. */
function versucheLetzteStunde(personId) {
const seit = new Date(Date.now() - 3600_000).toISOString();
const z = db().prepare(
`SELECT COUNT(*) AS anzahl, MIN(zeitpunkt) AS aeltester
FROM protokoll
WHERE person_id = ? AND zeitpunkt >= ?
AND aktion IN ('kampagne_eingelesen','kampagne_gescheitert',
'kampagne_schon_da')`)
.get(personId, seit);
return { anzahl: z?.anzahl || 0, aeltester: z?.aeltester || null };
}
/** Das Banner holen und ablegen. Gibt `{ name, groesse, art }` zurück
* -- oder `null`, wenn es kein Bild ist.
*
* WARUM HERUNTERLADEN UND NICHT VERLINKEN: Beim Video ist es bewiesen
* (`x-expires`, gemessen 48 Stunden). Bei den Kampagnenbildern ist
* nicht einmal nachweisbar, OB sie eine Frist tragen -- die Adressen
* sehen anders aus. Das ist kein Grund, es zu riskieren: Ein fremdes
* CDN kann jederzeit umziehen, und wer die Adresse gespeichert hat,
* merkt es nicht beim Bauen und nicht beim Testen, sondern Wochen
* später an leeren Kacheln.
*
* Und es ist nicht nur Haltbarkeit: Als Datei mit `eintrag_id` greift
* automatisch `darfEintragSehen()`, und das Bild ist für Creator
* sichtbar. Am 03.10.2026 war genau das schon einmal ein 404. */
async function bannerAblegen(adresse) {
if (!adresse) return null;
/* Die Adresse kommt aus einer FREMDEN Seite. Siehe bildAdresseErlaubt. */
if (!bildAdresseErlaubt(adresse)) return null;
let a;
try { a = await holeMitFrist(adresse, {}, { frist: FRIST_SEITE_MS }); }
catch { return null; }
if (!a.ok) return null;
const roh = a.roh;
if (!roh.length || roh.length > BANNER_MAX) return null;
/* DIESELBE BYTEPRÜFUNG WIE BEI HOCHGELADENEN DATEIEN. `dateien.typ`
wäre hier sogar noch wertloser als sonst: Den Typ behauptet dann
ein fremder Server. */
const art = bildArtVon(roh.subarray(0, 16));
if (!art) return null;
const endung = { "image/png": ".png", "image/jpeg": ".jpg",
"image/gif": ".gif", "image/webp": ".webp" }[art] || ".bin";
const name = `${Date.now().toString(36)}-${randomBytes(8).toString("hex")}${endung}`;
writeFileSync(join(ORDNER, name), roh, { flag: "wx" });
return { name, groesse: roh.length, art };
}
/* =====================================================================
DIE ROUTE
===================================================================== */
kampagneRouter.post("/workspace/api/kampagne/einlesen", async (req, res) => {
const person = req.person;
const ip = echteIp(req);
let abgelegt = null;
/** Ein Fehlschlag, immer gleich: protokollieren UND antworten.
* Zwei getrennte Stellen dafür wären zwei Stellen, an denen man das
* Protokollieren vergessen kann. */
const schiefgegangen = (status, nachricht, grund) => {
protokolliere("kampagne_gescheitert", {
personId: person.id, rolle: person.rolle, ip,
detail: kurz(grund),
});
return res.status(status).json({ fehler: nachricht });
};
try {
/* ---- Recht --------------------------------------------------
Eine Kampagne ist eine Ansage an das ganze Team. Wer sie anlegen
darf, ist dieselbe Frage wie „wer darf ein Agentur-Event
anlegen" -- also wird die VORHANDENE Regel gefragt, nicht eine
neue erfunden. Eine zweite Rechteregel neben einer ersten ist
die Sorte Dopplung, die beim nächsten Umbau auseinanderläuft. */
if (!istLeitung(person)) {
return schiefgegangen(403,
"Kampagnen anlegen dürfen DogFather und die Manager.",
`keine Leitung (${person.rolle})`);
}
/* ---- Und nur auf der AGENTURADRESSE ---------------------
Bauplan, Teil 11: "Nichts im Haus Team Dogi."
WARUM DAS EINE EIGENE ZEILE BRAUCHT: DogFather ist in BEIDEN
Haeusern `admin`, und `istLeitung` fragt nur nach der Rolle.
Meldet er sich auf crew.dogfather-universe.com an und oeffnet
dort `bereich.html?b=agentur`, dann ist er Leitung, der
Bereich stimmt -- und `hausFuerNeuenEintrag` gaebe der
Kampagne das Haus "crew". Eine Agentur-Kampagne staende
damit im Rudel, und die Trennung waere genau an der Stelle
unterlaufen, an der sie niemand nachsieht.
Gefragt wird POSITIV ("ist es die Agentur?") und nicht
negativ ("ist es nicht die Crew?"): Ohne Adresse gibt
`hausFuerNeuenEintrag` `null` zurueck, und ein Eintrag ohne
Haus ist in BEIDEN sichtbar. Fuer die meisten Eintraege ist
das die bewusst sichere Richtung -- fuer eine Kampagne waere
es das Gegenteil.
Dasselbe Haus wird unten beim Schreiben benutzt, nicht ein
zweites Mal berechnet. */
const haus = hausFuerNeuenEintrag(person, "agentur");
if (haus !== "agentur") {
return schiefgegangen(403,
"Kampagnen gehören zur Agentur. Bitte auf "
+ "workspace.dogfather-universe.com anmelden.",
`falsches Haus: ${haus ?? "ohne Adresse"}`);
}
const text = String(req.body?.text ?? "");
if (!text.trim()) {
return schiefgegangen(400,
"Da war kein Text. Füge die ganze Einladung ein.",
"leerer Text");
}
/* ---- 0. Auf welche Seite gehört die Kachel? ------------------
Filipe: „ich will auswaehlen koennen wo es hingehoert, zu
tiktok oder der agentur." Die Auswahl kommt aus dem
Formular; fehlt sie oder steht etwas Fremdes darin, bleibt
es NULL -- und dann entscheidet wie bisher die Herkunft.
NICHT STILLSCHWEIGEND AUF 'tiktok' AUSWEICHEN: Das wäre
eine Entscheidung, die wie eine Auswahl aussieht. NULL
heißt „niemand hat gewählt", und das ist etwas anderes als
„links". */
const rohSeite = String(req.body?.seite ?? "").trim().toLowerCase();
/* `wunschSeite`, nicht `seite`: Weiter unten heisst die GEHOLTE
Seite so (`const seite = await holeSeite(...)`). Zwei Dinge mit
demselben Namen im selben Bereich sind genau die Sorte Falle,
die man beim naechsten Lesen nicht sieht -- hier hat sie der
Uebersetzer gefangen, beim naechsten Mal vielleicht nicht. */
const wunschSeite = rohSeite === "tiktok" || rohSeite === "agentur" ? rohSeite : null;
/* ---- 1. Den Link herauslösen -------------------------------- */
const link = linkAusText(text);
if (!link) {
return schiefgegangen(400,
"In diesem Text steht keine TikTok-Adresse. "
+ "Füge die Einladung noch einmal ein — der ganze Text reicht.",
`kein Link in ${text.length} Zeichen`);
}
/* ---- 2. Bremse, VOR dem ersten Abruf nach draußen ------------ */
{
const { anzahl, aeltester } = versucheLetzteStunde(person.id);
if (anzahl >= KAMPAGNEN_JE_STUNDE) {
const frei = new Date(Date.parse(aeltester) + 3600_000);
const min = Math.max(1, Math.ceil((frei.getTime() - Date.now()) / 60000));
/* Hier NICHT über `schiefgegangen`: Sonst zählte die Bremse
ihre eigene Meldung mit und verlängerte sich selbst. */
protokolliere("kampagne_bremse", {
personId: person.id, rolle: person.rolle, ip,
detail: `${anzahl} Versuche in einer Stunde`,
});
return res.status(429).json({
fehler: `Du hast in der letzten Stunde ${anzahl} Kampagnen eingelesen. `
+ `In ${min} ${min === 1 ? "Minute" : "Minuten"} geht es weiter.`,
});
}
}
/* ---- 3. Umleitung Station für Station ------------------------ */
const weg = await folgeUmleitung(link);
if (!weg.ok) {
if (weg.grund === "fremd") {
return schiefgegangen(400,
`Dieser Link führt zu ${hostVon(weg.url)}, nicht zu TikTok.`,
`Umleitung verlässt TikTok: ${weg.url}`);
}
if (weg.grund === "zu_viele") {
return schiefgegangen(400,
"Dieser Link führt im Kreis. Bitte die Einladung noch einmal öffnen.",
`mehr als 5 Umleitungen ab ${link}`);
}
if (weg.grund === "frist") {
return schiefgegangen(502,
"TikTok hat nicht geantwortet (12 s). Bitte später noch einmal.",
`Frist bei der Umleitung ab ${link}`);
}
return schiefgegangen(502,
"TikTok war nicht erreichbar. Bitte später noch einmal.",
`Umleitung ${weg.grund}: ${weg.fehler || weg.url}`);
}
const adresse = weg.url;
/* ---- 4./5. Kampagnennummer und Dublettenprüfung --------------
Erst aus der ADRESSE, weil das nichts kostet: Steht sie da und
kennen wir die Kampagne schon, sparen wir die ganze Seite.
Fehlt sie dort, wird sie unten aus der Seite gelesen -- und dann
noch einmal geprüft. */
const vorab = nummerAusAdresse(adresse);
if (vorab) {
const da = db().prepare(
"SELECT id FROM eintraege WHERE kampagne_id = ?").get(vorab);
if (da) {
protokolliere("kampagne_schon_da", {
personId: person.id, rolle: person.rolle, ip,
detail: kurz(`${vorab} -> Eintrag ${da.id}`),
});
return res.status(200).json({ id: da.id, schon: true });
}
}
/* ---- 6. Die Seite holen -------------------------------------- */
/* ---- IST DAS UEBERHAUPT EINE KAMPAGNENSEITE? ----------------
Vor dem Abruf, nicht danach: Wer eine Seite holt, von der er
schon weiss, dass sie nichts enthaelt, holt 233 KB und sagt
danach etwas Falsches („TikTok hat nicht geantwortet").
Die ausfuehrliche Messung steht bei `istCreatorNetwork`. Kurz:
Das Creator Network rendert nichts auf dem Server, und
dieselbe Kampagnennummer gibt es als oeffentliche Seite
nicht. Niemand kann das von aussen lesen -- auch kein
anderes Programm.
DIE MELDUNG SAGT, WAS ZU TUN IST. „Geht nicht" waere
richtig und nutzlos; was hilft, ist der Weg daneben: das
Event von Hand anlegen. */
if (istCreatorNetwork(adresse)) {
return schiefgegangen(422,
"Dieser Link führt ins Creator Network von TikTok. Diese Seiten "
+ "baut TikTok erst im Browser auf, angemeldet — von außen ist dort "
+ "nichts zu lesen. Leg das Event unten von Hand an und füge den "
+ "Link in die Beschreibung ein.",
`Creator Network: ${kurz(adresse)}`);
}
const seite = await holeSeite(adresse);
if (!seite.ok) {
if (seite.grund === "frist") {
return schiefgegangen(502,
"TikTok hat nicht geantwortet (12 s). Bitte später noch einmal.",
`Frist beim Holen von ${adresse}`);
}
if (seite.grund === "zu_gross") {
return schiefgegangen(502,
"Die Seite von TikTok war unerwartet groß und wurde abgebrochen.",
`über 3 MB (${seite.bytes}, angesagt: ${seite.angesagt})`);
}
if (seite.grund === "status") {
return schiefgegangen(502,
`TikTok hat die Seite nicht herausgegeben (${seite.status}).`,
`HTTP ${seite.status} bei ${adresse}`);
}
return schiefgegangen(502,
"TikTok war nicht erreichbar. Bitte später noch einmal.",
`Abruf ${seite.grund}: ${seite.fehler || adresse}`);
}
/* ---- 7. Lesen ------------------------------------------------ */
let gelesen;
try {
gelesen = leseKampagne(seite.text);
} catch (fehler) {
if (fehler instanceof KampagneUnlesbar) {
/* Die beiden Sorten werden verschieden beantwortet -- aber
beide mit 502 und beide mit dem GRUND im Klartext. Eine
Kachel ohne Zeitraum wäre eine Falschauskunft; sie behauptet
einen Termin, den sie nicht kennt. */
/* DREI SAETZE STATT ZWEI (07.10.2026). Die leere Huelle
ist kein Umbau und kein Fehler des Menschen davor -- und
"vermutlich hat TikTok umgebaut" schickt ihn suchen. */
const satz = fehler.art === "pflicht"
? `Die Seite wurde gelesen, aber ${fehler.grund}. Bitte von Hand anlegen.`
: (/Hülle/.test(fehler.grund)
? `${fehler.grund}. Das liegt nicht an dir und nicht am Link. `
+ "Bitte die Kachel vorerst von Hand anlegen."
: `Die Seite konnte nicht gelesen werden: ${fehler.grund}. `
+ "Vermutlich hat TikTok die Seite umgebaut.");
return schiefgegangen(502, satz, `${fehler.art}: ${fehler.grund}`);
}
throw fehler;
}
/* Die Nummer aus der Seite ist die verbindliche. Falls die Adresse
keine trug, wird jetzt zum ersten Mal auf Dubletten geprüft. */
const nummer = gelesen.pflicht.id;
if (!vorab) {
const da = db().prepare(
"SELECT id FROM eintraege WHERE kampagne_id = ?").get(nummer);
if (da) {
protokolliere("kampagne_schon_da", {
personId: person.id, rolle: person.rolle, ip,
detail: kurz(`${nummer} -> Eintrag ${da.id} (aus der Seite)`),
});
return res.status(200).json({ id: da.id, schon: true });
}
}
/* ---- 8. Banner ------------------------------------------------ */
abgelegt = await bannerAblegen(gelesen.kuer.bannerUrl);
const hinweise = [...gelesen.hinweise];
if (gelesen.kuer.bannerUrl && !abgelegt) {
hinweise.push("Das Banner konnte nicht geholt werden.");
}
/* ---- 9. Schreiben, in EINER Transaktion ----------------------
Eintrag und Banner gehören zusammen. Bricht das zweite INSERT
ab, darf das erste nicht stehen bleiben: Eine Kachel mit einem
Bild, das es nicht gibt, sieht aus wie ein Fehler im Anzeigen --
und man sucht ihn dort. */
const jetzt = jetztIso();
const tageBisEnde = Math.round(
(Date.parse(`${gelesen.pflicht.ende}T12:00:00Z`) - Date.parse(jetzt)) / 86400_000);
const dringlichkeit = tageBisEnde < DRAENGT_AB_TAGEN ? "hoch" : "mittel";
const d = db();
let eintragId = null;
let meine = false;
try {
try { d.exec("BEGIN IMMEDIATE"); meine = true; } catch { meine = false; }
const { lastInsertRowid } = d.prepare(`
INSERT INTO eintraege
(bereich, art, titel, text, datum, event_ende,
event_aufgaben, event_regeln, dringlichkeit, status, nur_leitung,
erstellt, erstellt_von, haus,
kampagne_id, kampagne_quelle, kampagne_geholt_am, kampagne_zone,
kampagne_daten, kampagne_seite)
VALUES ('agentur','kampagne', ?,?,?,?, ?,?,?, 'offen', 0, ?,?,?, ?,?,?,?,?,?)`)
.run(
gelesen.pflicht.titel.slice(0, 160),
kuerzen(gelesen.kuer.einleitung || ""),
/* Der TAG der Kampagne in IHRER Zone -- nicht heuteLokal().
Eine Kampagne, die morgen beginnt, steht sonst heute in der
Liste. */
gelesen.pflicht.start,
gelesen.pflicht.ende,
kuerzen(aufgabenText(gelesen.kuer)),
kuerzen(regelnText(gelesen.kuer, hinweise)),
dringlichkeit,
jetzt, person.id,
/* NICHT von Hand auf "agentur" gesetzt, sondern dieselbe
Angabe, die oben schon ueber die Annahme entschieden
hat. Zweimal zu rechnen waeren zwei Fassungen derselben
Regel -- und die laufen auseinander. */
haus,
nummer, link, jetzt, gelesen.pflicht.zone,
JSON.stringify(gelesen.auszug),
/* NULL, wenn niemand gewaehlt hat -- dann entscheidet
wie bisher die Herkunft. Siehe die Begruendung bei
der Spalte in workspace.js. */
wunschSeite);
eintragId = Number(lastInsertRowid);
if (abgelegt) {
d.prepare(`
INSERT INTO dateien (name_original, name_datei, groesse, typ, status, notiz,
hochgeladen_von, erstellt, eintrag_id, haus)
VALUES (?,?,?,?, 'entwurf', NULL, ?,?,?,?)`)
.run(`banner-${nummer}${abgelegt.name.slice(abgelegt.name.lastIndexOf("."))}`,
abgelegt.name, abgelegt.groesse, abgelegt.art,
person.id, jetzt, eintragId, person.haus || null);
abgelegt = null; /* ab hier gehört die Datei dem Eintrag */
}
if (meine) d.exec("COMMIT");
} catch (fehler) {
if (meine) { try { d.exec("ROLLBACK"); } catch { /* schon zurück */ } }
/* Der eindeutige Index hat zugeschlagen: zwei gleichzeitige
Aufrufe für dieselbe Kampagne. Das ist kein Fehler, das ist
genau sein Zweck -- und die Antwort ist dieselbe wie bei der
Prüfung oben. */
if (/UNIQUE|constraint/i.test(String(fehler?.message))) {
const da = db().prepare(
"SELECT id FROM eintraege WHERE kampagne_id = ?").get(nummer);
if (da) {
protokolliere("kampagne_schon_da", {
personId: person.id, rolle: person.rolle, ip,
detail: kurz(`${nummer} -> Eintrag ${da.id} (Wettlauf, Index)`),
});
return res.status(200).json({ id: da.id, schon: true });
}
}
throw fehler;
}
/* ---- 10. Protokoll ------------------------------------------- */
protokolliere("kampagne_eingelesen", {
personId: person.id, rolle: person.rolle, ip,
detail: kurz(`${nummer} "${gelesen.pflicht.titel}" `
+ `${gelesen.pflicht.start}..${gelesen.pflicht.ende} -> Eintrag ${eintragId}`
+ (hinweise.length ? ` (${hinweise.length} Hinweise)` : "")),
});
return res.status(201).json({
id: eintragId,
titel: gelesen.pflicht.titel,
von: gelesen.pflicht.start,
bis: gelesen.pflicht.ende,
hinweise,
});
} catch (fehler) {
console.error("[kampagne] einlesen:", fehler?.message);
/* Ein abgelegtes Banner ohne Eintrag wäre eine Datei, die niemand
je wieder findet -- und die Ablage wächst still. */
if (abgelegt) {
try { unlinkSync(join(ORDNER, abgelegt.name)); } catch { /* weg ist weg */ }
}
return schiefgegangen(500,
"Beim Anlegen ist etwas schiefgegangen. Bitte noch einmal versuchen.",
`unerwartet: ${fehler?.message}`);
}
});