Agentur-Events: aus einem Bildschirmfoto wird eine Kampagnenkachel
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."
WARUM EIN BILD UND NICHT DER LINK. Gemessen am 07./08.10.2026 an seinem
echten Agenturlink, mit Gegenprobe:
vm.tiktok.com/ZSbACwAnK/ -> tiktok.com/tcn/activity?activity_id=...
233 KB reine Huelle, kein Datenpaket
dieselbe Nummer auf dem Weg der oeffentlichen Kampagnenseite
42 KB, ebenfalls ohne Datenpaket
eine ECHTE Kampagne auf demselben Weg
967 KB MIT Datenpaket
Die Agentur-Kampagnen leben im Creator Network, und das rendert nichts
auf dem Server. Was Filipe sieht, sieht nur sein Browser -- also wird
genau das gelesen: das Bild davon.
DREI WEGE ZUM SELBEN KASTEN: einfuegen (Strg+V nach Win+Umschalt+S),
hereinziehen, auswaehlen (Handy). Derselbe Kasten, derselbe
Knopfzustand, dieselbe Meldezeile wie beim Link.
TESSERACT, NICHT EIN DIENST IM NETZ. Apache 2.0, auf dem eigenen
Server, 0 EUR, und das Bild verlaesst ihn nicht -- ein Backstage-Foto
zeigt Teilnehmerzahlen und Kampagnennamen. Version 5.5.0 mit deutschen
Sprachdaten war bereits vorhanden. `--psm 4` gemessen gegen `6` (24
statt 14 Zeilen); keine Bildnachbearbeitung, weil vier gemessene
Fassungen (unveraendert, invertiert, doppelt, beides) dieselben Ziele
wortgleich lesen.
DIESELBE FORM WIE DER LINK-LESER. `leseBild()` gibt Feld fuer Feld
dasselbe zurueck wie `leseKampagne()` -- dadurch greifen Gliederung,
Geschenkplaketten, Aufklappen, Teilen, Steckbrief und Spaltenwahl
unveraendert. Geprueft wird das ausdruecklich (17 gegen 17 Felder).
DER ZEITRAUM WIRD ZWEIMAL GELESEN UND MUSS ZWEIMAL DASSELBE SAGEN.
Auf der Seite steht er amerikanisch (10/01/2026) und deutsch
(01.10.2026). Genommen wird die deutsche, die amerikanische ist die
Gegenprobe. Widersprechen sie sich, entsteht KEINE Kachel: Eine Kachel
ohne Zeitraum ist unvollstaendig und sagt das selbst -- eine mit dem
falschen ist eine Falschauskunft, und bei Texterkennung ist aus einer 3
schnell eine 8.
Der Anlege-Block ist aus der Link-Route herausgezogen
(`kachelSchreiben`), damit beide Wege DIESELBE Stelle benutzen.
Abgeschrieben waere er die Dopplung, die hier am 11.09.2026 schon
einmal drei Spalten gekostet hat.
GEMESSEN UND BERICHTIGT (alles aus echten Laeufen, nicht geraten):
- Die Phasenueberschrift trug ein Zielscheiben-Zeichen, das als `@`
gelesen wurde -- die erste "Aufgabe" hiess "Ziele erreichen Endetin
23 Tage 22 : 50 : 44".
- Halbe Schilder liessen ihre zweite Haelfte im Wert stehen:
"Sichtbar für: für Privat".
- "Creator*innen" schnitt mitten im Nachbarwert ab und machte aus
"1 Creator Network, 55 Creator*innen" ein "1 Creator Network, 55".
- "In Bearbeitung" kam als "in aearbeitung" durch.
- Die Creator-Zahl steht als "1 (i)" da, und der Kreis wird als Null
gelesen. Sie wird deshalb GAR NICHT angezeigt: keine Angabe ist
richtig, eine falsche ist falsch.
- Das Wasserzeichen (seine Mailadresse, vielfach und schraeg) taucht
im Erkennungstext kein einziges Mal auf -- festgehalten mit
Gegenprobe, falls eine kuenftige Fassung es doch liest.
- `.knopf--still` hiess zuerst wie ein allgemeiner Knopf-Zusatz;
pruef-css-klassen hatte recht, ihn auf drei Seiten zu vermissen.
Jetzt `.kampagneholen__waehlen`, im Namensraum seines Kastens.
- Die Pruefung selbst meldete zweimal Falsches: Sie fand "execFile"
und "Tesseract" in den KOMMENTAREN des Lesers und hielt
`regex.exec(...)` fuer einen fremden Prozess.
GEPRUEFT: 43 neu (pruef-kampagne-bild, davon 1 nicht nachsehbar --
Tesseract fehlt auf dem Entwicklungsrechner; auf dem Server liefert es
den gespeicherten Erkennungstext zeichengleich), 169 (Route,
unveraendert nach dem Herausziehen), struktur, css-klassen, zeichen,
deutsche-texte.
PARALLELE ARBEIT: An derselben Arbeitskopie wird gleichzeitig an der
Crew-Seite gebaut (Reaction, Foyer). Dieser Commit fasst ausschliesslich
die zehn Dateien oben an -- einzeln vorgemerkt, kein `git add -A`. Der
globale Cache-Stempel wurde absichtlich NICHT gelaufen, nur die zwei
Verweise in bereich.html; gestempelt wird am Ende einmal fuer beide.
Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
+408
-53
@@ -69,7 +69,7 @@
|
||||
===================================================================== */
|
||||
|
||||
import express from "express";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { randomBytes, createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { mkdirSync, writeFileSync, unlinkSync } from "node:fs";
|
||||
import {
|
||||
@@ -82,6 +82,9 @@ import {
|
||||
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();
|
||||
|
||||
@@ -283,6 +286,186 @@ async function bannerAblegen(adresse) {
|
||||
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
|
||||
===================================================================== */
|
||||
@@ -541,62 +724,24 @@ kampagneRouter.post("/workspace/api/kampagne/einlesen", async (req, res) => {
|
||||
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();
|
||||
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");
|
||||
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) {
|
||||
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
|
||||
@@ -642,3 +787,213 @@ kampagneRouter.post("/workspace/api/kampagne/einlesen", async (req, res) => {
|
||||
`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));
|
||||
protokolliere("kampagne_bremse", {
|
||||
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 */ } }
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user