/* ===================================================================== WIE STEHEN DIE KACHELN — je Rolle, Gruppe für Gruppe (24.09.2026) --------------------------------------------------------------------- WARUM ES DIESE DATEI GIBT Filipe schickt seine Wünsche zur Startseite fast immer als Bildschirmfoto: „die kachel auf screen 2 soll zwischen den kacheln auf screen3 sein", „wechsel bitte die reihenfolge, links soll unterstützen sein". Beides sind Fragen nach einer REIHE, nicht nach einer Liste. Und genau da liegt die Falle: Die Reihenfolge im Quelltext ist NICHT die Reihenfolge auf dem Bildschirm. * Das Raster hat drei Spalten, „Willkommen" belegt zwei — alles danach verschiebt sich um eins. * Gruppiert wird nach dem ERSTEN Auftreten einer Gruppe im Feld; eine Kachel am Ende der Liste kann dadurch mitten auf der Seite landen. * Und eine Kachel ohne `gruppe` bekommt einen eigenen Abschnitt ohne Überschrift — genau so stand der Support einen halben Tag lang allein in einem namenlosen Kasten, und beim Lesen des Quelltextes sieht man das nicht. Wer das im Kopf ausrechnet, rechnet früher oder später falsch. Deshalb wird es hier GEMESSEN: angemeldet, im Browser, je Rolle. Sie prüft NICHTS und meldet keinen Fehlschlag. Sie beantwortet eine Frage, die man vor jeder Umsortierung stellen muss. Sie heißt `mess-` und nicht `pruef-`: Die Portvergabe leitet sich aus den `pruef-`-Namen ab, und eine Messdatei darf die Nummern der Prüfungen nicht verschieben. Aufrufen mit: node server/mess-kachelreihen.mjs Nur eine Rolle: ROLLE=modi node server/mess-kachelreihen.mjs Bild: SCHIRM=1 node server/mess-kachelreihen.mjs ===================================================================== */ import { mkdtempSync, rmSync, readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { execFileSync } from "node:child_process"; import { request as httpAnfrage } from "node:http"; import { createServer as httpsServer } from "node:https"; const PORT = 5387, HP = 5388; const CREW = "crew.dogfather-universe.com"; const ordner = mkdtempSync(join(tmpdir(), "ws-kachelreihen-")); process.env.WORKSPACE_DB = join(ordner, "workspace.db"); process.env.PORT = String(PORT); process.env.SITE_ACCESS_SECRET = "lokaler-test"; process.env.SITE_PUBLIC_LAUNCH_AT = "2020-01-01T00:00:00+01:00"; const express = (await import("express")).default; const ec = express.response.cookie; express.response.cookie = function (n, w, o) { return ec.call(this, n, w, { ...(o || {}), secure: false }); }; import { notbremse } from "./helfer-notbremse.mjs"; import { zertifikatBauen } from "./helfer-openssl.mjs"; await import("./index.js"); notbremse(240_000, "mess-kachelreihen"); await new Promise((r) => setTimeout(r, 900)); process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); }); process.on("unhandledRejection", (f) => { console.error("ABSTURZ (Versprechen):", f); process.exit(7); }); const { DatabaseSync } = await import("node:sqlite"); const { scryptSync, randomBytes, createHmac } = await import("node:crypto"); const d = new DatabaseSync(process.env.WORKSPACE_DB); const jetzt = new Date().toISOString(); const sch = randomBytes(32).toString("hex"); d.prepare("INSERT INTO einstellungen (schluessel, wert, geaendert, von)" + " VALUES (?,?,?,NULL) ON CONFLICT(schluessel) DO NOTHING") .run("code_kennung_schluessel", sch, jetzt); const anlegen = (name, rolle, code) => { const s = randomBytes(16).toString("hex"); const h = scryptSync(code, s, 64, { N: 32768, r: 8, p: 1, maxmem: 96 * 1024 * 1024 }).toString("hex"); d.prepare("INSERT INTO personen" + " (name, rolle, code_hash, code_salt, code_n, code_kennung, aktiv, erstellt)" + " VALUES (?,?,?,?,?,?,1,?)") .run(name, rolle, h, s, 32768, createHmac("sha256", sch).update(code).digest("hex"), jetzt); }; /* Die fünf Rollen, die auf der Crew-Adresse eine Startseite haben. */ const ROLLEN = [ ["Filipe", "admin", "CODE-DOGI-0001"], ["VanVan", "hand", "CODE-HAND-0001"], ["Kessi", "linke", "CODE-LINKE-0001"], ["Diene", "modi", "CODE-MODI-0001"], ["Zuschauer", "gast", "CODE-GAST-0001"], ]; for (const [name, rolle, code] of ROLLEN) anlegen(name, rolle, code); d.close(); /* ÜBER DIE CREW-ADRESSE, nicht über 127.0.0.1: Welches Haus gemeint ist, leitet der Server aus dem `Host`-Kopf ab. Über die Zahladresse landet man im Agenturhaus, und dort gibt es weder `gast` noch `modi` — man misst dann eine Seite, die niemand so sieht. */ const anfrage = (pfad, art, koerper) => new Promise((fertig, schief) => { const a = httpAnfrage({ host: "127.0.0.1", port: PORT, path: pfad, method: art, headers: { Host: CREW, "Content-Type": "application/json" }, }, (r) => { let t = ""; r.on("data", (x) => { t += x; }); r.on("end", () => fertig({ code: r.statusCode, kopf: r.headers, text: t })); }); a.on("error", schief); if (koerper) a.write(JSON.stringify(koerper)); a.end(); }); const kekse = {}; for (const [, rolle, code] of ROLLEN) { const an = await anfrage("/workspace/api/anmelden", "POST", rolle === "gast" ? { rolle, code, alter_ok: true } : { rolle, code }); if (an.code !== 200) { console.log(` ABBRUCH: ${rolle} ließ sich nicht anmelden (HTTP ${an.code}). ` + "Ohne Anmeldung gibt es keine Startseite zu messen."); process.exit(2); } kekse[rolle] = (an.kopf["set-cookie"] || []).map((x) => x.split(";")[0]).join("; "); } const schl = join(ordner, "b.key"), zert = join(ordner, "b.crt"); zertifikatBauen(schl, zert, CREW); const vorbau = httpsServer({ key: readFileSync(schl), cert: readFileSync(zert) }, (von, nach) => { const w = httpAnfrage({ host: "127.0.0.1", port: PORT, path: von.url, method: von.method, headers: { ...von.headers, host: `${CREW}:${HP}` }, }, (a) => { nach.writeHead(a.statusCode || 502, a.headers); a.pipe(nach); }); von.pipe(w); }); await new Promise((r) => vorbau.listen(HP, "127.0.0.1", r)); const pw = await import( "file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs"); const browser = await pw.chromium.launch({ args: [`--host-resolver-rules=MAP ${CREW} 127.0.0.1`, "--ignore-certificate-errors"], }); try { const nur = process.env.ROLLE; for (const [, rolle] of ROLLEN) { if (nur && nur !== rolle) continue; const kontext = await browser.newContext({ viewport: { width: 1280, height: 1800 } }); await kontext.addCookies(kekse[rolle].split("; ").map((k) => { const [n, ...r] = k.split("="); return { name: n, value: r.join("="), domain: CREW, path: "/" }; })); const seite = await kontext.newPage(); const konsole = []; seite.on("console", (e) => { if (e.type() === "error" && !/favicon|ERR_CERT|net::ERR/i.test(e.text())) { konsole.push(e.text().slice(0, 110)); } }); await seite.goto(`https://${CREW}:${HP}/workspace/start.html`, { waitUntil: "networkidle" }); await seite.waitForTimeout(800); const gruppen = await seite.evaluate(() => [...document.querySelectorAll("section")].map((s) => { const titel = s.querySelector("h2, .gruppe__titel")?.textContent?.trim() || ""; const k = [...s.querySelectorAll(".kachel")].map((x) => { const r = x.getBoundingClientRect(); return { n: x.querySelector(".kachel__name")?.textContent, x: Math.round(r.x), y: Math.round(r.y), b: Math.round(r.width), }; }); return k.length ? { titel, k } : null; }).filter(Boolean)); console.log(""); console.log(`=== ${rolle} ===`); for (const g of gruppen) { /* NACH ZEILEN SORTIEREN, nicht nach Feldreihenfolge -- das ist ja gerade die Frage. Gerundet auf 20 px, damit zwei Kacheln derselben Zeile mit leicht verschiedener Oberkante zusammenbleiben. */ const zeilen = new Map(); for (const k of g.k) { const y = Math.round(k.y / 20) * 20; if (!zeilen.has(y)) zeilen.set(y, []); zeilen.get(y).push(k); } const sortiert = [...zeilen.entries()].sort((a, b) => a[0] - b[0]); const name = g.titel || "!! OHNE ÜBERSCHRIFT !!"; console.log(` [${name}]`); for (const [, ks] of sortiert) { console.log(" " + ks.sort((a, b) => a.x - b.x) .map((k) => `${k.n} (${k.b}px)`).join(" | ")); } } /* EIN ABSCHNITT OHNE ÜBERSCHRIFT IST EIN BEFUND, kein Zufall: Er entsteht, wenn eine Kachel keine `gruppe` hat. */ const ohne = gruppen.filter((g) => !g.titel); if (ohne.length) { console.log(` !! ${ohne.length} Abschnitt(e) OHNE Überschrift: ` + ohne.map((g) => g.k.map((k) => k.n).join(", ")).join(" / ")); } if (konsole.length) console.log(" !! Konsole: " + konsole[0]); if (process.env.SCHIRM) { /* MIT `GRUPPE="Für dich"` nur diesen einen Abschnitt -- ein Bild der ganzen Seite ist 1280 mal viertausend Pixel, und darauf sucht man die drei Kacheln, um die es gerade geht. */ const nurGruppe = process.env.GRUPPE; const window0 = await seite.evaluate(() => window.scrollY); const feld = nurGruppe ? await seite.evaluate((titel) => { const s2 = [...document.querySelectorAll("section")] .find((x) => (x.querySelector("h2, .gruppe__titel")?.textContent || "").trim() === titel); return s2 ? s2.getBoundingClientRect().toJSON() : null; }, nurGruppe) : null; const datei = `server/mess-kachelreihen-${rolle}.png`; try { if (feld) { /* MIT `fullPage`, auch beim Ausschnitt: Die Gruppe liegt meist unterhalb des Fensters, und ohne fullPage kennt Playwright dort keine Koordinaten -- der Aufruf scheitert. Gemessen am 24.09.2026, als genau das passierte. */ await seite.screenshot({ path: datei, fullPage: true, clip: { x: Math.max(0, feld.x - 10), y: Math.max(0, feld.y + window0 - 10), width: Math.min(1280, feld.width + 20), height: feld.height + 20 } }); } else { await seite.screenshot({ path: datei, fullPage: true }); } console.log(` -- Bild: ${datei}`); } catch (f) { /* NICHT STILL SCHEITERN. Ein Lauf, der erfolgreich aussieht und kein Bild hinterlaesst, kostet drei Wiederholungen. */ console.log(` !! Bild ging nicht: ${String(f?.message || f).slice(0, 120)}`); } } await kontext.close(); } } finally { await browser.close(); try { vorbau.close(); } catch { /* egal */ } try { rmSync(ordner, { recursive: true, force: true }); } catch { /* egal */ } process.exit(0); }