From 698c23e20d2b5c83aba593ac55da2d97e89f8464 Mon Sep 17 00:00:00 2001 From: Dogfather Date: Thu, 8 Oct 2026 10:04:57 +0200 Subject: [PATCH] Agentur-Events: aus einem Bildschirmfoto wird eine Kampagnenkachel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 18 + server/kampagne-bild-lesen.mjs | 599 ++++++++++++++++++++ server/pruef-kampagne-bild.mjs | 403 +++++++++++++ server/pruefdaten/bild-agentur-erkannt.txt | 22 + server/pruefdaten/bild-agentur-nachbau.html | 128 +++++ server/pruefdaten/bild-agentur-nachbau.png | Bin 0 -> 47330 bytes server/workspace-kampagne.js | 461 +++++++++++++-- workspace/assets/css/bereich.css | 73 +++ workspace/assets/js/bereich.js | 104 +++- workspace/bereich.html | 25 +- 10 files changed, 1775 insertions(+), 58 deletions(-) create mode 100644 server/kampagne-bild-lesen.mjs create mode 100644 server/pruef-kampagne-bild.mjs create mode 100644 server/pruefdaten/bild-agentur-erkannt.txt create mode 100644 server/pruefdaten/bild-agentur-nachbau.html create mode 100644 server/pruefdaten/bild-agentur-nachbau.png diff --git a/.gitignore b/.gitignore index 7f60ec04..46c1374a 100644 --- a/.gitignore +++ b/.gitignore @@ -141,3 +141,21 @@ tools/.messkopf.txt # Das Arbeitsschloss gehoert dem Rechner, nicht dem Verlauf: Es sagt, # wer GERADE arbeitet. Committet waere es eine Behauptung von gestern. .arbeitsschloss + +# --------------------------------------------------------------------- +# AUSNAHME: server/pruefdaten/ IST VERSIONIERT (08.10.2026) +# +# Dort liegen PRUEFDATEN, keine erzeugten Bilder: gespeicherte Seiten, +# ein Erkennungstext und das Bild, aus dem er stammt. Ihre Pruefsummen +# stehen in den Pruefdateien -- ohne sie laeuft pruef-kampagne-lesen +# und pruef-kampagne-bild gar nicht erst an. +# +# Noetig wurde die Zeile, weil `bild-*.png` weiter oben die ERZEUGTEN +# Bildschirmfotos ausschliesst (bild-kampagne-1280.png und +# Geschwister) -- und `bild-agentur-nachbau.png` zufaellig genauso +# heisst. Zwei verschiedene Dinge, ein Muster. +# +# `.gitattributes` haelt diese Dateien ausserdem byteweise fest +# (`server/pruefdaten/** -text`), sonst wuerde Git die Zeilenenden +# umschreiben und die Pruefsummen brechen. +!server/pruefdaten/** diff --git a/server/kampagne-bild-lesen.mjs b/server/kampagne-bild-lesen.mjs new file mode 100644 index 00000000..408d1f9a --- /dev/null +++ b/server/kampagne-bild-lesen.mjs @@ -0,0 +1,599 @@ +/* ===================================================================== + EINE KAMPAGNE AUS EINEM BILDSCHIRMFOTO LESEN (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." + + WARUM ES DIESE DATEI UEBERHAUPT GIBT. Gemessen am 07.10.2026 an + seinem echten Agenturlink: `vm.tiktok.com/ZSbACwAnK/` fuehrt auf + `tiktok.com/tcn/activity?...` -- das Creator Network. Diese Seiten + baut TikTok erst im Browser auf, angemeldet; von aussen stehen dort + 233 KB leere Huelle. Dieselbe Kampagnennummer auf dem Weg der + oeffentlichen Kampagnenseite gibt 42 KB ohne Datenpaket, waehrend + eine echte Kampagne dort 967 KB MIT Datenpaket liefert. Die Kampagne + existiert also schlicht nicht als oeffentliche Seite. Kein Programm + kann sie von aussen lesen -- auch keines, das jemand anders baut. + + Was Filipe sieht, sieht nur sein Browser. Also wird genau das + gelesen: das Bild davon. + + --------------------------------------------------------------------- + HIER PASSIERT NICHTS AUSSER LESEN + + Keine Netzanfrage, keine Datenbank, kein Express, keine Datei, keine + Uhr, und auch KEINE Texterkennung. Text hinein, Daten heraus -- + dieselbe Regel wie bei kampagne-lesen.mjs, und aus demselben Grund: + So laesst sich das Auslesen gegen GESPEICHERTE Erkennungstexte + pruefen, ohne Tesseract, ohne Bild, ohne Server. Eine Pruefung, die + dafuer ein Bild durch die Texterkennung schicken muss, misst die + Tagesform von Tesseract statt unseren Code. + + Die Texterkennung selbst steht in workspace-kampagne.js -- an der + Stelle, an der es ohnehin einen Prozess und ein Dateisystem gibt. + + --------------------------------------------------------------------- + DIESELBE FORM WIE DER LINK-LESER + + `leseBild()` gibt `{ pflicht, kuer, hinweise, auszug }` zurueck -- + Feld fuer Feld dasselbe wie `leseKampagne()`. Das ist die wichtigste + Entscheidung in dieser Datei: Dadurch greifen Gliederung, + Geschenkplaketten, Aufklappen, Teilen, Steckbrief und Spaltenwahl + unveraendert weiter. Haette das Bild eine eigene Form, gaebe es zwei + Wege durch das Haus, und der zweite waere der, der beim naechsten + Umbau vergessen wird. + + --------------------------------------------------------------------- + DER ZEITRAUM WIRD ZWEIMAL GELESEN -- UND MUSS ZWEIMAL DASSELBE SAGEN + + Auf der Seite steht er an zwei Stellen, in zwei Schreibweisen: + + 10/01/2026 00:00:00 ~ 10/31/2026 22:59:59 (UTC+02:00) (oben) + Daten 01.10.2026 00:00 ~ 31.10.2026 22:59(UTC+02:00) (im Raster) + + Die obere ist amerikanisch (Monat zuerst), die untere deutsch (Tag + zuerst). Beide meinen den 1. bis 31. Oktober. Genommen wird die + DEUTSCHE -- sie ist eindeutig --, und die amerikanische dient als + Gegenprobe. Stimmen sie nicht ueberein, entsteht KEINE Kachel: + + Eine Kachel ohne Zeitraum ist unvollstaendig und sagt das selbst. + Eine Kachel mit dem FALSCHEN Zeitraum ist eine Falschauskunft -- sie + behauptet einen Termin, den sie nicht kennt, und man sieht es ihr + nicht an. Bei einer Texterkennung ist das keine theoretische Gefahr: + Aus einer 3 kann eine 8 werden, und „bis 08.10." statt „bis 03.10." + liest sich genauso plausibel. + ===================================================================== */ + +/** Was beim Lesen schiefgehen kann. Dieselbe Bauart wie + * `KampagneUnlesbar` in kampagne-lesen.mjs -- zwei Arten, damit die + * Route unterscheiden kann, was sie meldet. + * + * "pflicht" Titel oder Zeitraum fehlen / widersprechen sich + * "aufbau" das sieht nicht nach dieser Seite aus + */ +export class BildUnlesbar extends Error { + constructor(grund, art = "aufbau") { + super(grund); + this.name = "BildUnlesbar"; + this.art = art; + } +} + +/* --------------------------------------------------------------------- + MUSTER + + ALLE MIT TOLERANZ GEGEN DIE TEXTERKENNUNG. Gemessen an einer + nachgebauten Seite (server/pruefdaten/bild-agentur-*.txt): + + „Sichtbar für" kam als „Sichtbar für" durch -- gut + „22:59(UTC+02:00)" kam als „22:5XUTC+02:00)" durch -- die + Klammer ging verloren, die Ziffer auch + „Erstellt von" kam als „Erstellt von." durch -- ein Punkt + zu viel + „Creator*innen" kam als „Creator‘innen" und „Creatorinnen" + durch -- der Stern wandert + + Deshalb wird nirgends auf ein Satzzeichen vertraut, und hinter jedem + Schild steht `[\s.:]*` statt eines festen Trenners. Was dagegen NICHT + toleriert wird, sind die Ziffern des Zeitraums -- dafuer gibt es die + Gegenprobe oben. */ +const Z_DEUTSCH = new RegExp( + "Daten[\\s.:]*" + + "([0-9]{1,2})[.\\-/]([0-9]{1,2})[.\\-/]([0-9]{4})" + + "[^0-9]{0,12}(?:[0-9]{1,2}[:.][0-9]{2})?" + + "[\\s]*[~\\-–—][\\s]*" + + "([0-9]{1,2})[.\\-/]([0-9]{1,2})[.\\-/]([0-9]{4})", "i"); + +const Z_AMERIKANISCH = new RegExp( + "([0-9]{1,2})/([0-9]{1,2})/([0-9]{4})[\\s]+[0-9]{1,2}:[0-9]{2}:[0-9]{2}" + + "[\\s]*[~\\-–—][\\s]*" + + "([0-9]{1,2})/([0-9]{1,2})/([0-9]{4})", "i"); + +/* Eine Zonenangabe ist „Erdteil/Ort" -- und genau so steht sie da. + Ohne den Schraegstrich waere „Europe" allein keine Zone, die + `Intl.DateTimeFormat` kennt, und ein stillschweigendes Ausweichen + auf UTC waere ein anderer Tag (siehe tagInZone in + kampagne-lesen.mjs). */ +const ZONE = /Zeitzone[\s.:]*([A-Za-z]+\/[A-Za-z_+\-0-9]+)/i; + +/* DIE SCHILDER DES RASTERS -- mit ihrem GANZEN Namen. + + Die Texterkennung zieht die zwei Rasterspalten in EINE Zeile + zusammen: „Gruppenname X Zeitzone Y“. Gelesen wird deshalb + zeilenweise: Erst werden alle Schilder in der Zeile gesucht, + dann ist der Wert eines Schildes das, was bis zum NAECHSTEN + Schild steht. + + WARUM DER GANZE NAME (gemessen 08.10.2026, erster Lauf): + Mit dem Stueck „Sichtbar“ kam „Sichtbar für: für Privat“ heraus + -- das „für“ gehoert zum Schild, nicht zum Wert. Mit + „Anmeldung“ dasselbe: „Anmeldung erforderlich: erforderlich + Nein“. Ein halbes Schild laesst seine zweite Haelfte im Wert + stehen. + + UND WARUM „Creator“ KEIN SCHILD IST: Es steht mitten IM Wert + des Nachbarn („1 Creator Network, 55 Creator*innen“). Als + Schild gefuehrt schnitt es genau dort ab und machte aus der + Teilnehmerangabe „innen der. 1“. Das Schild heisst + „Creator*innen“ mit Stern -- und der Stern wandert bei der + Texterkennung, deshalb `Creator.{0,3}innen` und ein + Wortende dahinter. + + `muster` findet das Schild, `name` steht spaeter auf der + Kachel. Beides getrennt, weil das eine tolerant sein muss und + das andere lesbar. */ +const RASTER_SCHILDER = [ + { schluessel: "gruppenname", muster: /Gruppenname/i, name: "Gruppenname" }, + { schluessel: "zeitzone", muster: /Zeitzone/i, name: "Zeitzone" }, + { schluessel: "daten", muster: /Daten/i, name: "Daten" }, + { schluessel: "sichtbar", muster: /Sichtbar\s*f[uü]r/i, name: "Sichtbar für" }, + { schluessel: "erstellt", muster: /Erstellt\s*von/i, name: "Erstellt von" }, + /* „Teilnehmer*innen der ...“ -- auf dem Bild abgeschnitten, und + der Stern wandert. Der Punktrest am Ende gehoert zum Schild. */ + { schluessel: "teilnehmer", muster: /Teilnehmer.{0,3}innen(?:\s*der)?\s*\.{0,3}/i, + name: "Teilnehmer*innen" }, + { schluessel: "anmeldung", muster: /Anmeldung\s*erforderlich/i, + name: "Anmeldung erforderlich" }, + /* NUR AM ZEILENANFANG -- und das ist die wichtigste Zeile + dieser Liste (gemessen 08.10.2026, zweiter Lauf). + + „Creator*innen“ ist ein Schild UND steht zugleich mitten im + Wert des Nachbarn: „Teilnehmer*innen der ... 1 Creator + Network, 55 Creator*innen. Einzelheiten anzeigen“. Ohne diese + Einschraenkung schnitt es dort ab, und heraus kamen zwei + falsche Felder: „Teilnehmer*innen: 1 Creator Network, 55“ und + „Creator*innen: Einzelheiten anzeigen“. + + Im Raster steht dieses Schild in der LINKEN Spalte, also am + Zeilenanfang. Zwei Zeichen Spielraum fuer das, was die + Texterkennung links manchmal dazuerfindet. */ + { schluessel: "creator", muster: /Creator.{0,3}innen\b(?!\s*der)/i, + name: "Creator*innen", nurZeilenanfang: true }, +]; + +/* Aufzaehlungszeichen, wie die Texterkennung sie liefert. Gemessen: + Aus demselben runden Punkt wurden « (U+00AB) und ® (U+00AE) -- je + nach Zeile. Wer hier nur „•" erwartet, findet keine einzige + Aufgabe. */ +const AUFZAEHLUNG = /^[\s]*[«»®©@*•·▪◦‣>+\-–—]{1,3}[\s]*/u; + +/* DIE STATUSWORTE -- mit Toleranz gegen EINEN verlesenen + Anfangsbuchstaben (gemessen 08.10.2026): Aus „In Bearbeitung“ + wurde „in aearbeitung“. Das B vor „earbeitung“ ist genau das + Zeichen, das auf dunklem Grund am haeufigsten kippt. + + `name` ist, was auf der Kachel steht; `muster`, woran es + erkannt wird. Nie das Erkannte anzeigen -- sonst stuende + „in aearbeitung“ auf der Kachel. */ +const STATUSWORTE = [ + { name: "In Bearbeitung", muster: /\bin\s*\S?earbeitung\b/i }, + { name: "Nicht gestartet", muster: /\bnicht\s*\S?estartet\b/i }, + { name: "Beendet", muster: /\b\S?eendet\b/i }, + { name: "Abgeschlossen", muster: /\b\S?bgeschlossen\b/i }, + { name: "Entwurf", muster: /\b\S?ntwurf\b/i }, + { name: "Laufend", muster: /\b\S?aufend\b/i }, + { name: "Geplant", muster: /\b\S?eplant\b/i }, +]; + +/* --------------------------------------------------------------------- + KLEINE HELFER (alle rein) */ + +const zeilen = (text) => String(text || "") + .split(/\r?\n/).map((z) => z.trim()).filter(Boolean); + +const iso = (t, m, j) => `${j}-${String(m).padStart(2, "0")}-${String(t).padStart(2, "0")}`; + +/** Ist das ein moegliches Datum? Keine Uhr, kein Kalender -- nur die + * Frage, ob die Zahlen ueberhaupt einen Tag ergeben koennen. */ +function tagMoeglich(t, m, j) { + const tag = Number(t); + const monat = Number(m); + const jahr = Number(j); + if (!(monat >= 1 && monat <= 12)) return false; + if (!(jahr >= 2000 && jahr <= 2100)) return false; + const lang = [31, (jahr % 4 === 0 && jahr % 100 !== 0) || jahr % 400 === 0 ? 29 : 28, + 31, 30, 31, 30, 31, 31, 30, 31, 30, 31][monat - 1]; + return tag >= 1 && tag <= lang; +} + +/** Den Wert hinter einem Schild holen -- bis zum naechsten Schild. + * + * WARUM NICHT „bis zum Zeilenende": Die Texterkennung zieht die zwei + * Rasterspalten in EINE Zeile zusammen. „Gruppenname Star Light + * Zeitzone Europe/Amsterdam" wuerde sonst den halben Nachbarn + * mitnehmen. */ +/** Alle Rasterfelder einer Zeile -- zerlegt an ihren Schildern. + * + * Erst werden alle Schilder gesucht und nach ihrer Stelle + * sortiert; der Wert eines Schildes ist dann genau das, was bis + * zum naechsten steht. Das ist der Unterschied zu „bis zum + * Zeilenende“ (nimmt den Nachbarn mit) und zu „bis zum ersten + * Wort, das wie ein Schild aussieht“ (schneidet mitten im Wert, + * gemessen am 08.10.2026 bei „1 Creator Network“). + * + * JEDES SCHILD NUR EINMAL JE ZEILE: `exec` ohne `g` liefert den + * ERSTEN Treffer. Stuende eines zweimal, gewaenne das erste -- + * und das ist auf dieser Seite auch das richtige. */ +/* Was die Seite neben einen Wert stellt und was die Texterkennung + daraus macht -- beides gehoert nicht in die Kachel. + + DIE FRAGEZEICHEN-KREISE der Seite (ⓘ) werden je nach Zeile als + `®`, `©`, `@` oder `0` gelesen. Gemessen: „Nein ⓘ“ kam als + „Nein ®“ durch und „1 ⓘ“ als „10“ -- das erste laesst sich + entfernen, das zweite nicht (aus „10“ ist nicht mehr zu sehen, + dass die 0 ein Kreis war). Deshalb wird nur abgeraeumt, was + eindeutig keine Angabe ist: ein EINZELNES Sonderzeichen am Ende. + + DIE KNOPFBESCHRIFTUNGEN („Einzelheiten anzeigen“) stehen im + selben Rasterfeld wie der Wert. Auf einer Kachel waeren sie ein + Hinweis auf etwas, das man dort nicht anklicken kann. */ +const WERT_ZIERDE = /[\s]*[®©@*^~|]+[\s]*$/u; +const WERT_KNOEPFE = /(Einzelheiten|Mehr|Weniger|Details)\s+anzeigen\s*\.?\s*$/i; + +function wertSaeubern(roh) { + let w = String(roh || ""); + w = w.replace(/^[\s.:,]+/, "").replace(WERT_KNOEPFE, ""); + w = w.replace(WERT_ZIERDE, "").replace(/[\s.:,]+$/, ""); + return w.trim(); +} + +function rasterZeile(zeile) { + const stellen = []; + for (const s of RASTER_SCHILDER) { + const m = s.muster.exec(zeile); + if (!m || m.index < 0) continue; + if (s.nurZeilenanfang && m.index > 2) continue; + stellen.push({ ...s, von: m.index, bis: m.index + m[0].length }); + } + stellen.sort((a, b) => a.von - b.von); + const aus = {}; + for (let i = 0; i < stellen.length; i += 1) { + const ende = i + 1 < stellen.length ? stellen[i + 1].von : zeile.length; + const wert = wertSaeubern(zeile.slice(stellen[i].bis, ende)); + if (wert) aus[stellen[i].schluessel] = wert; + } + return aus; +} + +/** Alle Rasterfelder des ganzen Textes. Spaetere Zeilen + * ueberschreiben nicht: Das erste Vorkommen gewinnt, weil das + * Raster oben steht und weiter unten dieselben Woerter in + * Fliesstext auftauchen koennen. */ +function rasterFelder(alle) { + const aus = {}; + for (const z of alle) { + for (const [k, v] of Object.entries(rasterZeile(z))) { + if (aus[k] === undefined) aus[k] = v; + } + } + return aus; +} + +/** Den Titel aus der Kopfzeile schaelen. + * + * Rechts daneben stehen Bedienelemente („Kampagne duplizieren", + * „Weniger anzeigen") und links ein Statusabzeichen. Beides gehoert + * nicht in den Titel einer Kachel. */ +function titelSaeubern(roh) { + let t = String(roh || "").trim(); + for (const wort of ["Kampagne duplizieren", "Weniger anzeigen", "Mehr anzeigen", + "Kampagne bearbeiten", "Einzelheiten anzeigen"]) { + const i = t.search(new RegExp(wort.replace(/ /g, "[\\s]+"), "i")); + if (i > 0) t = t.slice(0, i); + } + for (const s of STATUSWORTE) t = t.replace(s.muster, " "); + /* Das Pfeilzeichen des Aufklappers und uebrig gebliebene Zierde. */ + t = t.replace(/[\s^~*_|]+$/u, "").replace(/^[\s*_|]+/u, ""); + return t.trim(); +} + +/** Welches Statuswort steht oben? Nur zur Auskunft, nichts haengt daran. */ +function statusAus(text) { + for (const s of STATUSWORTE) if (s.muster.test(String(text || ""))) return s.name; + return null; +} + +/* ===================================================================== + LESEN + ===================================================================== */ + +/** Liest den Erkennungstext eines Kampagnen-Bildschirmfotos. + * + * @param {string} text was die Texterkennung geliefert hat + * @returns {{pflicht: object, kuer: object, hinweise: string[], auszug: object}} + * @throws {BildUnlesbar} + */ +export function leseBild(text) { + const roh = String(text || ""); + const alle = zeilen(roh); + const hinweise = []; + + if (alle.length < 3) { + throw new BildUnlesbar( + "auf dem Bild war fast kein Text zu erkennen -- " + + `${alle.length} Zeile(n)`, "aufbau"); + } + + /* EINMAL ZERLEGEN, DANN BENUTZEN. Vorher wurde fuer jedes Feld + einzeln im ganzen Text gesucht -- und dabei schnitt die Suche + mitten in den Werten der Nachbarn ab (gemessen 08.10.2026: + aus „1 Creator Network, 55 Creator*innen“ wurde „innen der. + 1“). Jetzt wird das Raster zeilenweise zerlegt, einmal. */ + const feld = rasterFelder(alle); + + /* ---- PFLICHT: der Zeitraum ------------------------------------ */ + const d = Z_DEUTSCH.exec(roh); + const a = Z_AMERIKANISCH.exec(roh); + + let start = null; + let ende = null; + let quelle = null; + + if (d && tagMoeglich(d[1], d[2], d[3]) && tagMoeglich(d[4], d[5], d[6])) { + start = iso(d[1], d[2], d[3]); + ende = iso(d[4], d[5], d[6]); + quelle = "deutsch"; + } + + let gegenStart = null; + let gegenEnde = null; + if (a && tagMoeglich(a[2], a[1], a[3]) && tagMoeglich(a[5], a[4], a[6])) { + /* Monat zuerst -- deshalb a[2] als Tag und a[1] als Monat. */ + gegenStart = iso(a[2], a[1], a[3]); + gegenEnde = iso(a[5], a[4], a[6]); + if (!start) { start = gegenStart; ende = gegenEnde; quelle = "amerikanisch"; } + } + + if (!start || !ende) { + throw new BildUnlesbar( + /* ANFUEHRUNGSZEICHEN: oeffnend „ (U+201E), schliessend “ + (U+201C) -- NIE das gerade ". Das gerade beendet die + Zeichenkette, und der Uebersetzer meldet einen Fehler zwanzig + Zeilen weiter unten. Dieses Haus hat das schon zweimal einen + Abend gekostet. */ + "auf dem Bild war kein Zeitraum zu lesen -- gesucht wurde " + + "„Daten 01.10.2026 ... ~ 31.10.2026 ...“ und " + + "„10/01/2026 00:00:00 ~ 10/31/2026 22:59:59“", "pflicht"); + } + + /* DIE GEGENPROBE. Stehen beide Schreibweisen da und sagen + Verschiedenes, hat die Texterkennung sich bei einer Ziffer vertan + -- und welche die richtige ist, weiss niemand. Dann lieber keine + Kachel als eine mit einem erfundenen Termin. */ + if (quelle === "deutsch" && gegenStart + && (gegenStart !== start || gegenEnde !== ende)) { + throw new BildUnlesbar( + `der Zeitraum steht auf dem Bild zweimal und beide Male anders ` + + `(${start} bis ${ende} gegen ${gegenStart} bis ${gegenEnde}) -- ` + + "bitte ein schärferes Bild einfügen", "pflicht"); + } + if (!gegenStart) { + hinweise.push("Der Zeitraum konnte nur an einer Stelle des Bildes " + + "geprüft werden — bitte Anfang und Ende kurz nachsehen."); + } + + if (ende < start) { + throw new BildUnlesbar( + `das Ende liegt vor dem Anfang (${start} bis ${ende}) -- ` + + "da hat die Texterkennung sich vertan", "pflicht"); + } + + /* ---- PFLICHT: der Titel --------------------------------------- */ + /* ERST „Gruppenname", DANN die Kopfzeile. Im Raster steht der Titel + ohne Abzeichen und ohne Bedienelemente daneben -- also sauberer. + Die Kopfzeile ist die Ausweiche. */ + let titel = titelSaeubern(feld.gruppenname || ""); + if (!titel || titel.length < 2) titel = titelSaeubern(alle[0]); + if (!titel || titel.length < 2) { + throw new BildUnlesbar( + "auf dem Bild war kein Titel zu lesen", "pflicht"); + } + titel = titel.slice(0, 160); + + /* ---- Zeitzone -------------------------------------------------- */ + const zoneTreffer = ZONE.exec(roh); + const zone = zoneTreffer ? zoneTreffer[1] : null; + if (!zone) { + hinweise.push("Die Zeitzone war auf dem Bild nicht zu lesen — " + + "die Tage stehen so da, wie sie auf dem Bild standen."); + } + + /* ---- Die Aufgaben ---------------------------------------------- */ + /* WO SIE ANFANGEN: nach der Phasenueberschrift. Steht die nicht da, + werden alle Aufzaehlungszeilen genommen -- auf dieser Seite gibt + es oberhalb keine. */ + const phaseAb = alle.findIndex((z) => + /Ziele erreichen|Phasendetails|Endet in|Endetin/i.test(z)); + /* AB DER ZEILE DANACH, nicht ab ihr selbst (berichtigt + 08.10.2026). + + Die Phasenueberschrift traegt auf der Seite ein Zielscheiben- + Zeichen, und die Texterkennung macht daraus ein `@`. Damit + sah sie aus wie eine Aufzaehlungszeile, und die erste + „Aufgabe“ hiess „Ziele erreichen Endetin 23 Tage 22 : 50 : + 44“ -- ein Haken, den niemand abhaken kann. + + Die Zeile selbst ist die Ueberschrift; was zaehlt, steht + darunter. */ + const ab = phaseAb >= 0 ? phaseAb + 1 : 0; + const aufgaben = []; + const gesehen = new Set(); + /* WAS NIE EINE AUFGABE IST (berichtigt 08.10.2026, zweiter + Lauf). `ab` reicht nicht: Gesucht wird die erste Zeile, die + nach der Phase aussieht, und das war „Phasendetails + Creator*innen-Leistung“ -- eine Zeile VOR der eigentlichen + Ueberschrift. Die rutschte dadurch wieder hinein, mitsamt + ihrem Countdown: „Ziele erreichen Endetin 23 Tage 22 : 50 : + 44“. + + Statt die Startzeile noch genauer zu suchen, wird gesagt, was + KEINE Aufgabe ist. Das haelt auch, wenn die Seite die Bloecke + einmal anders anordnet. */ + const KEINE_AUFGABE = /Endet\s*in|Endetin|Phasendetails|Leistung|Ziele erreichen/i; + for (const z of alle.slice(ab)) { + if (!AUFZAEHLUNG.test(z)) continue; + if (KEINE_AUFGABE.test(z)) continue; + /* EIN PUNKT AM ENDE IST FAST IMMER ZUFALL. Gemessen kam + „Geh LIVE für 900 Minuten.“ heraus, auf der Seite steht es + ohne Punkt. Eine Aufgabe ist eine Beschriftung, kein Satz; + und der Haken haengt an ihrem Text, also soll der stimmen. + Entfernt wird NUR ein einzelnes Satzzeichen am Ende -- ein + „...“ oder ein Fragezeichen bleibt stehen. */ + const nur = z.replace(AUFZAEHLUNG, "").trim().replace(/(? /Gruppenbeschreibung/i.test(z)); + if (beschrAb >= 0) { + for (const z of alle.slice(beschrAb + 1, beschrAb + 4)) { + if (/Phasendetails|Creator.{0,3}innen-Leistung|Ziele erreichen/i.test(z)) break; + if ((z.match(/\p{L}/gu) || []).length < 3) continue; + einleitung = z; + break; + } + } + + /* ---- Der Steckbrief -------------------------------------------- */ + /* Dieselben Felder wie beim Link-Leser, soweit es sie gibt. Was + leer ist, faellt weg statt als „—" zu erscheinen. */ + const steckbrief = { + nummer: null, + kurznummer: null, + region: null, + sprache: null, + verbund: feld.erstellt || null, + zeitzone: zone, + anmeldung_ab: null, + ergebnis_bis: null, + }; + + const kuer = { + einleitung, + einleitungTitel: null, + aufgaben, + aufgabenTitel: aufgaben.length ? "Ziele erreichen" : null, + aufgabenGruppen: [], + weitereRegeln: [], + geschenke: [], + geschenkTitel: null, + geschenkHinweis: null, + preise: [], + preisTitel: null, + klapptexte: [], + freitext: null, + rangliste: null, + steckbrief, + laender: [], + bannerUrl: null, + }; + + /* ---- Was sonst noch dastand ------------------------------------ */ + /* Als eigener Abschnitt im Regeltext -- mit „## " davor, damit die + Gliederung ihn als Ueberschrift erkennt (siehe gliederung() in + kampagne-lesen.mjs). */ + /* DIE NAMEN KOMMEN AUS DER SCHILDERLISTE, nicht aus einer + zweiten hier. Zwei Listen derselben Woerter laufen + auseinander, und dann heisst dasselbe Feld an zwei Stellen + verschieden. */ + const nebenbei = []; + /* „creator“ STEHT HIER ABSICHTLICH NICHT (08.10.2026). + Auf der Seite steht „1 ⓘ“ -- und der Fragezeichen-Kreis wird als + Null gelesen. Heraus kam „Creator*innen: 10“, also eine falsche + Zahl, und aus dem Erkennungstext ist nicht mehr zu sehen, ob die + 0 ein Kreis war oder eine Ziffer. Eine Zahl, der man nicht trauen + kann, gehoert nicht auf eine Kachel: Keine Angabe ist richtig, + eine falsche Angabe ist falsch. Die nuetzliche Zahl steht ohnehin + daneben („55 Creator*innen“ in der Teilnehmerzeile). */ + for (const schluessel of ["sichtbar", "teilnehmer", "anmeldung", "erstellt"]) { + const w = feld[schluessel]; + if (!w) continue; + const s = RASTER_SCHILDER.find((x) => x.schluessel === schluessel); + nebenbei.push(`${s ? s.name : schluessel}: ${w}`); + } + if (nebenbei.length) { + kuer.weitereRegeln.push({ titel: "Angaben der Kampagne", zeilen: nebenbei }); + } + + const status = statusAus(alle[0] || "") || statusAus(roh); + + return { + pflicht: { titel, start, ende, id: null, zone: zone || "" }, + kuer, + hinweise, + auszug: { + gelesen_aus: "bildschirmfoto", + sprache: "de-DE", + start_ms: null, + ende_ms: null, + zeitzone: zone, + zeitzone_versatz: null, + status: null, + status_anzeige: status, + kurznummer: null, + laender: [], + bausteine: [], + einleitung, + einleitungTitel: null, + aufgaben, + aufgabenTitel: kuer.aufgabenTitel, + aufgabenGruppen: [], + geschenke: [], + geschenkTitel: null, + geschenkHinweis: null, + preise: [], + preisTitel: null, + klapptexte: [], + rangliste: null, + steckbrief, + banner_url: null, + weitere_bilder: [], + woerterbuch_eintraege: 0, + platzhalter_ohne_eintrag: 0, + /* DER ERKANNTE TEXT KOMMT MIT. Geht spaeter etwas schief -- + ein Feld falsch, ein Datum daneben --, steht hier, WAS die + Texterkennung wirklich gesehen hat. Ohne ihn liesse sich ein + Fehlgriff nur nachstellen, indem man dasselbe Bild noch + einmal schickt, und das Bild hat vielleicht niemand mehr. + Gekuerzt, damit der Auszug nicht aufgeblaeht wird. */ + erkannter_text: roh.slice(0, 8000), + erkannte_zeilen: alle.length, + zeitraum_quelle: quelle, + zeitraum_gegenprobe: gegenStart ? `${gegenStart}..${gegenEnde}` : null, + }, + }; +} diff --git a/server/pruef-kampagne-bild.mjs b/server/pruef-kampagne-bild.mjs new file mode 100644 index 00000000..804d7686 --- /dev/null +++ b/server/pruef-kampagne-bild.mjs @@ -0,0 +1,403 @@ +/* ===================================================================== + EINE KAMPAGNE AUS EINEM BILDSCHIRMFOTO — GEPRUEFT (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." + + ZWEI DINGE, ZWEI MESSUNGEN, UND SIE HAENGEN NICHT ANEINANDER: + + 1. DAS AUSLESEN (Abschnitte 1-4). Text hinein, Daten heraus. Laeuft + ueberall, auch ohne Tesseract -- deshalb ist der Leser ueberhaupt + von der Texterkennung getrennt. Geprueft wird gegen einen + GESPEICHERTEN Erkennungstext, dessen Pruefsumme hier steht. + + 2. DIE TEXTERKENNUNG (Abschnitt 5). Bild hinein, Text heraus. Dafuer + braucht es Tesseract. Ist es nicht da, ist das Ergebnis NICHT + „bestanden" und auch nicht „uebersprungen", sondern der dritte + Ausgang: „konnte nicht nachsehen", mit Grund und Anleitung + (CLAUDE.md, 03.09.2026). + + WARUM DER ERKENNUNGSTEXT GESPEICHERT IST und nicht bei jedem Lauf + neu erzeugt wird: Sonst misst diese Pruefung die Tagesform von + Tesseract statt unseren Code. Faellt die Texterkennung einmal anders + aus, soll Abschnitt 5 rot werden -- und die Abschnitte 1-4 sollen + trotzdem weiter sagen, ob das Auslesen stimmt. + ===================================================================== */ +import { readFileSync, existsSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { notbremse } from "./helfer-notbremse.mjs"; + +notbremse(90_000, "pruef-kampagne-bild"); + +const HIER = dirname(fileURLToPath(import.meta.url)); +const DATEN = join(HIER, "pruefdaten"); + +let fehler = 0; +let geprueft = 0; +const melde = (t) => console.log(t); +const ok = (b, t) => { + geprueft++; + console.log((b ? " ok " : " FEHL ") + t); + if (!b) fehler++; +}; +/* Der dritte Ausgang: nicht in Ordnung, aber auch nicht „bestanden". */ +let offen = 0; +const nichtNachsehbar = (was, wie) => { + offen++; + melde(` -- NICHT NACHSEHBAR: ${was}`); + melde(` ${wie}`); +}; + +/* ---- Die gespeicherten Daten ---------------------------------- */ +const DATEIEN = { + text: { + name: "bild-agentur-erkannt.txt", + sha: "49de95db94482ebd", gross: 768, + was: "was Tesseract am 08.10.2026 aus dem Nachbau gelesen hat", + }, + bild: { + name: "bild-agentur-nachbau.png", + was: "der Nachbau von Filipes Bildschirmfoto, als Bild", + }, + seite: { + name: "bild-agentur-nachbau.html", + was: "derselbe Nachbau als Seite -- daraus entsteht das Bild neu", + }, +}; + +melde("=== 0. Sind die gespeicherten Daten da? ==="); +for (const [, d] of Object.entries(DATEIEN)) { + const pfad = join(DATEN, d.name); + if (!existsSync(pfad)) { + melde(` FEHL ${d.name} fehlt in ${DATEN}`); + melde(" Sie gehoert ins Verzeichnis und ist in Gitea versioniert."); + process.exit(3); + } + const roh = readFileSync(pfad); + const sha = createHash("sha256").update(roh).digest("hex").slice(0, 16); + if (d.sha) { + ok(sha === d.sha && roh.length === d.gross, + `${d.name}: ${roh.length} Bytes, sha ${sha} — ${d.was}` + + (sha === d.sha ? "" : ` (ERWARTET ${d.sha}/${d.gross})`)); + } else { + ok(roh.length > 100, `${d.name}: ${roh.length} Bytes — ${d.was}`); + } +} + +const { leseBild, BildUnlesbar } = await import("./kampagne-bild-lesen.mjs"); +const ERKANNT = readFileSync(join(DATEN, DATEIEN.text.name), "utf8"); + +/* ===================================================================== + 1. WAS AUF DER KACHEL LANDET + ===================================================================== */ +melde(""); +melde("=== 1. Aus dem Erkennungstext wird eine Kachel ==="); +{ + const g = leseBild(ERKANNT); + + ok(g.pflicht.titel === "[Star Light] Refined visuals Goal 1.0", + `der Titel: ${JSON.stringify(g.pflicht.titel)}`); + /* OHNE DAS STATUSABZEICHEN und ohne die Knoepfe daneben. Auf dem + Bild steht rechts „Kampagne duplizieren" und links „In + Bearbeitung" -- beides gehoert nicht in einen Kacheltitel. */ + ok(!/Bearbeitung|duplizieren|anzeigen/i.test(g.pflicht.titel), + " ohne Statusabzeichen und ohne die Knoepfe daneben"); + + ok(g.pflicht.start === "2026-10-01" && g.pflicht.ende === "2026-10-31", + `der Zeitraum: ${g.pflicht.start} bis ${g.pflicht.ende}`); + /* DIE DEUTSCHE SCHREIBWEISE GEWINNT -- sie ist eindeutig. Die + amerikanische oben (10/01/2026) ist die Gegenprobe. */ + ok(g.auszug.zeitraum_quelle === "deutsch", + `gelesen aus der deutschen Angabe (${g.auszug.zeitraum_quelle})`); + ok(g.auszug.zeitraum_gegenprobe === "2026-10-01..2026-10-31", + `und die amerikanische sagt dasselbe (${g.auszug.zeitraum_gegenprobe})`); + + ok(g.pflicht.zone === "Europe/Amsterdam", `die Zeitzone: ${g.pflicht.zone}`); + ok(g.auszug.status_anzeige === "In Bearbeitung", + `der Stand: ${JSON.stringify(g.auszug.status_anzeige)}`); + + /* ---- Die Ziele --------------------------------------------- */ + ok(g.kuer.aufgaben.length === 3, + `drei Ziele (${g.kuer.aufgaben.length}): ${JSON.stringify(g.kuer.aufgaben)}`); + ok(g.kuer.aufgaben[0] === "Geh LIVE für 900 Minuten", + " das erste wortgleich, ohne den Punkt, den die Texterkennung anhaengt"); + ok(g.kuer.aufgaben[2] === "An 20 Tagen Ziele für optimierte Bildqualität erreichen", + " und das dritte mit Umlauten"); + /* DIE PHASENUEBERSCHRIFT IST KEINE AUFGABE. Sie traegt auf der + Seite ein Zielscheiben-Zeichen, und daraus macht die + Texterkennung ein `@` -- sie sah damit aus wie eine + Aufzaehlungszeile. Beim ersten Lauf hiess die erste „Aufgabe" + wirklich „Ziele erreichen Endetin 23 Tage 22 : 50 : 44". */ + ok(!g.kuer.aufgaben.some((a) => /Endet|Ziele erreichen|Leistung/i.test(a)), + " und die Phasenueberschrift mit ihrem Countdown ist keine"); + + ok(g.kuer.einleitung === "Zusätzliche Prämien für das Team im Oktober.", + `die Beschreibung: ${JSON.stringify(g.kuer.einleitung)}`); + ok(g.kuer.steckbrief.verbund === "Spicy Media", + `erstellt von: ${JSON.stringify(g.kuer.steckbrief.verbund)}`); + + /* ---- Die Nebenangaben -------------------------------------- */ + const zeilen = g.kuer.weitereRegeln[0]?.zeilen || []; + ok(zeilen.includes("Sichtbar für: Privat"), + `„Sichtbar für" steht sauber da: ${JSON.stringify(zeilen)}`); + /* HALBE SCHILDER WAREN DER FEHLER. Mit dem Stueck „Sichtbar" kam + „Sichtbar für: für Privat" heraus -- das zweite Wort gehoert zum + Schild, nicht zum Wert. */ + ok(!zeilen.some((z) => /: (für|erforderlich|von|innen)/i.test(z)), + " und kein Schild steht doppelt im eigenen Wert"); + /* UND „Creator*innen" SCHNEIDET NICHT MEHR IM NACHBARN. Es steht + mitten in „1 Creator Network, 55 Creator*innen" und machte daraus + „1 Creator Network, 55". */ + ok(zeilen.some((z) => /Teilnehmer.*55 Creator/i.test(z)), + " und die Teilnehmerzahl ist vollstaendig"); + /* DIE ZAHL, DER MAN NICHT TRAUEN KANN, FEHLT. Auf der Seite steht + „1 ⓘ" -- der Kreis wird als Null gelesen, heraus kaeme „10". + Keine Angabe ist richtig, eine falsche Angabe ist falsch. */ + ok(!zeilen.some((z) => /^Creator\*innen:/i.test(z)), + " und die unsichere Creator-Zahl steht gar nicht erst da"); + + /* ---- Die Form ist dieselbe wie beim Link-Leser -------------- */ + /* DAS IST DIE TRAGENDE ZUSICHERUNG DIESER DATEI. Weicht sie ab, + greifen Gliederung, Geschenke, Aufklappen und Teilen nicht mehr + -- und zwar still. */ + const { leseKampagne } = await import("./kampagne-lesen.mjs"); + const { brotliDecompressSync } = await import("node:zlib"); + const vomLink = leseKampagne(brotliDecompressSync( + readFileSync(join(DATEN, "kampagne-gipfelstuermer.html.br"))).toString("utf8")); + const felderBild = Object.keys(g.kuer).sort(); + const felderLink = Object.keys(vomLink.kuer).sort(); + ok(felderBild.join(",") === felderLink.join(","), + "kuer hat dieselben Felder wie beim Link-Leser " + + `(${felderBild.length} gegen ${felderLink.length})`); + const aBild = Object.keys(g.auszug).sort(); + const aLink = Object.keys(vomLink.auszug).sort(); + const fehltImBild = aLink.filter((f) => !aBild.includes(f)); + ok(fehltImBild.length === 0, + `und der Auszug laesst kein Feld des Link-Lesers aus` + + (fehltImBild.length ? `: ${fehltImBild.join(", ")}` : "")); + ok(["pflicht", "kuer", "hinweise", "auszug"].every((f) => f in g), + "und die vier Teile heissen gleich"); + + /* ---- Die Kachel sagt, woher sie kommt ---------------------- */ + ok(g.auszug.gelesen_aus === "bildschirmfoto", + `der Auszug nennt die Quelle (${g.auszug.gelesen_aus})`); + ok(typeof g.auszug.erkannter_text === "string" + && g.auszug.erkannter_text.includes("Star Light"), + `und traegt den Erkennungstext mit (${g.auszug.erkannter_text.length} Zeichen)`); +} + +/* ===================================================================== + 2. WAS NICHT GEHT — UND WARUM DAS RICHTIG IST + ===================================================================== */ +melde(""); +melde("=== 2. Lieber keine Kachel als eine mit erfundenem Termin ==="); +{ + const wirft = (text, art, was) => { + let f = null; + try { leseBild(text); } catch (x) { f = x; } + ok(f instanceof BildUnlesbar && f.art === art, + `${was} -> ${f ? `${f.name}/${f.art}` : "KEIN Fehler"}`); + return f; + }; + + wirft("", "aufbau", "gar kein Text"); + wirft("nur\nzwei", "aufbau", "fast kein Text"); + wirft("Gruppenname Test\nZeitzone Europe/Berlin\nnoch eine Zeile", + "pflicht", "ein Bild ohne Zeitraum"); + + /* DIE GEGENPROBE ZWISCHEN DEN ZWEI SCHREIBWEISEN -- der Kern + dieser Datei. Eine Texterkennung macht aus einer 3 eine 8, und + „bis 08.10." liest sich genauso plausibel wie „bis 03.10.". */ + const widerspruch = [ + "Kampagne X", + "10/01/2026 00:00:00 ~ 10/31/2026 22:59:59 (UTC+02:00)", + "Gruppenname Kampagne X Zeitzone Europe/Berlin", + "Daten 01.10.2026 00:00 ~ 28.10.2026 22:59", + ].join("\n"); + const f = wirft(widerspruch, "pflicht", "zwei Zeitraeume, die sich widersprechen"); + ok(/zweimal und beide Male anders/.test(f?.message || ""), + ` und die Meldung sagt, was los ist: „${f?.message?.slice(0, 70)}…"`); + + /* GEGENPROBE ZUR GEGENPROBE: Stimmen beide ueberein, entsteht sehr + wohl eine Kachel. Ohne diese Zeile waere „es wirft" auch dann + gruen, wenn es IMMER wirft. */ + const einig = widerspruch.replace("28.10.2026", "31.10.2026"); + let g = null; + try { g = leseBild(einig); } catch { g = null; } + ok(g !== null && g.pflicht.ende === "2026-10-31", + `Gegenprobe: stimmen beide ueberein, entsteht die Kachel ` + + `(${g ? g.pflicht.ende : "KEINE"})`); + + /* Steht nur EINE Schreibweise da, geht es -- mit Hinweis. Das ist + der mittlere Fall: nicht falsch, aber auch nicht doppelt + geprueft. */ + const nurDeutsch = "Kampagne Y\nGruppenname Kampagne Y Zeitzone Europe/Berlin\n" + + "Daten 05.11.2026 00:00 ~ 20.11.2026 22:59"; + const h = leseBild(nurDeutsch); + ok(h.pflicht.start === "2026-11-05" && h.pflicht.ende === "2026-11-20", + `eine Schreibweise allein reicht (${h.pflicht.start}..${h.pflicht.ende})`); + ok(h.hinweise.some((x) => /nur an einer Stelle/i.test(x)), + ` und die Kachel sagt, dass nicht gegengeprueft werden konnte`); + + /* Ein Ende vor dem Anfang ist ein Zahlendreher, kein Zeitraum. */ + wirft("Kampagne Z\nGruppenname Kampagne Z\nDaten 20.11.2026 ~ 05.11.2026", + "pflicht", "Ende vor Anfang"); + /* Und ein Datum, das es nicht gibt. */ + wirft("Kampagne Q\nGruppenname Kampagne Q\nDaten 31.02.2026 ~ 05.03.2026", + "pflicht", "der 31. Februar"); +} + +/* ===================================================================== + 3. DAS WASSERZEICHEN + ===================================================================== */ +melde(""); +melde("=== 3. Filipes Mailadresse liegt ueber dem ganzen Bild ==="); +{ + /* Auf seinen Bildschirmfotos steht sie vielfach und schraeg ueber + der Seite. Gemessen: Sie taucht im Erkennungstext kein einziges + Mal auf -- zu wenig Kontrast. Das ist Glueck, kein Verdienst, und + deshalb wird es festgehalten: Wuerde eine kuenftige + Tesseract-Fassung sie lesen, stuende sie mitten in den Zielen. */ + ok(!/dogfather1608|gmail/i.test(ERKANNT), + "das Wasserzeichen steht nicht im Erkennungstext"); + const g = leseBild(ERKANNT); + const alles = JSON.stringify(g); + ok(!/dogfather1608|gmail/i.test(alles), + "und damit auch nirgends in der Kachel"); + /* GEGENPROBE: Stuende es doch im Text, wuerde es auch in den Zielen + landen -- also waere die Zeile oben nicht nur Zierde. */ + const mitWasser = ERKANNT.replace("« 15 gültige", "« dogfather1608@gmail.com 15 gültige"); + const g2 = leseBild(mitWasser); + ok(g2.kuer.aufgaben.some((a) => /dogfather1608/.test(a)), + " Gegenprobe: stuende es im Text, stuende es auch in den Zielen"); +} + +/* ===================================================================== + 4. KEINE UHR, KEIN NETZ, KEINE DATENBANK + ===================================================================== */ +melde(""); +melde("=== 4. Der Leser haengt an nichts ==="); +{ + /* OHNE DIE KOMMENTARE (berichtigt 08.10.2026, erster Lauf). + Die Datei ERKLAERT, warum sie die Texterkennung nicht aufruft -- + und nannte sie dabei beim Namen. Die Muster unten fanden + „execFile" und „Tesseract" in genau diesen Saetzen und meldeten + einen Schaden, den es nicht gab. + + Das ist derselbe Fehlgriff wie am 07.10.2026 beim Teilen, wo das + Wort „agentur" im eingebetteten Stilblatt fuer ein Leck gehalten + wurde: Eine Pruefung, die eine Erklaerung fuer die Sache haelt, + meldet jeden Tag einen Schaden -- und wird nach dem zweiten Mal + weggeklickt. + + Gemessen wird der CODE. Der Kommentarraeumer ist absichtlich + schlicht; er muss nur Kommentare entfernen, nicht JavaScript + verstehen. */ + const mitAllem = readFileSync(join(HIER, "kampagne-bild-lesen.mjs"), "utf8"); + const quelle = mitAllem + .replace(/\/\*[\s\S]*?\*\//g, " ") + .replace(/(^|[^:])\/\/.*/g, "$1 "); + const verboten = [ + [/\bimport\s/, "ein import"], + [/\bfetch\s*\(/, "fetch"], + [/\bDate\.now\s*\(/, "Date.now"], + [/new\s+Date\s*\(\s*\)/, "new Date() ohne Angabe"], + [/\bdb\s*\(/, "die Datenbank"], + [/readFileSync|writeFileSync/, "das Dateisystem"], + /* `(? m.test(quelle)); + ok(drin.length === 0, + `die Datei fuehrt NICHTS ein und fasst nichts an` + + (drin.length ? ` — gefunden: ${drin.map((x) => x[1]).join(", ")}` : "")); + /* GEGENPROBE: Die Muster finden sehr wohl etwas, wenn es dasteht. + Ohne sie waere die Zeile oben auch bei kaputten Mustern gruen. */ + ok(verboten.filter(([m]) => m.test('import x from "y"; fetch(); db(); tesseract')).length >= 4, + " Gegenprobe: die Muster schlagen an, wenn es wirklich dasteht"); + /* UND DER KOMMENTARRAEUMER WIRFT NICHT ALLES WEG. Ohne diese Zeile + waere die Pruefung oben auch dann gruen, wenn `quelle` leer ist -- + ein gruener Haken ueber einem leeren Feld. */ + ok(quelle.length > 1500 && quelle.includes("export function leseBild"), + ` und der Code selbst steht noch da (${quelle.length} von ` + + `${mitAllem.length} Zeichen, Rest sind Kommentare)`); + ok(/execFile|tesseract/i.test(mitAllem), + " Gegenprobe: in den KOMMENTAREN kommen die Woerter sehr wohl vor"); + + /* GLEICHE EINGABE, GLEICHES ERGEBNIS. Eine Lesefunktion mit einer + Uhr darin liefert zu verschiedenen Zeiten Verschiedenes -- und + eine Pruefung darauf ist eine Zeitbombe. */ + const a = JSON.stringify(leseBild(ERKANNT)); + const b = JSON.stringify(leseBild(ERKANNT)); + ok(a.length > 500 && a === b, + `zwei Laeufe, ${a.length} Zeichen, Zeichen fuer Zeichen gleich`); +} + +/* ===================================================================== + 5. DIE TEXTERKENNUNG SELBST + ===================================================================== */ +melde(""); +melde("=== 5. Vom Bild zum Text ==="); +{ + let da = false; + try { + execFileSync("tesseract", ["--version"], { stdio: "ignore", timeout: 10_000 }); + da = true; + } catch { da = false; } + + if (!da) { + nichtNachsehbar( + "Tesseract ist auf dieser Maschine nicht eingerichtet — " + + "der Schritt vom BILD zum TEXT wurde nicht gemessen.", + "Auf dem Server ist es vorhanden (5.5.0 mit deutschen Sprachdaten, " + + "gemessen 08.10.2026); dort misst dieser Abschnitt wirklich. " + + "Lokal nachruesten: winget install UB-Mannheim.TesseractOCR — " + + "die Abschnitte 1 bis 4 laufen auch ohne."); + } else { + const bild = join(DATEN, DATEIEN.bild.name); + let text = ""; + try { + text = execFileSync("tesseract", + [bild, "stdout", "-l", "deu", "--psm", "4"], + { encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "ignore"] }); + } catch (f) { + text = ""; + melde(` -- Tesseract brach ab: ${f?.message?.slice(0, 80)}`); + } + ok(text.length > 200, `aus dem Bild kommen ${text.length} Zeichen Text`); + + /* NICHT „Zeichen fuer Zeichen gleich wie gespeichert": Eine + andere Tesseract-Fassung liest ein Komma anders, und dann waere + diese Pruefung rot, ohne dass etwas kaputt ist. Gemessen wird, + was zaehlt -- dass die KACHEL dieselbe wird. */ + let g = null; + try { g = leseBild(text); } catch (f) { void f; g = null; } + ok(g !== null, "und der Leser macht daraus eine Kachel"); + ok(g?.pflicht.titel === "[Star Light] Refined visuals Goal 1.0", + `mit demselben Titel: ${JSON.stringify(g?.pflicht.titel)}`); + ok(g?.pflicht.start === "2026-10-01" && g?.pflicht.ende === "2026-10-31", + `demselben Zeitraum: ${g?.pflicht.start}..${g?.pflicht.ende}`); + ok(g?.kuer.aufgaben.length === 3, + `und denselben drei Zielen (${g?.kuer.aufgaben.length})`); + } +} + +melde(""); +melde(`${geprueft} Pruefungen, ${fehler} Fehler` + + (offen ? `, ${offen} nicht nachsehbar` : "")); +if (!geprueft) { + melde("ABBRUCH: nichts geprueft -- das ist ein Fehler, kein Erfolg."); + process.exit(3); +} +melde(fehler ? "NICHT IN ORDNUNG" : (offen ? "IN ORDNUNG, mit Luecke" : "ALLES IN ORDNUNG")); +process.exit(fehler ? 1 : 0); diff --git a/server/pruefdaten/bild-agentur-erkannt.txt b/server/pruefdaten/bild-agentur-erkannt.txt new file mode 100644 index 00000000..e464ad16 --- /dev/null +++ b/server/pruefdaten/bild-agentur-erkannt.txt @@ -0,0 +1,22 @@ +[Star Light] Refined visuals Goal 1.0 in aearbeitung Kampagne duplizieren Weniger anzeigen * +10/01/2026 00:00:00 - 10/31/2026 22:59:59 (UTC+02:00) + +Grundlegende Informationen +Gruppenname [Star Light] Refined visuals Goal 1.0 Zeitzone Europe/Amsterdam + +Daten 01.10.2026 00:00 - 31.10.2026 22:5XUTC+02:00) Sichtbar für Privat. + +Erstellt von. Spicy Media Teilnehmerinnen der. 1 Creator Network, 55 Creator*innen. Einzelheiten anzeigen +Creator*innen 10 Anmeldung erforderlich Nein ® + +Gruppenbeschreibung + +Zusätzliche Prämien für das Team im Oktober. + +Phasendetails _Creator‘innen-Leistung + +@ Ziele erreichen Endetin 23 Tage 22 : 50 : 44 + +«Geh LIVE für 900 Minuten. +« 15 gültige LIVE-Gehen-Tage haben +® An 20 Tagen Ziele für optimierte Bildqualität erreichen diff --git a/server/pruefdaten/bild-agentur-nachbau.html b/server/pruefdaten/bild-agentur-nachbau.html new file mode 100644 index 00000000..3614a62c --- /dev/null +++ b/server/pruefdaten/bild-agentur-nachbau.html @@ -0,0 +1,128 @@ + + + + + + + +
+ +
+
+

【Star Light】Refined visuals Goal 1.0

+ In Bearbeitung + Kampagne duplizierenWeniger anzeigen ⌃ +
+

10/01/2026 00:00:00 ~ 10/31/2026 22:59:59 (UTC+02:00)

+ +

Grundlegende Informationen

+
+ Gruppenname + 【Star Light】Refined visuals Goal 1.0 + Zeitzone + Europe/Amsterdam + + Daten + 01.10.2026 00:00 ~ 31.10.2026 22:59(UTC+02:00) + Sichtbar für + Privat + + Erstellt von + Spicy Media + Teilnehmer*innen der ... + 1 Creator Network, 55 Creator*innen. Einzelheiten anzeigen + + Creator*innen + 1 ⓘ + Anmeldung erforderlich + Nein ⓘ +
+ +

Gruppenbeschreibung

+

Zusätzliche Prämien für das Team im Oktober.

+
+ +
+
PhasendetailsCreator*innen-Leistung
+
+
+

🎯 Ziele erreichen

+ Endet in 23 Tage 22 : 50 : 44 +
+
    +
  • Geh LIVE für 900 Minuten
  • +
  • 15 gültige LIVE-Gehen-Tage haben
  • +
  • An 20 Tagen Ziele für optimierte Bildqualität erreichen
  • +
+
+
+ + + + diff --git a/server/pruefdaten/bild-agentur-nachbau.png b/server/pruefdaten/bild-agentur-nachbau.png new file mode 100644 index 0000000000000000000000000000000000000000..6af431c1eabeb7c05dc5314fb1af2fcb654301c8 GIT binary patch literal 47330 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 literal 0 HcmV?d00001 diff --git a/server/workspace-kampagne.js b/server/workspace-kampagne.js index 4527e22f..e36578a0 100644 --- a/server/workspace-kampagne.js +++ b/server/workspace-kampagne.js @@ -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 */ } } + } + }); diff --git a/workspace/assets/css/bereich.css b/workspace/assets/css/bereich.css index c31fb230..4364a705 100644 --- a/workspace/assets/css/bereich.css +++ b/workspace/assets/css/bereich.css @@ -4222,3 +4222,76 @@ body[data-ton] .kopf-zeile::before { .kampagneholen__seite { border: 1px solid CanvasText; } .kampagneholen__seite:has(input:checked) { border: 2px solid Highlight; } } + +/* ===================================================================== + EIN BILDSCHIRMFOTO STATT EINES LINKS (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.“ + + Drei Wege zum selben Ziel — einfügen, hereinziehen, auswählen. Der + Kasten muss deshalb beim Hereinziehen sichtbar antworten: Ein Bild + loszulassen und nicht zu wissen, ob es angekommen ist, ist der + Moment, in dem man es ein zweites Mal versucht. + ===================================================================== */ +.kampagneholen__bild { + display: flex; flex-wrap: wrap; align-items: center; + gap: 8px 12px; + margin: 12px 0 0; +} +.kampagneholen__bild .feld-hinweis { margin: 0; } + +/* Der stille Knopf: ein `label` zum versteckten Dateifeld. Er sieht + aus wie ein Knopf, weil er einer ist — aber er tritt zurück, denn + „Kampagne holen“ bleibt die Haupthandlung. + + HIESS ZUERST `.knopf--still` (berichtigt 08.10.2026). Das klang + nach einem allgemeinen Knopf-Zusatz für das ganze Haus — und + pruef-css-klassen hatte recht, als sie ihn auf drei Seiten + vermisste: Ein Zusatz im `knopf`-Namensraum verspricht, überall + verwendbar zu sein. Er gilt aber genau für diese eine Stelle, also + trägt er auch ihren Namen. */ +.kampagneholen__waehlen { + background: transparent; + border: 1px dashed color-mix(in srgb, var(--rand) 85%, transparent); + color: var(--text-leise); + cursor: pointer; +} +.kampagneholen__waehlen:hover, .kampagneholen__waehlen:focus-visible { + color: var(--text); + border-style: solid; + border-color: color-mix(in srgb, var(--akzent, #3fbdf5) 45%, transparent); +} +/* Das Dateifeld ist `hidden`; sein Fokus wandert sichtbar auf das + Etikett, sonst wäre der Knopf mit der Tastatur unsichtbar. */ +.kampagneholen__bild input:focus-visible + label, +.kampagneholen__bild input:focus-visible ~ label { + outline: 2px solid var(--akzent, #3fbdf5); + outline-offset: 2px; +} + +/* WÄHREND ETWAS DARÜBER SCHWEBT. Kein Blinken, kein Springen — ein + ruhiger Rahmen in der Hausfarbe reicht, und er lässt die Höhe + unverändert (`inset`-Schatten statt Rand, sonst ruckt der ganze + Kasten um zwei Pixel). */ +.kampagneholen[data-zieht="ja"] { + box-shadow: inset 0 0 0 2px color-mix(in srgb, var(--akzent, #3fbdf5) 60%, transparent); +} +.kampagneholen[data-zieht="ja"] .kampagneholen__bild .feld-hinweis { + color: var(--text); +} + +@media (max-width: 560px) { + /* 44 px Tippziel — dieselbe Vorgabe wie überall im Haus. Auf dem + Handy ist „auswählen“ der einzige der drei Wege, den es gibt. */ + .kampagneholen__waehlen { min-height: 44px; } +} + +@media (forced-colors: active) { + .kampagneholen__waehlen { border: 1px solid CanvasText; } + .kampagneholen[data-zieht="ja"] { outline: 2px solid Highlight; } +} + +@media (prefers-reduced-motion: reduce) { + .kampagneholen__waehlen { transition: none; } +} diff --git a/workspace/assets/js/bereich.js b/workspace/assets/js/bereich.js index 9f35c291..82c3a6b5 100644 --- a/workspace/assets/js/bereich.js +++ b/workspace/assets/js/bereich.js @@ -2865,6 +2865,10 @@ const feld = $('kampagne-text'); const knopf = $('kampagne-holen'); const sagt = $('kampagne-sagt'); + /* DER GANZE KASTEN, nicht nur das Textfeld: Daran haengen + Einfuegen und Hereinziehen weiter unten. Wer ein Bild einfuegt, + soll nicht erst in das Textfeld klicken muessen. */ + const kasten = $('kampagneholen') || form; const ruheText = sagt ? sagt.textContent : ''; /* DER KNOPF GIBT SICH VON SELBST WIEDER FREI. @@ -2882,10 +2886,15 @@ zweites Mal. */ const FREI_NACH_MS = 15000; let wecker = null; - const sperren = () => { + /* `satz` SEIT DEM 08.10.2026: Beim Bild steht dort „Bild wird + gelesen …“, beim Link „Wird geholt …“. Dieselbe Sperre, + dieselbe Zeile -- nur das Wort passt zu dem, was wirklich + laeuft. „Wird geholt“ waere beim Bild schlicht falsch: Es + wird nichts geholt, es wird gelesen. */ + const sperren = (satz) => { knopf.disabled = true; form.dataset.laeuft = 'ja'; - if (sagt) sagt.textContent = 'Wird geholt …'; + if (sagt) sagt.textContent = satz || 'Wird geholt …'; clearTimeout(wecker); wecker = setTimeout(() => { knopf.disabled = false; @@ -2901,6 +2910,97 @@ if (sagt) sagt.textContent = text || ruheText; }; + /* ==== EIN BILDSCHIRMFOTO STATT EINES LINKS (08.10.2026) ==== + + Filipe: „bei den agentur events soll ich kein link sondern + immer so ein screen reinschicke.“ + + DERSELBE KASTEN, DERSELBE KNOPFZUSTAND, DIESELBE + MELDEZEILE. Ein zweiter Kasten daneben hiesse: zweimal + dasselbe lernen, und beim naechsten Umbau laufen beide + auseinander. Nur der Weg zum Server ist ein anderer, weil + ein Bild keine Zeichenkette ist. + + ROHE BYTES, BESCHRIFTUNG IM KOPF -- so nimmt dieses Haus + jede Datei entgegen (siehe workspace-dateien.js). Kein + multipart/form-data von Hand. */ + const bildSenden = async (datei) => { + if (!datei) return; + if (!/^image\//.test(datei.type || '')) { + freigeben('Das war kein Bild. Es geht ein Bildschirmfoto als PNG oder JPEG.'); + return; + } + sperren('Bild wird gelesen …'); + try { + const gewaehlt = form.querySelector('input[name="kampagne-seite"]:checked'); + const a = await hole('/workspace/api/kampagne/bild', { + method: 'POST', + headers: { + 'Content-Type': datei.type || 'application/octet-stream', + 'x-seite': gewaehlt ? gewaehlt.value : '', + }, + body: datei, + }); + const r = await a.json().catch(() => ({})); + if (!a.ok) { + freigeben(sagWas(r.fehler, + `Der Server hat abgelehnt (${a.status}), ohne den Grund mitzuschicken.`)); + return; + } + if (r.schon) { + freigeben('Diese Kampagne war schon da — die Kachel steht unten.'); + } else { + const spanne = r.von && r.bis ? ` (${r.von} bis ${r.bis})` : ''; + const zahl = (r.aufgaben || []).length; + freigeben(`„${r.titel}“${spanne} steht unten — ` + + `${zahl} Ziel(e) gelesen. Bitte Titel und Zeitraum nachsehen.`); + } + await laden(); + } catch (f) { + void f; /* umgeleitet -- holen.js schickt bei 401 zur Anmeldung */ + } + }; + + /* EINFÜGEN. Das ist der Weg, den Filipe meint: Win+Umschalt+S, + dann Strg+V. Der Zuhörer haengt am ganzen Kasten, nicht nur + am Textfeld -- sonst muss man erst hineinklicken. */ + kasten.addEventListener('paste', (ev) => { + const dinge = [...(ev.clipboardData?.items || [])]; + const bild = dinge.find((x) => x.kind === 'file' && /^image\//.test(x.type)); + if (!bild) return; /* Text eingefuegt: alles wie bisher */ + ev.preventDefault(); + bildSenden(bild.getAsFile()); + }); + + /* HEREINZIEHEN. `dragover` muss abgefangen werden, sonst oeffnet + der Browser das Bild einfach in der Seite -- und die Arbeit + waere weg. */ + kasten.addEventListener('dragover', (ev) => { + if (![...(ev.dataTransfer?.types || [])].includes('Files')) return; + ev.preventDefault(); + kasten.dataset.zieht = 'ja'; + }); + kasten.addEventListener('dragleave', () => { delete ziel.dataset.zieht; }); + kasten.addEventListener('drop', (ev) => { + const d = ev.dataTransfer?.files?.[0]; + if (!d) return; + ev.preventDefault(); + delete kasten.dataset.zieht; + bildSenden(d); + }); + + /* AUSWÄHLEN -- der Weg auf dem Handy, wo es kein Strg+V gibt. + Das Feld wird danach geleert: Sonst laesst sich dasselbe Bild + kein zweites Mal waehlen (`change` kommt nur bei Aenderung). */ + const bildfeld = $('kampagne-bilddatei'); + if (bildfeld) { + bildfeld.addEventListener('change', () => { + const d = bildfeld.files?.[0]; + bildfeld.value = ''; + bildSenden(d); + }); + } + form.addEventListener('submit', async (ev) => { ev.preventDefault(); const text = (feld?.value || '').trim(); diff --git a/workspace/bereich.html b/workspace/bereich.html index 695a352a..498cd1e1 100644 --- a/workspace/bereich.html +++ b/workspace/bereich.html @@ -17,7 +17,7 @@ - + @@ -399,8 +399,27 @@ Agentur + +
+ + + … oder hier einfügen (Strg+V) oder hereinziehen. +
-

Titel, Zeitraum, Banner, Aufgaben und Preise kommen von selbst.

+

Aus dem Link kommen Titel, Zeitraum, Banner, Aufgaben und Preise von selbst — aus einem Bildschirmfoto Titel, Zeitraum und Ziele.

@@ -427,6 +446,6 @@ - +