ZWEI KLEINE SACHEN, BEIDE DERSELBEN ART: Etwas steht still da und sieht fast richtig aus. 1. DER TITEL WIRD GEGENGELESEN Anlass war ein echter Fall aus dem Betrieb: Filipe hat den Bildweg benutzt, und aus "Refined visuals Goal 1.0" wurde "Refined visvals Goal 1.0" -- ein u als v. Genau die Verwechslung, die man beim Korrekturlesen ueberliest, weil das Wort noch fast richtig aussieht. NACHGEMESSEN, OB DIE SPRACHWAHL HILFT: auf dem Server gegen die gespeicherte Pruefdatei mit deu, eng, deu+eng und eng+deu -- alle vier lesen "visuals" RICHTIG. Der Fehler liess sich also nicht nachstellen, und eine Einstellung zu aendern, deren Wirkung man nicht messen kann, ist Raten, das danach wie eine Loesung aussieht. Es bleibt bei `deu`. Was stattdessen hilft: Der Titel steht auf der Seite ZWEIMAL -- oben als Ueberschrift (gross, fett) und im Raster hinter "Gruppenname" (klein, normal). Zwei Schriftgroessen heisst zwei Gelegenheiten, sich zu verlesen, und zwar unabhaengig voneinander. Gehen die Lesungen auseinander, sagt die Kachel es, und beide stehen im Auszug. EIN HINWEIS, KEINE ABLEHNUNG -- anders als beim Zeitraum. Ein falsches Datum ist eine Falschauskunft: Die Kachel behauptet einen Termin, den sie nicht kennt, und niemand sieht es ihr an. Ein verlesener Buchstabe im Titel ist ein Schoenheitsfehler; daran eine ganze Kachel scheitern zu lassen waere unverhaeltnismaessig. 2. "Kampagne bremse" IST KEIN SATZ Die Protokollseite baut ihren Anzeigetext aus dem Schluessel: `aktionLesbar()` in personen.js macht aus Unterstrichen Leerzeichen und schreibt nur den ersten Buchstaben gross. Aus `kampagne_bremse` wurde damit "Kampagne bremse" -- Maschinensprache mit einem grossen Anfangsbuchstaben. Gemessen an allen sechs Woertern dieses Hauses war es das einzige kaputte: "Kampagne eingelesen", "Kampagne gescheitert", "Event geteilt" und sogar "Event zurueckgezogen" (die Umlautliste macht daraus "zurueckgezogen" mit u-Umlaut) lesen sich als Satz. Die Regel dahinter: Hauptwort plus Mittelwort, nie zwei Hauptwoerter. UND DAS UMBENENNEN WAR UMSONST ZU HABEN -- vorher gezaehlt, weil es bei Altbestand eine andere Rechnung waere: In der Live-Datenbank steht KEINE Zeile mit dem alten Wort (die Bremse hat nie gegriffen; zum Vergleich event_geteilt 7, kampagne_eingelesen 3). Es gibt also nichts, was danach krumm angezeigt wuerde. GEPRUEFT: 48 (Bildleser, +5 mit Gegenprobe, dass der Hinweis NICHT immer kommt), 190 (Route, unveraendert). Keine ausgelieferte Datei beruehrt, also kein neuer Stempel noetig. Co-Authored-By: Claude Opus 5 <[email protected]>
1024 lines
46 KiB
JavaScript
1024 lines
46 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, createHash } 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";
|
|
import { leseBild, BildUnlesbar } from "./kampagne-bild-lesen.mjs";
|
|
import { execFile } from "node:child_process";
|
|
import { tmpdir } from "node:os";
|
|
|
|
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_gebremst" 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 TEXTERKENNUNG (08.10.2026)
|
|
|
|
Filipe: „bei den agentur events soll ich kein link sondern immer so
|
|
ein screen reinschicke und dan soll daraus alles genommen werden."
|
|
|
|
WARUM HIER UND NICHT IM LESER: `kampagne-bild-lesen.mjs` darf
|
|
nichts anfassen -- kein Netz, keine Datei, kein Prozess. Nur so
|
|
laesst sich das Auslesen gegen GESPEICHERTE Erkennungstexte
|
|
pruefen. Der Aufruf von Tesseract braucht beides, also steht er
|
|
hier.
|
|
|
|
TESSERACT, NICHT EIN DIENST IM NETZ. Apache 2.0, laeuft auf dem
|
|
eigenen Server, kostet nichts und schickt das Bild nirgendwohin.
|
|
Ein Bildschirmfoto aus dem Agentur-Backstage zeigt Teilnehmerzahlen
|
|
und Kampagnennamen -- das geht niemanden sonst etwas an.
|
|
Nachgemessen am 08.10.2026: Version 5.5.0 mit deutschen
|
|
Sprachdaten ist auf dem Server vorhanden.
|
|
|
|
`--psm 4` (eine Spalte mit wechselnden Groessen) statt `6`
|
|
(ein Block): gemessen 24 statt 14 Zeilen, und die Bloecke der Seite
|
|
bleiben getrennt. Beide lesen die Aufgaben fehlerfrei; `4` haelt
|
|
zusaetzlich das Raster auseinander.
|
|
|
|
KEINE NACHBEARBEITUNG DES BILDES. Gemessen wurden vier Fassungen
|
|
(unveraendert, invertiert, doppelt so gross, beides): alle vier
|
|
lesen die drei Aufgaben wortgleich. Was nichts aendert, kommt nicht
|
|
in den Weg -- und eine Bildbibliothek mehr waere eine Abhaengigkeit
|
|
mehr.
|
|
|
|
DAS WASSERZEICHEN STOERT NICHT. Auf Filipes Bildschirmfotos liegt
|
|
seine Mailadresse vielfach und schraeg ueber der Seite. Gemessen:
|
|
Sie taucht im Erkennungstext kein einziges Mal auf -- zu wenig
|
|
Kontrast. (Gepruefte Gegenprobe in pruef-kampagne-bild.mjs.)
|
|
===================================================================== */
|
|
|
|
const OCR_FRIST_MS = 20_000;
|
|
const BILD_MAX = 8 * 1024 * 1024;
|
|
|
|
/** Liest den Text aus einem Bild. Gibt `{ ok, text }` oder
|
|
* `{ ok: false, grund }` -- der dritte Ausgang inbegriffen:
|
|
* „Texterkennung nicht vorhanden" ist etwas anderes als
|
|
* „nichts erkannt". */
|
|
function textAusBild(pfad) {
|
|
return new Promise((fertig) => {
|
|
execFile("tesseract", [pfad, "stdout", "-l", "deu", "--psm", "4"],
|
|
{ timeout: OCR_FRIST_MS, maxBuffer: 4 * 1024 * 1024 },
|
|
(fehler, aus) => {
|
|
if (fehler) {
|
|
/* ENOENT heisst: Tesseract gibt es auf dieser Maschine
|
|
nicht. Das ist KEIN Fehler des Bildes und muss anders
|
|
klingen -- sonst sucht jemand stundenlang am
|
|
Bildschirmfoto. */
|
|
const grund = fehler.code === "ENOENT"
|
|
? "Die Texterkennung ist auf diesem Server nicht eingerichtet."
|
|
: (fehler.killed
|
|
? `Die Texterkennung hat länger als ${OCR_FRIST_MS / 1000} s gebraucht.`
|
|
: `Die Texterkennung ist gescheitert (${fehler.code ?? fehler.message}).`);
|
|
return fertig({ ok: false, grund });
|
|
}
|
|
return fertig({ ok: true, text: String(aus || "") });
|
|
});
|
|
});
|
|
}
|
|
|
|
/** Der Dublettenschluessel eines Bildschirmfotos.
|
|
*
|
|
* EIN BILD TRAEGT KEINE KAMPAGNENNUMMER. Auf der Seite steht keine,
|
|
* und aus dem Bild laesst sich keine lesen. Ohne Schluessel entstuende
|
|
* bei jedem zweiten Einfuegen desselben Bildes eine zweite Kachel.
|
|
*
|
|
* Genommen wird deshalb, was die Kampagne ausmacht: Titel und
|
|
* Zeitraum. Zweimal dasselbe Bild gibt denselben Schluessel; zwei
|
|
* verschiedene Kampagnen geben verschiedene.
|
|
*
|
|
* DAS PRAEFIX `bild:` HAELT DIE RAEUME AUSEINANDER. Eine
|
|
* TikTok-Kampagnennummer besteht nur aus Ziffern; so kann ein
|
|
* Bildschlüssel nie mit einer verwechselt werden -- auch nicht im
|
|
* eindeutigen Index, den sich beide teilen. */
|
|
function bildSchluessel(pflicht) {
|
|
const kern = `${pflicht.titel}|${pflicht.start}|${pflicht.ende}`;
|
|
return `bild:${createHash("sha256").update(kern, "utf8").digest("hex").slice(0, 32)}`;
|
|
}
|
|
|
|
/* =====================================================================
|
|
EINE KACHEL SCHREIBEN -- EINMAL IM HAUS (08.10.2026)
|
|
|
|
Gerufen von BEIDEN Wegen: aus einem Link (`/einlesen`) und aus
|
|
einem Bildschirmfoto (`/bild`). Vorher stand dieser Block in der
|
|
Link-Route; als der Bild-Weg dazukam, waere die naheliegende Loesung
|
|
gewesen, ihn abzuschreiben.
|
|
|
|
WARUM NICHT ABSCHREIBEN: Am 11.09.2026 hat genau das in diesem Haus
|
|
drei Spalten gekostet. Eine Umstellung schrieb die Spaltenliste von
|
|
Hand ab, spaeter kamen drei Spalten dazu, und die Abschrift warf sie
|
|
weg -- mit Inhalt, ohne Fehlermeldung, bei unveraenderter
|
|
Zeilenzahl. Zwei INSERTs fuer dieselbe Tabelle sind dieselbe Falle.
|
|
|
|
EINTRAG UND BILD IN EINER TRANSAKTION. 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.
|
|
|
|
@param {object} o
|
|
@param {object} o.gelesen was der Leser geliefert hat
|
|
@param {string[]} o.hinweise was dabei gefehlt hat
|
|
@param {object} o.person wer es anlegt
|
|
@param {?string} o.haus das Haus der Adresse, nicht geraten
|
|
@param {string} o.jetzt ein Zeitstempel fuer alles
|
|
@param {?string} o.nummer Dublettenschluessel oder null
|
|
@param {?string} o.quelle Link oder Herkunftsvermerk
|
|
@param {?string} o.wunschSeite "tiktok" | "agentur" | null
|
|
@param {?object} o.abgelegt eine schon abgelegte Bilddatei
|
|
@param {string} o.dateiBasis wie die Datei am Eintrag heissen soll
|
|
@returns {number} die Nummer des neuen Eintrags
|
|
===================================================================== */
|
|
function kachelSchreiben({ gelesen, hinweise, person, haus, jetzt,
|
|
nummer, quelle, wunschSeite, abgelegt, dateiBasis }) {
|
|
/* Ab wann draengt es? Gerechnet, nicht geraten -- und hier, nicht im
|
|
Leser: Eine Lesefunktion, die die Wanduhr befragt, liefert zu
|
|
verschiedenen Zeiten verschiedene Ergebnisse auf dieselbe
|
|
Eingabe. */
|
|
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 meine = false;
|
|
try { d.exec("BEGIN IMMEDIATE"); meine = true; } catch { meine = false; }
|
|
try {
|
|
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, quelle, 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 ?? null);
|
|
const 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(`${dateiBasis}${abgelegt.name.slice(abgelegt.name.lastIndexOf("."))}`,
|
|
abgelegt.name, abgelegt.groesse, abgelegt.art,
|
|
person.id, jetzt, eintragId, person.haus || null);
|
|
}
|
|
if (meine) d.exec("COMMIT");
|
|
return eintragId;
|
|
} catch (fehler) {
|
|
if (meine) { try { d.exec("ROLLBACK"); } catch { /* schon zurück */ } }
|
|
throw fehler;
|
|
}
|
|
}
|
|
|
|
/* =====================================================================
|
|
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. */
|
|
/* HAUPTWORT PLUS MITTELWORT, NIE ZWEI HAUPTWOERTER
|
|
(berichtigt 08.10.2026).
|
|
|
|
Die Protokollseite baut ihren Anzeigetext aus dem
|
|
Schluessel: `aktionLesbar()` in personen.js macht aus
|
|
Unterstrichen Leerzeichen und schreibt nur den ERSTEN
|
|
Buchstaben gross. Aus `kampagne_bremse` wurde damit
|
|
„Kampagne bremse" -- Maschinensprache mit einem grossen
|
|
Anfangsbuchstaben, und genau davor warnt
|
|
pruef-meldungen.
|
|
|
|
Gemessen an den anderen fuenf Woertern dieses Hauses:
|
|
„Kampagne eingelesen", „Kampagne gescheitert", „Event
|
|
geteilt", „Event zurueckgezogen" (die Umlautliste macht
|
|
daraus „zurückgezogen") lesen sich als Satz. Nur dieses
|
|
eine nicht.
|
|
|
|
UMBENENNEN WAR HIER UMSONST ZU HABEN: Gemessen am
|
|
08.10.2026 steht in der Live-Datenbank KEINE einzige
|
|
Zeile mit dem alten Wort -- die Bremse hat noch nie
|
|
gegriffen. Es gibt also keinen Altbestand, der danach
|
|
krumm angezeigt wuerde. Bei einem Wort mit Zeilen in der
|
|
Tabelle waere die Rechnung eine andere. */
|
|
protokolliere("kampagne_gebremst", {
|
|
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. */
|
|
/* ---- 9. Anlegen ---------------------------------------------
|
|
DURCH DIESELBE TUER WIE DER BILD-WEG. `kachelSchreiben` steht
|
|
weiter unten und wird von beiden Routen gerufen. Abgeschrieben
|
|
waere dieser Block die Sorte Dopplung, die in diesem Haus schon
|
|
einmal drei Spalten gekostet hat (CLAUDE.md, 11.09.2026): Wer
|
|
spaeter eine Spalte ergaenzt, ergaenzt sie an einer Stelle und
|
|
vergisst die andere -- ohne Fehlermeldung, bei unveraenderter
|
|
Zeilenzahl. */
|
|
const jetzt = jetztIso();
|
|
let eintragId = null;
|
|
try {
|
|
eintragId = kachelSchreiben({
|
|
gelesen, hinweise, person, haus, jetzt,
|
|
nummer, quelle: link, wunschSeite,
|
|
abgelegt, dateiBasis: `banner-${nummer}`,
|
|
});
|
|
abgelegt = null; /* ab hier gehört die Datei dem Eintrag */
|
|
} catch (fehler) {
|
|
/* 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}`);
|
|
}
|
|
});
|
|
|
|
/* =====================================================================
|
|
EINE KAMPAGNE AUS EINEM BILDSCHIRMFOTO (08.10.2026)
|
|
|
|
Filipe: „bei den agentur events soll ich kein link sondern immer so
|
|
ein screen reinschicke und dan soll daraus alles genommen werden und
|
|
daraus die kampagne kachel gemacht werden."
|
|
|
|
DERSELBE WEG WIE DER LINK, nur mit einer anderen Quelle: Recht ->
|
|
Haus -> Bremse -> Lesen -> Dublette -> Anlegen -> Protokoll. Was
|
|
dabei herauskommt, hat dieselbe Form (siehe kampagne-bild-lesen.mjs),
|
|
und geschrieben wird mit derselben Funktion (`kachelSchreiben`).
|
|
|
|
DAS BILD KOMMT ALS ROHE BYTES, so wie jede andere Datei im Haus
|
|
(`express.raw`, Beschriftung im Kopf). Kein multipart/form-data von
|
|
Hand -- die Begruendung steht in workspace-dateien.js.
|
|
|
|
UND ES BLEIBT AN DER KACHEL. Das Bildschirmfoto IST die Quelle
|
|
dieser Kampagne; es wegzuwerfen hiesse, die einzige Stelle
|
|
wegzuwerfen, an der man nachsehen kann, was wirklich dastand. Es
|
|
wird deshalb als Datei an den Eintrag gehaengt -- und erscheint
|
|
damit als Buehnenbild der Kachel, genau wie ein Banner.
|
|
===================================================================== */
|
|
|
|
kampagneRouter.post("/workspace/api/kampagne/bild",
|
|
express.raw({ type: "*/*", limit: BILD_MAX }),
|
|
async (req, res) => {
|
|
const person = req.person;
|
|
const ip = echteIp(req);
|
|
let abgelegt = null;
|
|
let temp = null;
|
|
|
|
const schiefgegangen = (status, nachricht, grund) => {
|
|
protokolliere("kampagne_gescheitert", {
|
|
personId: person.id, rolle: person.rolle, ip,
|
|
detail: kurz(`bild: ${grund}`),
|
|
});
|
|
return res.status(status).json({ fehler: nachricht });
|
|
};
|
|
|
|
try {
|
|
/* ---- Recht und Haus: WORTGLEICH mit dem Link-Weg ----------
|
|
Dieselben zwei Fragen, dieselben zwei Antworten. Eine eigene
|
|
Regel hier waere eine zweite -- und die zweite ist die, die
|
|
beim naechsten Umbau etwas durchlaesst. */
|
|
if (!istLeitung(person)) {
|
|
return schiefgegangen(403,
|
|
"Kampagnen anlegen dürfen DogFather und die Manager.",
|
|
`keine Leitung (${person.rolle})`);
|
|
}
|
|
if (hausFuerNeuenEintrag(person, "agentur") !== "agentur") {
|
|
return schiefgegangen(403,
|
|
"Das geht nur auf der Agenturadresse.",
|
|
`falsches Haus (${hausFuerNeuenEintrag(person, "agentur")})`);
|
|
}
|
|
const haus = hausFuerNeuenEintrag(person, "agentur");
|
|
|
|
/* ---- Ist das ueberhaupt ein Bild? ------------------------- */
|
|
if (!Buffer.isBuffer(req.body) || !req.body.length) {
|
|
return schiefgegangen(400,
|
|
"Es ist kein Bild angekommen. Füge das Bildschirmfoto noch einmal ein.",
|
|
"leerer Rumpf");
|
|
}
|
|
/* DIE BYTES ENTSCHEIDEN, nicht der angesagte Typ -- dieselbe
|
|
Regel wie bei jedem Upload im Haus. Ein Browser darf
|
|
behaupten, was er will. */
|
|
const art = bildArtVon(req.body.subarray(0, 16));
|
|
if (!art) {
|
|
return schiefgegangen(415,
|
|
"Das war kein Bild. Es geht ein Bildschirmfoto als PNG, JPEG, GIF oder WebP.",
|
|
`unbekannte Bytes (${req.body.length} B)`);
|
|
}
|
|
|
|
/* ---- Bremse, VOR der Texterkennung ------------------------
|
|
Dieselbe Bremse wie beim Link, und aus demselben Grund:
|
|
Texterkennung kostet Rechenzeit auf unserem Server. Gezaehlt
|
|
wird im Protokoll, also fallen beide Wege in denselben
|
|
Zaehler -- wer zwanzigmal in der Stunde etwas einliest, hat
|
|
es getan, egal womit. */
|
|
{
|
|
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));
|
|
/* Dasselbe Wort wie oben -- Begruendung dort. */
|
|
protokolliere("kampagne_gebremst", {
|
|
personId: person.id, rolle: person.rolle, ip,
|
|
detail: kurz(`bild: ${anzahl} in der letzten Stunde`),
|
|
});
|
|
return res.status(429).json({
|
|
fehler: `Das waren ${anzahl} Kampagnen in einer Stunde. `
|
|
+ `In ${min} Minute(n) geht es weiter.`,
|
|
});
|
|
}
|
|
}
|
|
|
|
/* ---- Die Texterkennung ------------------------------------
|
|
Das Bild muss dafuer kurz als Datei dastehen: Tesseract liest
|
|
einen Pfad. Es liegt im Temp-Ordner, nicht in der Ablage --
|
|
dort landet es erst, wenn eine Kachel daraus wird. */
|
|
const endung = { "image/png": ".png", "image/jpeg": ".jpg",
|
|
"image/gif": ".gif", "image/webp": ".webp" }[art] || ".bin";
|
|
temp = join(tmpdir(),
|
|
`kampagnebild-${Date.now().toString(36)}-${randomBytes(6).toString("hex")}${endung}`);
|
|
writeFileSync(temp, req.body, { flag: "wx" });
|
|
|
|
const erkannt = await textAusBild(temp);
|
|
if (!erkannt.ok) {
|
|
return schiefgegangen(503, erkannt.grund, `ocr: ${erkannt.grund}`);
|
|
}
|
|
|
|
/* ---- Lesen ------------------------------------------------- */
|
|
let gelesen;
|
|
try {
|
|
gelesen = leseBild(erkannt.text);
|
|
} catch (fehler) {
|
|
if (fehler instanceof BildUnlesbar) {
|
|
/* DREI AUSGAENGE, ZWEI DAVON HIER: „Pflichtfeld fehlt oder
|
|
widerspricht sich" und „das sieht nicht nach dieser Seite
|
|
aus". Beide bekommen ihren eigenen Satz, weil sie
|
|
verschiedene Handgriffe brauchen: schaerferes Bild gegen
|
|
richtiges Bild. */
|
|
const satz = fehler.art === "pflicht"
|
|
? `Das Bild wurde gelesen, aber ${fehler.message}.`
|
|
: `Aus diesem Bild wurde die Kampagne nicht erkannt: ${fehler.message}. `
|
|
+ "Am besten das ganze Fenster abfotografieren — mit Titel, "
|
|
+ "Zeitraum und den Zielen.";
|
|
return schiefgegangen(422, satz, `${fehler.art}: ${fehler.message}`);
|
|
}
|
|
throw fehler;
|
|
}
|
|
|
|
/* ---- Dublette --------------------------------------------- */
|
|
const nummer = bildSchluessel(gelesen.pflicht);
|
|
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(`bild ${nummer} -> Eintrag ${da.id}`),
|
|
});
|
|
return res.status(200).json({ id: da.id, schon: true });
|
|
}
|
|
|
|
/* ---- Das Bild in die Ablage -------------------------------- */
|
|
const name = `${Date.now().toString(36)}-${randomBytes(8).toString("hex")}${endung}`;
|
|
writeFileSync(join(ORDNER, name), req.body, { flag: "wx" });
|
|
abgelegt = { name, groesse: req.body.length, art };
|
|
|
|
/* ---- Die Seitenwahl ---------------------------------------
|
|
WIE BEIM LINK, nur mit einer anderen Vorgabe: Ein
|
|
Bildschirmfoto aus dem Backstage ist ein Event der AGENTUR --
|
|
dort steht „Erstellt von: Spicy Media". Wer „Automatisch"
|
|
stehen laesst, bekommt also die rechte Spalte.
|
|
|
|
Das ist eine Vorgabe, keine Festlegung: Die drei
|
|
Moeglichkeiten stehen im Kasten, und an der Kachel laesst es
|
|
sich danach jederzeit umstellen. */
|
|
const rohSeite = String(req.get("x-seite") || "").trim().toLowerCase();
|
|
const wunschSeite = rohSeite === "tiktok" || rohSeite === "agentur"
|
|
? rohSeite : "agentur";
|
|
|
|
const hinweise = [...gelesen.hinweise];
|
|
/* DIE KACHEL SAGT SELBST, WOHER SIE KOMMT. Ein Mensch, der sie
|
|
spaeter liest, soll wissen, dass hier eine Maschine
|
|
abgeschrieben hat -- und dass ein Zahlendreher moeglich ist. */
|
|
hinweise.push("Diese Kachel wurde aus einem Bildschirmfoto gelesen — "
|
|
+ "bitte Titel und Zeitraum einmal nachsehen.");
|
|
|
|
const jetzt = jetztIso();
|
|
const eintragId = kachelSchreiben({
|
|
gelesen, hinweise, person, haus, jetzt,
|
|
nummer,
|
|
/* Es gibt keinen Link. Statt die Spalte leer zu lassen, steht
|
|
hier, WORAUS die Kachel entstanden ist -- sonst sieht eine
|
|
Kachel ohne Quelle aus wie eine von Hand angelegte. */
|
|
quelle: "Bildschirmfoto (Creator Network)",
|
|
wunschSeite, abgelegt, dateiBasis: "kampagnenbild",
|
|
});
|
|
abgelegt = null; /* ab hier gehoert die Datei dem Eintrag */
|
|
|
|
protokolliere("kampagne_eingelesen", {
|
|
personId: person.id, rolle: person.rolle, ip,
|
|
detail: kurz(`bild "${gelesen.pflicht.titel}" `
|
|
+ `${gelesen.pflicht.start}..${gelesen.pflicht.ende} -> Eintrag ${eintragId}`
|
|
+ ` (${gelesen.kuer.aufgaben.length} Aufgaben)`),
|
|
});
|
|
|
|
return res.status(201).json({
|
|
id: eintragId,
|
|
titel: gelesen.pflicht.titel,
|
|
von: gelesen.pflicht.start,
|
|
bis: gelesen.pflicht.ende,
|
|
aufgaben: gelesen.kuer.aufgaben,
|
|
hinweise,
|
|
});
|
|
} catch (fehler) {
|
|
console.error("[kampagne] bild:", fehler?.message);
|
|
if (abgelegt) {
|
|
try { unlinkSync(join(ORDNER, abgelegt.name)); } catch { /* weg ist weg */ }
|
|
}
|
|
return schiefgegangen(500,
|
|
"Beim Lesen des Bildes ist etwas schiefgegangen. Bitte noch einmal versuchen.",
|
|
`unerwartet: ${fehler?.message}`);
|
|
} finally {
|
|
/* DIE ZWISCHENDATEI GEHT IMMER WEG -- auch wenn oben etwas
|
|
geworfen hat. Sonst fuellt sich der Temp-Ordner mit
|
|
Bildschirmfotos, die niemand mehr zuordnen kann. */
|
|
if (temp) { try { unlinkSync(temp); } catch { /* schon weg */ } }
|
|
}
|
|
});
|