Kampagnen-Kachel aus einem TikTok-Link -- Etappe 2 bis 4
Der Auftrag ist damit fertig: Im Agentur-Bereich gibt es ein Feld, in das die ganze Einladungsnachricht eingefuegt wird -- und daraus entsteht eine Agentur-Event-Kachel mit Titel, Zeitraum, Banner, Aufgaben zum Abhaken, Regeln, Ligen und Preisen. Gemessen 38 ms vom Einfuegen bis zur Kachel. ETAPPE 2 -- DER WEG NACH DRAUSSEN (helfer-tiktok.mjs) linkAusText holt die Adresse aus dem Fliesstext, damit niemand sie heraussuchen muss. folgeUmleitung geht Sprung fuer Sprung (redirect "manual", hoechstens fuenf) und schickt JEDE Zwischenadresse durch istTikTok -- bisher folgte holeMitFrist den Umleitungen selbst, und istTikTok hatte nur die EINGABE gesehen. Das ist die einzige Stelle im Haus, an der unser Server eine fremdbestimmte Adresse abruft. holeSeite zieht die Grenze von 3 MB BEIM LESEN, nicht hinterher: Wer arrayBuffer() abwartet und dann die Laenge ansieht, hat die 50 MB schon im Speicher. Frist 12 s statt 8 -- gemessen 750 ms allein beim Ursprungsserver, dazu 1,1 MB Uebertragung. bildAdresseErlaubt kam beim Bauen dazu, nicht aus dem Bauplan: Die Banneradresse steht in der Seite, die TikTok ausliefert, ist also FREMDBESTIMMT. Ohne Schranke wuerde unser Server abrufen, was dort steht -- auch http://127.0.0.1:4100/ oder 169.254.169.254. Elf Faelle durchgemessen. ETAPPE 3 -- DIE ROUTE (workspace-kampagne.js, 5 Spalten, 1 Index) Rechte aus istLeitung, nicht neu erfunden. Bremse 20 je Stunde, VOR dem ersten Abruf nach draussen -- eine Bremse hinter dem Abruf bremst den fremden Server nicht. Dublettenpruefung zweimal: im Code (faengt den Normalfall) und als eindeutiger Index (faengt den Wettlauf zweier gleichzeitiger Aufrufe). 'kampagne_schon_da' zaehlt bei der Bremse MIT (gefunden von der Pruefung): Zuerst zaehlten nur Erfolg und Fehlschlag -- wer denselben Link wieder und wieder einfuegt, bekommt jedes Mal "schon da" und loeste jedes Mal einen Abruf aus, ohne gezaehlt zu werden. Gemessen: 21 Versuche, 0 gebremst. Der Regeltext wird SICHTBAR gekuerzt, an einer Abschnittsgrenze. Gipfelstuermer ergibt 7474 Zeichen, das Feld fasst 6000 (TEXT_MAX), und die PUT-Route lehnt mehr ab: Ungekuerzt entstuende eine Kachel, die DogFather OEFFNEN, aber nicht SPEICHERN kann -- er aendert ein Wort und bekommt eine Fehlermeldung ueber etwas, das er nie getippt hat. Verloren geht nichts: kampagne_daten traegt das Gelesene gegliedert. ETAPPE 4 -- DIE OBERFLAECHE Ein Textbereich (die Einladung ist mehrzeilig), der Knopf gibt sich nach 15 s von selbst frei, die Meldung steht neben dem Feld und nennt den Grund im Klartext. Ob jemand darf, sagt der Server ueber darf_kampagne_einlesen in /api/ich -- keine Rollenliste im Browser. Gemessen auf 412 und 1280 px (bild-kampagne.mjs, 20 Messungen): Knopf 44 px, Kontrast 7,68:1, nichts liegt auf dem Knopf, kein Ueberlauf, keine Bewegung bei prefers-reduced-motion. Die Antwortzeile steht auf .9rem statt der hausweiten .76rem -- dort erscheint, was der Server geantwortet hat, und eine Fehlermeldung in 12 px liest niemand zweimal. Die hausweite Klasse bleibt unberuehrt. VIERZEHN SABOTAGEN, VIERZEHN TREFFER Sieben an kampagne-lesen.mjs (Etappe 1) und sieben an Route und Weg: Umleitung ungeprueft folgen, Tag nach Serverzeit, Dublettenpruefung weglassen, dem fremden Server den Bildtyp glauben, Bremse abschalten, Bremse wieder blind fuer Dubletten, jeden einlesen lassen. Jede wurde bemerkt, jede im erwarteten Abschnitt. EINE BLIEB ZUERST BLIND, und das war lehrreich: Nimmt man die Dublettenpruefung aus dem Code, faengt der INDEX es auf -- die Antwort ist dieselbe, die Pruefung blieb gruen. Kein Schaden, aber ein blinder Fleck. Jetzt unterscheidet die Pruefung am Protokolltext, WELCHE der beiden Sicherungen gegriffen hat. VIER VORBESTEHENDE BEFUNDE MIT ERLEDIGT willkommen.html hatte kein theme-color, kein Manifest, kein apple-touch-icon. Drei Zeilen sind billiger und haltbarer als eine Ausnahme in der Pruefung. workspace-anleitung.js bildete den Tag "ab wann geht mehr" aus UTC. Wer zwischen 00:00 und 02:00 beitritt, bekam einen Termin, der einen Tag zu frueh war. workspace.js hat dafuer jetzt tagLokalVon(zeitpunkt, versatz) -- und tagLokal ist nur noch ein Aufruf davon. Es gibt also WENIGER Rechenwege als vorher, nicht mehr. pruef-anleitung.mjs legte Testdaten auf den UTC-Tag und waere zwischen 00:00 und 02:00 rot gewesen, ohne dass etwas kaputt ist. mess-anleitung-crew.mjs rechnete seinen zweiten Port als PORT + 1. Das sieht aus wie eine Ableitung und ist keine: portNummer ueberspringt gesperrte Nummern, und dann gehoert PORT + 1 der naechsten DATEI. pruef-struktur ist damit zum ersten Mal vollstaendig gruen. GEPRUEFT (alle gruen, keine Zahl gesunken) pruef-kampagne 96 (neu) · pruef-kampagne-lesen 108 · pruef-video 74 · pruef-eventkarte 94 · pruef-agentur 62 · pruef-anleitung 196 · pruef-portnummern 41 (war 40 mit einem Fehler) · pruef-struktur, pruef-zeichen 8, pruef-ports 10, pruef-buehnen-fest 13, pruef-css-klassen 39, pruef-haus-trennung 107, pruef-lesbarkeit, pruef-tippziele, pruef-deutsche-texte 12, pruef-formulare, pruef-fingermass 5, pruef-workspace-seiten, pruef-tempo-workspace Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,509 @@
|
||||
/* =====================================================================
|
||||
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 {
|
||||
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. */
|
||||
const FELD_MAX = 6000;
|
||||
|
||||
/** 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. */
|
||||
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 n = new URL(String(roh)).searchParams.get("activityId");
|
||||
return n && /^\d{6,25}$/.test(n) ? n : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
/** 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})`);
|
||||
}
|
||||
|
||||
const text = String(req.body?.text ?? "");
|
||||
if (!text.trim()) {
|
||||
return schiefgegangen(400,
|
||||
"Da war kein Text. Füge die ganze Einladung ein.",
|
||||
"leerer Text");
|
||||
}
|
||||
|
||||
/* ---- 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 -------------------------------------- */
|
||||
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. */
|
||||
const satz = fehler.art === "pflicht"
|
||||
? `Die Seite wurde gelesen, aber ${fehler.grund}. Bitte 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)
|
||||
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: Das Haus entscheidet
|
||||
die Adresse, über die jemand hereinkommt. Wer es hier
|
||||
festschreibt, baut die Trennung ein zweites Mal -- und
|
||||
zwei Fassungen derselben Regel laufen auseinander. */
|
||||
hausFuerNeuenEintrag(person, "agentur"),
|
||||
nummer, link, jetzt, gelesen.pflicht.zone,
|
||||
JSON.stringify(gelesen.auszug));
|
||||
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}`);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user