diff --git a/server/index.js b/server/index.js
index c1c6f938..a6be4e86 100644
--- a/server/index.js
+++ b/server/index.js
@@ -46,6 +46,7 @@ import { kiRouter } from "./workspace-ki.js";
import { contentRouter } from "./workspace-content.js";
import { sicherungRouter, sicherungStarten } from "./workspace-sicherung.js";
import { steckbriefRouter } from "./workspace-steckbrief.js";
+import { draussenRouter } from "./workspace-draussen.js";
import { vorlagenRouter } from "./workspace-vorlagen.js";
/* Die Arbeitslage von Managern und Scouts -- nur fuer Spicy Media und
DogFather. Sie ruehrt Termine, Chats und Dateien nicht an; die
@@ -217,6 +218,7 @@ app.use(sicherungRouter);
JSON in einem ergibt nur ein kaputtes Bild ohne jeden Hinweis.
Der Steckbrief bringt seine eigene Schranke mit. */
app.use(steckbriefRouter);
+app.use(draussenRouter);
/* Ebenfalls VOR aufgabenRouter: Der Service Worker holt den
oeffentlichen Schluessel, bevor irgendetwas anderes laeuft, und die
Push-Wege bringen ihre eigene Schranke mit. */
diff --git a/server/pruef-draussen.mjs b/server/pruef-draussen.mjs
new file mode 100644
index 00000000..2086fdd2
--- /dev/null
+++ b/server/pruef-draussen.mjs
@@ -0,0 +1,407 @@
+/* =====================================================================
+ DRAUSSEN -- der Puls auf der Community-Seite (21.09.2026)
+
+ Filipe: "mach was anderes aus dieser seite oder mach es viel krass
+ und geiler ... erstell was was mich von den socken schmeisst. es
+ soll wirklich sogar ein wow effekt fuer die community ergeben."
+
+ ---------------------------------------------------------------------
+ WAS HIER GEPRUEFT WIRD, UND WARUM AUSGERECHNET DAS
+
+ Die Seite beantwortet jetzt die Frage "laeuft er gerade?". Eine
+ Anzeige, die diese Frage beantwortet, hat genau eine Art, wirklich
+ Schaden anzurichten: Sie sagt "nicht live", ohne nachgesehen zu
+ haben. Wer das liest, macht die Seite zu -- und verpasst den Stream.
+
+ DAS IST KEIN GEDANKENSPIEL. Genau so macht es die oeffentliche
+ Seite: `streamplan.js` faengt jeden Fehler ab und setzt
+ `istLive = false`. Aus "wir konnten nicht fragen" wird "er sendet
+ nicht". Der Dienst liefert sogar `autoHealthy` mit, damit man das
+ unterscheiden kann -- gelesen wird es dort nicht.
+
+ DESHALB WIRD DIE STOERUNG HIER ABSICHTLICH HERGESTELLT. Vier Lagen,
+ vier Messungen, und die wichtigste ist die, in der nichts antwortet:
+
+ 1. er sendet -> "JETZT live", Weg zum Stream
+ 2. er sendet nicht -> Countdown
+ 3. der Pruefer ist gestoert -> "konnten wir nicht nachsehen"
+ 4. gar keine Antwort -> dasselbe, nicht "nicht live"
+
+ Die Lagen 3 und 4 tragen zusaetzlich eine GEGENPROBE: Es reicht
+ nicht, dass irgendein Text dasteht -- es darf ausdruecklich NICHT
+ "Gerade nicht live" dort stehen. Ohne diese Zeile waere die Pruefung
+ gruen, sobald ueberhaupt etwas angezeigt wird.
+
+ ---------------------------------------------------------------------
+ UND DIE ZAHL, DIE ES ZWEIMAL GIBT
+
+ Die Sendezeit (20 Uhr) steht im Server und in `streamplan.js` der
+ oeffentlichen Seite. Zwei Zahlen, die dasselbe bedeuten, laufen
+ irgendwann auseinander -- beide sehen fuer sich plausibel aus, und
+ es faellt erst auf, wenn jemand vergeblich wartet. Abschnitt 4 haelt
+ sie gegeneinander, ohne Netz.
+
+ Aufrufen mit: node server/pruef-draussen.mjs
+ ===================================================================== */
+
+import { mkdtempSync, rmSync, readFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { request as httpAnfrage } from "node:http";
+import { eigenerPort } from "./helfer-port.mjs";
+
+const PORT = await eigenerPort(import.meta, "pruef-draussen");
+const ordner = mkdtempSync(join(tmpdir(), "ws-draussen-"));
+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";
+await import("./index.js");
+notbremse(180_000, "pruef-draussen");
+await new Promise((r) => setTimeout(r, 700));
+
+process.on("uncaughtException", (f) => { console.error("ABSTURZ:", f); process.exit(7); });
+
+let fehler = 0, geprueft = 0;
+const melde = (t) => console.log(t);
+const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
+
+const CREW = "crew.dogfather-universe.com";
+const WURZEL = join(import.meta.dirname, "..");
+
+function roh(pfad, host, kopf = {}, koerper = null) {
+ return new Promise((fertig, schief) => {
+ const a = httpAnfrage({
+ host: "127.0.0.1", port: PORT, path: pfad,
+ method: koerper ? "POST" : "GET",
+ headers: { Host: host, ...(koerper ? { "Content-Type": "application/json" } : {}), ...kopf },
+ }, (antwort) => {
+ let text = "";
+ antwort.on("data", (s) => { text += s; });
+ antwort.on("end", () => fertig({ code: antwort.statusCode, text, kopf: antwort.headers }));
+ });
+ a.on("error", schief);
+ if (koerper) a.write(JSON.stringify(koerper));
+ a.end();
+ });
+}
+
+const { DatabaseSync } = await import("node:sqlite");
+const { scryptSync, randomBytes } = await import("node:crypto");
+{
+ const d = new DatabaseSync(process.env.WORKSPACE_DB);
+ for (const [name, rolle, code] of [["Mara", "gast", "CODE-GAST-0001"]]) {
+ const salz = randomBytes(16).toString("hex");
+ const hash = scryptSync(code, salz, 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, aktiv, erstellt)"
+ + " VALUES (?,?,?,?,?,1,?)")
+ .run(name, rolle, hash, salz, 32768, new Date().toISOString());
+ }
+ d.close();
+}
+
+async function anmelden(rolle, code, host) {
+ let a = await roh("/workspace/api/anmelden", host, {}, { rolle, code });
+ if (a.code === 400 && a.text.includes("alter_offen")) {
+ a = await roh("/workspace/api/anmelden", host, {}, { rolle, code, alter_ok: true });
+ }
+ if (a.code !== 200) throw new Error(`Anmeldung ${rolle}: ${a.code} ${a.text}`);
+ return [].concat(a.kopf["set-cookie"] || []).map((z) => z.split(";")[0]).join("; ");
+}
+const kGast = await anmelden("gast", "CODE-GAST-0001", CREW);
+
+const { KANAELE } = await import("./workspace.js");
+const { SENDEZEIT_STUNDE } = await import("./workspace-draussen.js");
+
+/* =======================================================================
+ 1. DER WEG ZU DEN KANAELEN
+ ======================================================================= */
+melde("");
+melde("=== 1. Die Kanaele kommen vom Server ===");
+{
+ const zu = await roh("/workspace/api/draussen", CREW);
+ ok(zu.code === 401, `ohne Anmeldung verschlossen (${zu.code})`);
+
+ const a = await roh("/workspace/api/draussen", CREW, { Cookie: kGast });
+ ok(a.code === 200, `ein Mitglied bekommt sie (${a.code})`);
+ const d = JSON.parse(a.text || "{}");
+ const liste = d.kanaele || [];
+
+ ok(liste.length === KANAELE.length && liste.length > 0,
+ `es sind ${liste.length} Kanaele, so viele wie im Haus (${KANAELE.length})`);
+
+ /* NICHT ABGESCHRIEBEN, SONDERN VERGLICHEN. Wuerde hier ein Handle
+ als Text stehen, waere diese Pruefung genau so lange gruen wie der
+ Tippfehler, den sie finden soll -- am 21.09. war das bei zwei
+ anderen Pruefungen der Fall. */
+ const falsch = liste.filter((k, i) => k.handle !== KANAELE[i].handle);
+ ok(falsch.length === 0,
+ falsch.length ? `ABWEICHUNG: ${falsch.map((k) => k.handle).join(", ")}`
+ : `die Handles stimmen mit KANAELE ueberein (${liste.map((k) => k.handle).join(", ")})`);
+
+ const schlechteAdresse = liste.filter(
+ (k) => !k.adresse || !k.adresse.startsWith("https://www.tiktok.com/@")
+ || !k.adresse.endsWith(k.handle.replace(/^@/, "")));
+ ok(schlechteAdresse.length === 0,
+ schlechteAdresse.length
+ ? `ADRESSE PASST NICHT ZUM HANDLE: ${schlechteAdresse.map((k) => k.adresse).join(", ")}`
+ : "jede Adresse endet auf ihrem eigenen Handle");
+
+ const ohneSatz = liste.filter((k) => !k.satz || k.satz.length < 10);
+ ok(ohneSatz.length === 0,
+ ohneSatz.length ? `ohne Satz: ${ohneSatz.map((k) => k.name).join(", ")}`
+ : "jeder Kanal sagt in einem Satz, was es dort gibt");
+
+ /* Die Saetze fuer draussen sind ANDERE als die fuer das Team. Stuende
+ dort derselbe Text, waere das Feld ueberfluessig -- und jemand
+ wuerde es beim naechsten Kanal weglassen. */
+ const gleich = liste.filter((k, i) => k.satz === KANAELE[i].text);
+ ok(gleich.length === 0,
+ gleich.length ? `${gleich.length} Kanaele zeigen den internen Text`
+ : "und es ist nicht der interne Text aus der Aufgabenverteilung");
+}
+
+/* =======================================================================
+ 2. DIE INHALTSRICHTLINIE MUSS DEN ABRUF ERLAUBEN
+ ======================================================================= */
+melde("");
+melde("=== 2. Darf die Seite den Live-Dienst ueberhaupt fragen? ===");
+{
+ /* WARUM DAS EINE EIGENE PRUEFUNG IST: Verbietet `connect-src` den
+ Abruf, bricht `fetch` im Browser ab -- und zwar mit demselben
+ Fehler wie ein toter Server. Die Seite landet in "unklar" und
+ sieht dabei voellig in Ordnung aus. Der Unterschied steht nur in
+ der Browserkonsole, die niemand aufmacht. */
+ const a = await roh("/workspace/unsere-seiten.html", CREW, { Cookie: kGast });
+ const regel = String(a.kopf["content-security-policy"] || "");
+ const zeile = regel.split(";").map((t) => t.trim())
+ .find((t) => t.startsWith("connect-src")) || "";
+ ok(zeile.length > 0, `es gibt eine connect-src-Regel (${zeile.slice(0, 60)}…)`);
+ ok(zeile.includes("postfach.dogfather-universe.com"),
+ "und sie fuehrt postfach.dogfather-universe.com");
+ ok(!zeile.includes("*") || zeile.includes("postfach"),
+ "Gegenprobe: die Erlaubnis steht namentlich da, nicht als Sternchen");
+}
+
+/* =======================================================================
+ 3. DIE SEITE SELBST
+ ======================================================================= */
+melde("");
+melde("=== 3. Was im Quelltext der Seite steht ===");
+{
+ const html = readFileSync(join(WURZEL, "workspace/unsere-seiten.html"), "utf8");
+
+ ok(/id="puls"/.test(html), "der Puls steht auf der Seite");
+ ok(/data-lage="laedt"/.test(html),
+ "und beginnt in der Lage „laedt“ -- nicht in „nicht live“");
+
+ /* KEIN HANDLE IM QUELLTEXT DER KANALREIHE. Der Stream-Knopf traegt
+ bewusst einen: Er ist der eine feste Weg zum Hauptkanal und muss
+ auch dann funktionieren, wenn der Server-Abruf scheitert. Alles
+ andere kommt vom Server. */
+ const kanalReihe = (html.match(/id="kanaele-reihe"[^>]*>([\s\S]*?)<\/div>/) || [])[1] || "";
+ ok(kanalReihe.trim() === "",
+ "die Kanalreihe ist im Quelltext leer -- sie kommt vom Server");
+
+ const streamKnopf = (html.match(/id="puls-knopf"[\s\S]{0,200}?href="([^"]+)"/) || [])[1] || "";
+ const haupt = KANAELE[0].handle.replace(/^@/, "");
+ ok(streamKnopf.includes(haupt),
+ `der Weg zum Stream zeigt auf den Hauptkanal (${streamKnopf})`);
+
+ const css = readFileSync(join(WURZEL, "workspace/assets/css/unsere-seiten.css"), "utf8");
+ ok(/prefers-reduced-motion/.test(css),
+ "wer keine Bewegung moechte, bekommt keine");
+ ok(/\[data-lage="live"\][\s\S]{0,400}animation/.test(css),
+ "und die Bewegung haengt am Live-Zustand, laeuft also nicht dauernd");
+}
+
+/* =======================================================================
+ 4. EINE ZAHL, ZWEI ORTE
+ ======================================================================= */
+melde("");
+melde("=== 4. Die Sendezeit steht nicht zweimal verschieden da ===");
+{
+ const streamplan = readFileSync(join(WURZEL, "assets/js/streamplan.js"), "utf8");
+ const treffer = streamplan.match(/FIX_STUNDE\s*=\s*(\d+)/);
+ /* DRITTER AUSGANG: Findet sich die Zahl nicht, ist das kein "in
+ Ordnung" -- dann hat jemand `streamplan.js` umgebaut, und dieser
+ Vergleich misst ab sofort nichts mehr. Das muss auffallen. */
+ ok(!!treffer,
+ treffer ? `die oeffentliche Seite sendet um ${treffer[1]} Uhr`
+ : "FIX_STUNDE steht nicht mehr in assets/js/streamplan.js -- "
+ + "dieser Vergleich kann nichts mehr messen");
+ ok(!!treffer && Number(treffer[1]) === SENDEZEIT_STUNDE,
+ `und der Treff rechnet mit derselben Stunde (${SENDEZEIT_STUNDE})`);
+ ok(SENDEZEIT_STUNDE >= 0 && SENDEZEIT_STUNDE <= 23,
+ `Gegenprobe: die Stunde ist ueberhaupt eine Uhrzeit (${SENDEZEIT_STUNDE})`);
+}
+
+/* =======================================================================
+ 5. DIE VIER LAGEN -- IM ECHTEN BROWSER
+ ======================================================================= */
+melde("");
+melde("=== 5. Was ein Mensch wirklich sieht ===");
+{
+ let pw = null;
+ try {
+ pw = await import("file:///C:/Users/qciga/Documents/Obelix/Analyse/node_modules/playwright/index.mjs");
+ } catch { /* dritter Ausgang unten */ }
+
+ if (!pw) {
+ melde(" -- Playwright ist nicht da -- im Browser wurde NICHT nachgesehen.");
+ melde(" Das ist kein Befund. Die Abschnitte 1-4 gelten weiter.");
+ } else {
+ const browser = await pw.chromium.launch();
+ try {
+ const ctx = await browser.newContext({ viewport: { width: 412, height: 950 },
+ isMobile: true, hasTouch: true });
+ await ctx.addCookies(kGast.split("; ").filter(Boolean).map((z) => {
+ const [name, ...rest] = z.split("=");
+ return { name, value: rest.join("="), domain: "127.0.0.1", path: "/" };
+ }));
+ const seite = await ctx.newPage();
+ const kaputt = [];
+ seite.on("pageerror", (e) => kaputt.push(String(e).slice(0, 140)));
+
+ /** Stellt die Antwort des Live-Dienstes her und laedt neu.
+ * `antwort === null` heisst: gar keine Antwort -- so verhaelt
+ * sich ein Ausfall, und genau dieser Fall wird sonst nie
+ * geprueft, weil man ihn nicht herbeifuehren kann, ohne ihn
+ * herbeizufuehren. */
+ async function lage(antwort, event = null) {
+ await seite.unrouteAll?.().catch(() => {});
+ await seite.route("**/postfach.dogfather-universe.com/**", async (weg) => {
+ const u = weg.request().url();
+ if (u.includes("/special-event")) {
+ await weg.fulfill({ status: 200, contentType: "application/json",
+ body: JSON.stringify({ ok: true, event }) });
+ return;
+ }
+ if (antwort === null) { await weg.abort("failed"); return; }
+ await weg.fulfill({ status: 200, contentType: "application/json",
+ body: JSON.stringify(antwort) });
+ });
+ await seite.goto(`http://127.0.0.1:${PORT}/workspace/unsere-seiten.html`,
+ { waitUntil: "domcontentloaded" });
+ await seite.waitForTimeout(1200);
+ return seite.evaluate(() => ({
+ lage: document.getElementById("puls")?.dataset.lage,
+ satz: document.getElementById("puls-lage")?.textContent?.trim(),
+ zahl: document.getElementById("puls-zahl")?.textContent?.trim(),
+ klein: document.getElementById("puls-klein")?.textContent?.trim(),
+ knopfDa: !document.getElementById("puls-knopf")?.hidden,
+ eventDa: !document.getElementById("event")?.hidden,
+ eventZahl: document.getElementById("event-zahl")?.textContent?.trim(),
+ kanaele: [...document.querySelectorAll(".kanal")].map((a) => ({
+ handle: a.querySelector(".kanal__handle")?.textContent?.trim(),
+ ziel: a.getAttribute("href"),
+ })),
+ }));
+ }
+
+ /* ---- 5a. Er sendet ------------------------------------------ */
+ melde("");
+ melde(" --- Er sendet ---");
+ {
+ const s = await lage({ ok: true, live: true, source: "automatisch",
+ title: "Casper raeumt das Wohnzimmer aus", autoHealthy: true });
+ ok(s.lage === "live", `die Lage ist „live“ (${s.lage})`);
+ ok(/JETZT live/i.test(s.satz || ""), `und es steht dort „${s.satz}“`);
+ ok((s.zahl || "").includes("Casper"),
+ `der Titel des Streams steht da („${s.zahl}“)`);
+ ok(s.knopfDa === true, "und ein Weg zum Stream ist da");
+ }
+
+ /* ---- 5b. Er sendet nicht ------------------------------------ */
+ melde("");
+ melde(" --- Er sendet nicht ---");
+ {
+ const s = await lage({ ok: true, live: false, source: "automatisch",
+ title: null, autoHealthy: true });
+ ok(s.lage === "wartet", `die Lage ist „wartet“ (${s.lage})`);
+ ok(/^\d{2}:\d{2}:\d{2}$/.test(s.zahl || ""),
+ `ein Countdown laeuft („${s.zahl}“)`);
+ ok((s.klein || "").includes(`${SENDEZEIT_STUNDE}:00`),
+ `und er nennt den naechsten Termin („${s.klein}“)`);
+ }
+
+ /* ---- 5c. Der Pruefer ist gestoert --------------------------- */
+ melde("");
+ melde(" --- Der Live-Pruefer meldet eine Stoerung ---");
+ {
+ const s = await lage({ ok: true, live: false, source: "automatisch",
+ title: null, autoHealthy: false });
+ ok(s.lage === "unklar", `die Lage ist „unklar“ (${s.lage})`);
+ /* DIE EIGENTLICHE GEGENPROBE. Ohne sie waere die Zeile darueber
+ auch dann gruen, wenn dort "Gerade nicht live" stuende -- und
+ genau das ist der Fehler, um den es geht. */
+ ok(!/Gerade nicht live/i.test(s.satz || ""),
+ `es steht NICHT „nicht live“ da, sondern „${s.satz}“`);
+ ok(/^\d{2}:\d{2}:\d{2}$/.test(s.zahl || ""),
+ `der Countdown laeuft trotzdem weiter („${s.zahl}“) -- er braucht kein Netz`);
+ }
+
+ /* ---- 5d. Gar keine Antwort ---------------------------------- */
+ melde("");
+ melde(" --- Der Dienst antwortet gar nicht ---");
+ {
+ const s = await lage(null);
+ ok(s.lage === "unklar", `die Lage ist „unklar“ (${s.lage})`);
+ ok(!/Gerade nicht live/i.test(s.satz || ""),
+ `auch hier steht NICHT „nicht live“ da, sondern „${s.satz}“`);
+ ok(/TikTok/i.test(s.klein || ""),
+ `und es steht da, was man stattdessen tun kann („${s.klein}“)`);
+ ok(s.knopfDa === true, "der Weg zu TikTok bleibt offen");
+
+ /* Die Kanaele haengen NICHT am Live-Dienst -- sie kommen vom
+ eigenen Server. Faellt der eine aus, muss der andere
+ weiterhin dastehen. */
+ ok(s.kanaele.length === KANAELE.length,
+ `die ${s.kanaele.length} Kanaele stehen trotzdem da`);
+ const falsch = s.kanaele.filter((k, i) => k.handle !== KANAELE[i].handle);
+ ok(falsch.length === 0,
+ falsch.length ? `falsche Handles: ${falsch.map((k) => k.handle).join(", ")}`
+ : `mit den richtigen Handles (${s.kanaele.map((k) => k.handle).join(", ")})`);
+ }
+
+ /* ---- 5e. Das besondere Event -------------------------------- */
+ melde("");
+ melde(" --- Das besondere Event ---");
+ {
+ const ohne = await lage({ ok: true, live: false, source: "automatisch",
+ autoHealthy: true }, null);
+ ok(ohne.eventDa === false,
+ "ohne Event ist der Kasten nicht da (kein „nichts geplant“)");
+
+ const inZweiTagen = new Date(Date.now() + 2 * 86400000 + 3600000).toISOString();
+ const mit = await lage({ ok: true, live: false, source: "automatisch",
+ autoHealthy: true }, { titel: { de: "Die grosse Halloween-Nacht" },
+ zieldatum: inZweiTagen, link: "https://dogfather-universe.com/streamplan.html" });
+ ok(mit.eventDa === true, "mit Event ist er da");
+ ok(/noch 2 Tage/.test(mit.eventZahl || ""),
+ `und zaehlt in Tagen herunter („${mit.eventZahl}“)`);
+ }
+
+ ok(kaputt.length === 0,
+ kaputt.length ? `Fehler in der Browserkonsole: ${kaputt.join(" | ")}`
+ : "keine Fehler in der Browserkonsole");
+ } finally {
+ await browser.close();
+ }
+ }
+}
+
+/* ------------------------------------------------------------------- */
+melde("");
+melde(`${geprueft} Pruefungen, ${fehler} Fehler`);
+melde(fehler ? "NICHT IN ORDNUNG" : "ALLES IN ORDNUNG");
+try { rmSync(ordner, { recursive: true, force: true }); } catch { /* Aufraeumrest */ }
+process.exit(fehler ? 1 : 0);
diff --git a/server/pruef-wege-nach-draussen.mjs b/server/pruef-wege-nach-draussen.mjs
index 61a60720..c185c23b 100644
--- a/server/pruef-wege-nach-draussen.mjs
+++ b/server/pruef-wege-nach-draussen.mjs
@@ -128,7 +128,28 @@ const { bereicheFuer, TREFF_BRETTER } = await import("./workspace.js");
`ein Mitglied bekommt ${TREFF_BEREICHE?.length} Kacheln`);
const kachel = TREFF_BEREICHE.find((k) => k.ziel === "unsere-seiten.html");
ok(!!kachel, "sie steht in der Liste der Treff-Kacheln");
- ok(kachel?.name === "Unsere Seiten", `sie heisst „${kachel?.name}“`);
+ /* DER NAME WIRD ABGELEITET, NICHT ABGESCHRIEBEN (21.09.2026).
+
+ Hier stand "Unsere Seiten" als Text. Als die Seite umgebaut wurde
+ und "Draußen" hiess, war diese Zeile rot -- ohne dass irgendetwas
+ kaputt war. Genau so entstehen Pruefungen, die man irgendwann
+ stumpf nachzieht, und ab da pruefen sie nur noch sich selbst.
+
+ Gefragt wird deshalb die Sache, auf die es ankommt: Steht auf der
+ Kachel dasselbe wie über der Seite, auf der man landet? Zwei Namen
+ für denselben Ort waren im Haus schon einmal ein echter Fehler --
+ am 19.09. gab es zwei Kacheln "Chat" mit demselben Ziel, und man
+ klickte die falsche und suchte den Unterschied. */
+ const seiteRoh = readFileSync(
+ new URL("../workspace/unsere-seiten.html", import.meta.url), "utf8");
+ const h1 = (seiteRoh.match(/