Files
tickte-system/docs/superpowers/plans/2026-10-17-workers-p6-ohne-website.md
T
ClaudeandClaude Opus 5.5 11e4280a92 Hustler: Name in der Oberfläche, Budget höchstens 150 $/Monat (6 $/Tag) mit hartem Deckel
Kenso, 17.10.: "150 € im Monat max", "nenne es Hustler". Technische
Namen (workers*) bleiben.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:26:42 +00:00

225 KiB
Raw Blame History

Workers P6: Etage „Ohne Website“ und Kaltmail – Umsetzungsplan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Die vier Branchen-Scouts finden jede Nacht 10 neue Betriebe ohne oder mit veralteter Website, Bea wählt über alle Räume die besten 3 für eine automatische Kaltmail (Max), der Rest landet mit Grund in der Bot-Lead-Liste, und eine Mail geht erst raus, wenn der Datenschutz-Abschnitt „Interessenten“ live ist.

Architecture: Im Workers-Dienst (knso/workers-dienst) kommen Module unter src/scouts/, src/scoring/, src/archive/, src/cold/, src/mail/ und src/api/ dazu; ein Fassadenmodul src/ohneWebsite.js hängt sie über die Registries von P1/P3/P4 ein (Tick-Einträge, routes, deps.drafters, registerGateCheck). P1s planNight plant weiter die Suchaufträge der Scouts (Raum und Region); P6 liefert dazu den Läufer (Gewerk × Ort aus dem Suchprotokoll), die Top-3-Auswahl, die Entwürfe für P4s Sende-Tick (erstmail, nachfass) und die Zusatzprüfung am Sende-Tor. Google Places liefert live nur Kandidaten (dauerhaft bleibt nur die place_id), Anschrift, Telefon und Mail stammen aus der eigenen Website des Betriebs (Impressum und Kontaktseite, per einfachem HTTP-Abruf und Regex ohne Sprachmodell, H2) oder OpenStreetMap. WOMS bekommt den Reiter „Bot-Leads“; das Portal bekommt den neuen Datenschutz-Abschnitt 11, webklar.com die Weiterleitung mit Anker.

Tech Stack: Node 22 ESM (Dienst, keine Semikolons), node:test, node-appwrite@14.1.0, @anthropic-ai/sdk@0.135.0 (über src/claude.js; P6 ruft Claude nur für Bea und Max, nie zum Lesen von Impressen), Google Places API (New), Overpass API; React 18 + Vite (WOMS); Portal-Tests mit node:test; Vitest (webklar.com).

Spec: /home/webklar/apps/ticket.webklar.com/dev-workers/docs/superpowers/specs/2026-10-17-workers-design.md · Schnittstellen: /home/webklar/apps/ticket.webklar.com/dev-workers/docs/superpowers/plans/2026-10-17-workers-00-uebersicht.md (verbindlich)

Global Constraints

  • Alle Regeln aus Übersicht §2 gelten.
  • Anschluss an die Nachbarpläne (maßgeblich: Übersicht §6, §11, §12; wo sie schweigt, P1-E3/E4/E7/E8, P3-E1/E2/E3/E5/E6/E8, P4-E1/E2/E3/E5): deps = { db, clock, log, claude, notify, config, fetch, mailer, imap, imapState, drafters, … }; deps.clock.now() liefert Millisekunden (A2), Zeitplan-Funktionen bekommen berlinNow(clock), Funktionen mit Parameter now erhalten nowOf(deps) (ein Date, aus P4s src/gespraeche/util.js, wie getDoc und parsePayload); db.get liefert null; Claude wird nur als deps.claude.ask(deps, opts) aufgerufen (A1: es gibt weder deps.llm noch llmOf, Tests ersetzen deps.claude; search nutzt P6 in dieser Runde nicht, H2); Impressum und Kontaktseiten liest readOwnSite mit deps.fetch (P3, T08); Aufträge sind P1-Jobs { id, kind, workerId, prospectId, threadId, attempts, payload } (nicht $id/payloadJson; rohe Dokumente nur in Seeds); Datenbankabfragen nur mit Query.equal/orderAsc/orderDesc/limit, alles Weitere im Code filtern (P3-E2: eine Regel für den Produktivcode, weil Appwrite nur auf indizierten Spalten abfragt, die Fake-DB das aber nicht prüft; P1s Fake-DB kennt equal, orderAsc, orderDesc, limit, greaterThan, offset, cursorAfter und select, jede andere Query-Methode wirft Query-Methode nicht unterstützt); workersDaily schreibt allein book/updateDaily (P1-E4); die Suchaufträge der Scouts plant P1s planNight (Payload { night, run, room, region, candidates, entrance }); Mails an Interessenten versendet allein P4s Tor im Tick senden (Aufträge erstmail/nachfass, Text aus deps.drafters), Zusatzprüfungen hängen über registerGateCheck ein; den Schalter settings.privacyInteressentenLive legt P3 an (P3-E8, A5) und er ist der einzige Datenschutz-Schalter (kein Env, keine zweite Konfiguration); die Registries heißen buildTickJobs() und buildRoutes() (A4).
  • Arbeitsorte: Dienst /home/webklar/apps/workers-dev Branch p6-ohne-website (auf dem Stand, der P1–P4 enthält); WOMS /home/webklar/apps/ticket.webklar.com/dev-workers Branch feature/workers; Portal-Worktree /home/webklar/apps/kundenbereich.webklar.com/dev-workers-datenschutz Branch feature/workers-datenschutz von origin/main; Website-Worktree /home/webklar/apps/webklar.com/dev-workers-ds Branch fix/datenschutz-anker von origin/main.
  • Kein Agent pusht, deployt, startet Container oder ruft in Tests Google, Anthropic, Overpass, DNS, SMTP, Appwrite oder fremde Websites auf (nur Fakes; deps.fetch wirft in makeDeps Netzzugriff im Test); Live-Prüfungen stehen ausschließlich in der Betriebsanleitung.
  • Places: POST https://places.googleapis.com/v1/places:searchText, Feldmaske exakt places.id,places.displayName,places.websiteUri,places.rating,places.userRatingCount,places.businessStatus,nextPageToken, pageSize 20, höchstens 3 Seiten je Lauf, 0,035 $ je Anfrage; dauerhaft nur place_id (kein Name, keine Bewertungszahl, keine Adresse, kein Telefon aus Places).
  • Overpass: höchstens 12 Anfragen je Lauf, mindestens 1 500 ms Abstand, nach dem ersten Fehler (429, 5xx, Timeout) keine weitere Anfrage im Lauf, nie E-Mail-Tags übernehmen.
  • Eigene Website (H2, Übersicht §12): Impressum und Kontakt werden ohne Sprachmodell gelesen: einfacher GET mit deps.fetch (P3), User-Agent aus src/net/robots.js (USER_AGENT), 10 s Zeitlimit je Abruf, höchstens 3 Seiten (Startseite, Impressum-Link, Kontakt-Link; nur dieselbe registrierbare Domain, nur http/https, höchstens 3 Weiterleitungen innerhalb der Domain, Antwort ≤ 1 MB, nur HTML/Text), 1 s Pause zwischen den Seiten; vorher robotsAllows (verboten ⇒ Seite auslassen, Startseite verboten ⇒ Status robots); kein Abruf von Hosts aus BOT_VERBOT_DOMAINS (src/claude.js), von sozialen Netzen und Verzeichnissen (normalizeDomain aus P1-T17 liefert für sie '') und nicht, wenn der Host privat auflöst; Mail, Telefon und Anschrift per Regex aus dem sichtbaren Text von Impressum- und Kontaktseite (eine Adresse nur von der Startseite zählt nicht), keine Entschleierung („[at]“), kein Raten; vor der Annahme prüft deps.dns.resolveMx die Mail-Domain (kein MX ⇒ Adresse verworfen). claude.search nutzt P6 in dieser Runde gar nicht (Places + OSM entdecken).
  • Lauf: ein Suchauftrag je Nacht von P1s planNight (runsPerNight), candidatesPerRun = 10 frische Kandidaten, höchstens 20 Minuten; ein Kandidat wird erst unmittelbar vor seiner Verarbeitung geclaimt, vor jedem teuren Schritt.
  • Heilberufe (Raum aerzte oder Gewerk in GESUNDHEITS_GEWERKE): isHealth=true, Telefonnummer wird nirgends gespeichert und nie ausgeliefert.
  • Punkte (100) = Lücke 35 + Bewertungsanzahl 20 + Bewertungsnote 10 + Sympathie 10 + keine Kette 10 + Mail 15; Mail-Platz nur mit Mail, ohne Kette, Website fehlend oder Audit ≤ 5 (MAX_AUDIT_FOR_MAIL) und Punkten ≥ 55 (MIN_SCORE_MAIL).
  • Mail-Plätze je Sendetag = mailColdPerDay (3) − fällige Nachfass-Mails − offene Erstmail-Aufträge − heute Gesendetes; Top-3-Auswahl um 05:30 Berlin über alle Räume der letzten 72 Stunden.
  • Kaltmail nur über P4s Tor (sendMail(... cold:true), ausgelöst vom Tick senden): Anrede „Sie“, Fließtext mit genau einem Link (WEBKLAR_URL aus src/mail/links.js, vom Code in eine eigene Zeile vor dem Gruß gesetzt, nie vom Modell; H3, A22), 60–180 Wörter ohne Pflichtblock (COLD_WORDS), keine verbotenen Phrasen, kein ! im Betreff, keine Platzhalterreste (H3), KI-Hinweis als erster Satz, Angebotssatz wörtlich aus settings.offerText (höchstens 400 Zeichen, A11), Pflichtblock aus buildColdFooter (für Erst- und Nachfass-Mail) mit genau drei Adressen (Quelle, DATENSCHUTZ_URL = https://www.webklar.com/datenschutz#interessenten aus src/mail/links.js, Abmelde-Link), nur bei privacyInteressentenLive=true; die Tagesgrenze setzt das Tor durch (P4), P6 reserviert nur die Plätze (T04, T13). Die gemeinsamen Konstanten und Prüfungen (BANNED_PHRASES, PLACEHOLDER_PATTERNS, COLD_WORDS, countWords, checkOutgoing) liefert P4 in src/mail/rules.js; qaColdMail und die Tests benutzen sie, P6 definiert keine eigene Kopie.
  • Nachfass: höchstens 1 je Betrieb, followupDays (7) nach der Erstmail (Termin und Flag followupSent setzt P4s onOutbound), höchstens ein Auftrag nachfass je Faden in beliebigem Status, Text nur aus Code.
  • Aufbewahrung: purgeAfter = foundAt + retentionDays (90; Einstellung darf 90 nicht übersteigen), Absage oder Widerspruch 30 Tage, ruhende Unterhaltung ohne Kunde 183 Tage; workersKeys und workersSuppression werden nie angefasst.
  • Ereignistexte, Suchprotokoll und Aufträge enthalten nie Namen, Mail-Adressen oder Telefonnummern von Betrieben.
  • Kein Schreibzugriff auf customers, workorders, websiteProjects, customerPortalAccess (Übersicht §2.13); der Integrationstest (P6-T24) prüft die Schreibliste.
  • Priorität (H1): Die Reihenfolge der Arten und Ticks bestimmt rankOf aus src/priority.js (P1): scouts und top3 Rang 4, kuerzen Rang 5, die Aufträge erstmail und nachfass Rang 2, suche Rang 4. P6 definiert keine eigene Rangliste; Pin in P6-T24.
  • Rollen-Prompts (H5): Beas und Maxs Persona liegen in prompts/bea.md (P3) bzw. prompts/max.md (P4), aufgabenneutral und von P6 mitbenutzt; P6 hängt nur die Aufgabe per buildSystem(workerId, aufgabe) (src/prompts.js, P1) an. Die vier Scout-Dateien prompts/{ali,hanna,sven,lea}.md (P1) sind reservierte Entwürfe und werden in dieser Runde nicht geladen (die Scouts rufen kein Claude auf).
  • Commit-Nachrichten enden mit der Zeile Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.

Review Focus

  • R1 Places liefert Name, Bewertungsnote, Bewertungsanzahl und Website → in keinem gespeicherten Dokument (Interessent, Ereignis, Auftrag, Suchprotokoll) steht davon etwas außer placeId und abgeleiteten Punkten. Test: P6-T09 (Baustein) und P6-T12 (ganzer Lauf).
  • R2 Das Impressum zeigt die Adresse nur verschleiert („info [at] …“), sie steht nur auf Facebook oder nur auf der Startseite, robots.txt verbietet den Abruf (robotsAllows ist falsch), oder die Regex findet sie nicht im Text von Impressum oder Kontaktseite → keine Mail, Bot-Lead mit Grund „keine veröffentlichte Mail“ (Domain ohne MX: „Adresse nicht zustellbar“). Test: P6-T08 (Auslesen), P6-T10 (Grund), P6-T12 (MX-Fall), P6-T24 (ganzer Lauf).
  • R3 Eine Kaltmail wird als cold:false getarnt, to weicht von der gespeicherten veröffentlichten Adresse ab, die Quelle fehlt oder der Datenschutz-Schalter ist aus → das Gate verweigert, nichts geht raus. Test: P6-T15.
  • R4 Nachfass fällig + offene Erstmail-Aufträge + Wochenende, doppelter Top-3-Lauf, Absturz nach dem Senden → nie mehr als mailColdPerDay je Sendetag, kein Rückstau, Nachfass genau einmal. Test: P6-T04 (Plätze), P6-T13 (Auswahl), P6-T16 (ein Nachfass-Auftrag je Faden, auch nach Ende des Auftrags), P4-T08 (idempotencyKey: Versand je Auftrag höchstens einmal, auch nach Absturz), P6-T24 (ganze Woche).
  • R5 Heilberuf (Raum aerzte oder „Physiotherapie“ im Raum selbst) mit Telefonnummer im Impressum oder bei OSM → die Nummer wird weder gespeichert noch über die API ausgeliefert, auch nicht aus einem Altbestand. Test: P6-T09 und P6-T18.
  • R6 Eine Kaltmail aus P6 verletzt die Vorab-Prüfungen des Sende-Tors (H3): unter 60 oder über 180 Wörter, kein oder mehr als ein Link, ein Link außerhalb von webklar.com, Platzhalterrest, verbotene Phrase, ! im Betreff → das Tor verweigert mit Grund (Pult unsicher, nie still korrigiert). P6 muss deshalb sicherstellen, dass Erstmail, Nachfass und Ersatztext die Regeln bestehen. Test: P6-T14 (Länge und Regeln aller drei Texte, Schutz-Test mit 90-Wörter-Absätzen), P6-T15 (Tor mit echtem Text), P6-T24 (ganze Woche).

Ergänzungen zur Übersicht

Die P6-Ergänzungen E1–E8 stehen jetzt verbindlich in Übersicht §11.1 (P6-E1 bis P6-E8, angenommen) sowie §11.2 und §12; die Tabelle sagt nur noch, wo P6 sie umsetzt. Wo P1–P4 sie schon festlegen, prüft P6 nur.

Nr. Ergänzung Umgesetzt in
E1 siehe Übersicht §11, P6-E1 und A5, A11: privacyInteressentenLive legt P3 an, retentionDays 7–90 und offerText 1–400 erzwingt P1; P6 prüft nur (Pin-Tests). P6-T01
E2 siehe Übersicht §11, P6-E2: keine neuen Indizes (genutzt: §4 und §4.14). P6-T01
E3 entfällt (H2), siehe Übersicht §11, P6-E3: kein pages an search, kein Domainfilter am Abruf, kein src/llm.js. – (P6-T02 entfällt)
E4 siehe Übersicht §11, P6-E4 und A1: deps.places, deps.osm, deps.dns = { resolveMx, resolve4 }, kein llm; das Impressum-Lesen nutzt deps.fetch (H2). P6-T08 (Nutzung), P6-T24 (erweitereDeps)
E5 siehe Übersicht §11, P6-E5 und §6.1 (Ränge, H1): P6 ergänzt in src/schedule.js nur die Tick-Arten top3 (05:30) und scouts (alle 2 Minuten); kuerzen (03:00) steht dort seit P1-T08 und wird nicht noch einmal angelegt; Einhängen aller drei Tick-Einträge über buildTickJobs() (A4). P6-T05, P6-T24
E6 siehe Übersicht §11, P6-E6 und §6 (gate.js): Zusatzprüfung coldChecks über registerGateCheck (Gründe datenschutz, adresse_nicht_belegt, kein_kaltkontakt, kaltmail_ohne_antwort, status_unpassend), appendCompliance Zweig cold:true. P6-T15
E7 siehe Übersicht §11, P6-E7: GET /botleads?id=<prospectId>, Ereignislink /hustler?tab=botleads&bot=<prospectId> (WOMS-Route /hustler, Weiterleitung von /workers und /leads), Route über routes/buildRoutes() (A4). P6-T18, P6-T19, P6-T24
E8 siehe Übersicht §11, P6-E8: deps.drafters.erstmail/nachfass(deps, job) liefert P6; die Aufträge legen selectNightTop3 bzw. scheduleFollowups an, versendet P4s Tick senden. P6-T16

File Structure

Dienst (/home/webklar/apps/workers-dev)

Datei Zweck
src/scouts/rooms.js vier Scout-Räume, Gewerke, Heilberufe
src/scouts/regionen.js Orte je Bundesland für die Suchanfragen
src/scouts/plan.js Abfragewahl (Gewerk × Ort) aus dem Suchprotokoll eines Raums; Raum und Region wählt P1s planNight
src/scouts/places.js Places-Client (Text Search, feste Feldmaske)
src/scouts/osm.js Overpass-Client, zurückhaltend
src/scouts/ownsite.js eigene Website lesen (H2): Impressum und Kontaktseite per deps.fetch und Regex, ohne Sprachmodell; E-Mail-Auswahl, MX-Prüfung
src/scouts/assemble.js Platzhalter- und Fertig-Dokument des Interessenten ohne Places-Rohdaten
src/scouts/run.js Suchlauf eines Scouts (runScoutJob)
src/scoring/business.js Punkteformel, Ausschlussgründe, Rangfolge
src/scoring/bea.js Beas Urteil (Sonnet) mit Ersatzurteil
src/scoring/slots.js Sendetag und freie Mail-Plätze
src/scoring/night.js Top-3-Auswahl über alle Räume (selectNightTop3)
src/archive/botleads.js Arne: Bot-Lead ablegen
src/archive/kuerzen.js Kürzung nach Frist (runKuerzen)
src/cold/draft.js Max: Kaltmail entwerfen, Prüfung (Regeln aus src/mail/rules.js), Ersatztext; genau ein Link WEBKLAR_URL
src/cold/flow.js Entwürfe für P4s Sende-Tick (deps.drafters), Nachfass-Aufträge
src/mail/coldFooter.js Pflichtblock der Kaltmail (Datenschutz-Adresse aus src/mail/links.js, keine eigene Konstante)
src/mail/coldChecks.js Zusatzprüfung am Sende-Tor für Kaltmails (registerGateCheck)
src/mail/footer.js (P4) Zweig cold:true (buildColdFooter); src/mail/gate.js bleibt unberührt (P4 liest nur settings.privacyInteressentenLive)
src/api/botleads.js Liste der Bot-Leads und BOTLEADS_ROUTES (Registry von P3-E6)
src/ohneWebsite.js Fassade: drainScouts, kickScouts, top3Tick, kuerzenTick, SCOUT_KINDS, ohneWebsiteTickJobs (auch als tickJobs), routes (= BOTLEADS_ROUTES), registerColdPieces, erweitereDeps
src/schedule.js, src/main.js (P1/P3) kleine Ergänzungen laut E5, E6 (P6-T05: nur top3 und scouts, kuerzen kennt P1-T08 schon; P6-T24); src/settings.js, src/schema.js, src/claude.js und test/claudeSearch.test.js bleiben unberührt (H2, A11)
src/net/robots.js, src/mail/links.js, src/mail/rules.js, src/priority.js, src/prompts.js, prompts/{bea,max}.md nur benutzt, nicht geändert: USER_AGENT/robotsAllows (P3), feste Mail-Adressen (P3), Regeln und Konstanten für Mail-Texte (P4), Rangtabelle (P1), Rollen-Prompts (P1; Inhalt von bea.md P3, max.md P4)
scripts/p6-freischalten.mjs Datenschutz-Seite prüfen und Schalter privacyInteressentenLive setzen (nur nach Prüfung), connectedRooms der Scout-Räume setzen
scripts/probe-scout.mjs manuelle Live-Probe (Kenso): Places + readOwnSite, kein Sprachmodell
test/support/p6.js, test/p6-*.test.js Testhilfen (fakeClaude, siteFetch, betriebSeiten, …) und Tests

WOMS (.../dev-workers): src/lib/botleadsView.js (reine Anzeigelogik), src/components/workers/BotLeadList.jsx, src/pages/WorkersPage.jsx (Reiter), src/styles/workers.css, tests/unit/botleadsView.test.mjs (src/lib/workersApi.js bleibt unberührt: P2-T04 reicht id bei botleads schon durch).

Portal (.../dev-workers-datenschutz): public/datenschutz.html (Abschnitte 2, 11, 12, 14), docs/WEBklar-Datenschutzerklaerung.pdf + .sha256, scripts/rechtliches-hochladen.mjs (nur export), scripts/datenschutz-hochladen.mjs, test/legalTexts.test.js, test/datenschutzHochladen.test.js.

Website (.../dev-workers-ds): src/pages/Datenschutz.tsx, src/test/datenschutz-weiterleitung.test.tsx.


P6-T01: Vorprüfung, Testhilfen, Pin-Tests für Aufbewahrungsgrenze und Schalter (E1, E2)

Files:

  • Create: test/support/p6.js
  • Modify: keine Quelldatei (P3 legt privacyInteressentenLive in src/schema.js/src/settings.js an, P1 erzwingt retentionDays 7–90, offerText 1–400 und offerProductKey nur hosting, A11)
  • Test: test/p6-schema.test.js, test/p6-support.test.js

Interfaces:

  • Consumes (P1/P3/P4): SCHEMA, DEFAULTS, getSettings(deps), updateSettings(deps, patch, by), workerForRoom (src/roles.js), enqueue, next (src/jobs.js); Testhilfen makeDeps, fakeClock (test/support/helpers.js), createFakeDb (test/support/fakeDb.js: seed(col, over, id?), all(col)), fakeSdk, msg, makeP4Deps, fakeMailer (test/support/p4.js).

  • Produces: nur Pin-Tests für die Grenzen von P1/P3 (kein neuer Quellcode); test/support/p6.js mit makeDeps(over) (Step 4), den Re-Exporten fakeClock, fakeMailer, fakeClaude(script) (setzt deps.claude, A1), at(iso), page(url, text), siteFetch(map) (Fake für deps.fetch), betriebSeiten(host, over) (Start- und Impressum-Seite eines Testbetriebs für siteFetch), seedProspect(db, over), seedThread(db, over), seedJob(db, over) (rohes Dokument), takeJob(deps, { kind, workerId, prospectId?, threadId?, payload? }) (P1-Auftrag { id, …, payload } über enqueue + next, Status laeuft), logSearch(db, room, atIso, query?, region?).

  • Step 1: Vorprüfung (nur lesen) und Branch. cd /home/webklar/apps/workers-dev && git switch -c p6-ohne-website (vom Stand, der P1–P4 enthält). Dann prüfen: for n in claimKeys keysFor keyId emit assertCap checkBudget book BudgetStop enqueue next finish fail getSettings updateSettings getDaily updateDaily sendMail registerGateCheck appendCompliance unsubToken isDue berlinNow inSendWindow normalizeDomain normalizeName isSuppressed ensureThread lastOutbound nowOf getDoc planNight runSenden createFakeDb makeP4Deps fakeMailer rankOf buildSystem BOT_VERBOT_DOMAINS robotsAllows USER_AGENT htmlToLines extractAnchors DATENSCHUTZ_URL WEBKLAR_URL BANNED_PHRASES PLACEHOLDER_PATTERNS COLD_WORDS countWords checkOutgoing buildTickJobs buildRoutes; do rg -q "\b$n\b" src test/support || echo "FEHLT $n"; done ⇒ keine Ausgabe. Von den Nachbarplänen festgelegt, hier nur bestätigen (Nummern wie in Übersicht §11.2): A1 Claude nur als deps.claude.ask(deps, opts) (kein deps.llm, kein llmOf); A2 deps.clock.now() liefert Millisekunden, berlinNow(clock) für den Zeitplan, nowOf(deps) ein Date; db.get liefert null, db.create das Dokument, listAll ein Array (P1-E3); A3 next(deps, { kinds, workerIds? }) liefert { id, kind, workerId, prospectId, threadId, attempts, payload } oder null (Arten in rankOf-Reihenfolge, H1), fail(deps, id, error, { retry }) (P1-E8, P3-E3); A4 buildTickJobs(), buildRoutes() und Tick-Einträge { kind, workerId, pauseSafe, run(deps, { now, settings }) } in src/main.js (P3-E5/E6); A5 registerGateCheck(fn) mit ctx = { prospect, thread, to, text, cold, author, settings, first } und deps.drafters.erstmail/nachfass (P4-E1/E2); der einzige Datenschutz-Schalter ist settings.privacyInteressentenLive. Weicht der Code ab, wird das gemeldet und nur test/support/p6.js angepasst; die Pläne gelten.

  • Step 2: Fehlschlagende Tests schreiben.

// test/p6-schema.test.js
import test from 'node:test'
import assert from 'node:assert/strict'
import { SCHEMA } from '../src/schema.js'
import { getSettings, updateSettings } from '../src/settings.js'
import { makeDeps } from './support/helpers.js'

const attr = (col, key) => SCHEMA.find((c) => c.id === col).attributes.find((a) => a.key === key)
const indizes = (col) => SCHEMA.find((c) => c.id === col).indexes

test('E1 (P3): privacyInteressentenLive ist Boolean, nicht Pflicht, Standard false', async () => {
  const a = attr('workersSettings', 'privacyInteressentenLive')
  assert.deepEqual([a.type, a.required, a.default], ['boolean', false, false])
  assert.equal((await getSettings(makeDeps())).privacyInteressentenLive, false)
})
test('E1 (P1, A11): Schalter nur als Boolean; retentionDays über 90 und offerText über 400 Zeichen werden abgelehnt', async () => {
  const deps = makeDeps()
  await updateSettings(deps, { privacyInteressentenLive: true }, 'script:test')
  assert.equal((await getSettings(deps)).privacyInteressentenLive, true)
  await assert.rejects(() => updateSettings(deps, { privacyInteressentenLive: 'ja' }, 'script:test'), /privacyInteressentenLive/)
  await assert.rejects(() => updateSettings(deps, { retentionDays: 91 }, 'script:test'), /retentionDays/)
  await assert.rejects(() => updateSettings(deps, { offerText: 'x'.repeat(401) }, 'script:test'), /offerText/) // hält die Kaltmail unter 180 Wörtern (H3)
  await updateSettings(deps, { retentionDays: 90, offerText: 'x'.repeat(400) }, 'script:test')
  const s = await getSettings(deps)
  assert.deepEqual([s.retentionDays, s.offerText.length], [90, 400])
})
test('E2: vorhandene Indizes genügen (Status der Fäden, Art+Status der Aufträge, threadId der Nachrichten)', () => {
  assert.ok(indizes('workersThreads').some((i) => i.attributes.join() === 'status'))
  assert.ok(indizes('workersJobs').some((i) => i.attributes.join() === 'kind,status'))
  assert.ok(indizes('workersMessages').some((i) => i.attributes.join() === 'threadId'))
})
// test/p6-support.test.js
import test from 'node:test'
import assert from 'node:assert/strict'
import { makeDeps, siteFetch, betriebSeiten, seedProspect, seedThread, seedJob, takeJob, logSearch } from './support/p6.js'

test('makeDeps: Uhr in Millisekunden, Einstellungen über den Standardwerten, docs() und Schreibliste', async () => {
  const deps = makeDeps({ settings: { connectedRooms: ['handwerk'] } })
  assert.equal(typeof deps.clock.now(), 'number')
  assert.equal(new Date(deps.clock.now()).toISOString(), '2026-10-20T03:30:00.000Z')
  const s = deps.db.docs('workersSettings')[0]
  assert.deepEqual([s.$id, s.connectedRooms, s.dailyBudgetUsd, s.privacyInteressentenLive], ['main', ['handwerk'], 6, true])   // 6 $/Tag = Standard (P1, Kenso 17.10.: max. 150 € im Monat)
  const p = await seedProspect(deps.db, {})
  await deps.db.update('workersProspects', p.$id, { score: 1 })
  assert.deepEqual(deps.db.writes.slice(-1)[0], { op: 'update', col: 'workersProspects', id: p.$id })
  assert.equal(await deps.db.get('workersProspects', 'gibtsnicht'), null)
})
test('makeDeps: kein Netz und kein Sprachmodell – fetch wirft, claude ist ein Fake; sleep zeichnet auf', async () => {
  const deps = makeDeps()
  await assert.rejects(() => deps.fetch('https://beispiel-elektro.de/'), /Netzzugriff im Test/)
  await assert.rejects(() => deps.claude.ask(deps, {}), /nicht vorgesehen/)
  await assert.rejects(() => deps.claude.search(deps, {}), /nicht vorgesehen/)
  assert.ok(!('llm' in deps))
  await deps.sleep(1000)
  assert.deepEqual(deps.sleeps, [1000])
})
test('siteFetch: Seiten, robots.txt, Weiterleitungen, 404 und Zeitüberschreitung ohne Netz', async () => {
  const f = siteFetch({
    'https://a.example/': '<p>hallo</p>',
    'https://a.example/alt': { status: 301, headers: { location: 'https://a.example/' } },
    'https://a.example/robots.txt': 'User-agent: *\nDisallow: /privat',
    'https://a.example/langsam': { timeout: true },
  })
  const r = await f('https://a.example', { redirect: 'manual' }) // Adressen werden normalisiert
  assert.deepEqual([r.status, r.ok, r.headers.get('Content-Type'), await r.text()], [200, true, 'text/html; charset=utf-8', '<p>hallo</p>'])
  assert.equal((await f('https://a.example/alt')).headers.get('LOCATION'), 'https://a.example/')
  assert.equal((await f('https://a.example/alt')).status, 301)
  assert.equal((await f('https://a.example/gibtsnicht')).status, 404)
  const robots = await f('https://a.example/robots.txt')
  assert.deepEqual([robots.headers.get('content-type'), await robots.text()], ['text/plain', 'User-agent: *\nDisallow: /privat'])
  await assert.rejects(() => f('https://a.example/langsam'), (e) => e.name === 'TimeoutError')
  assert.deepEqual(f.calls.map((c) => c.url), ['https://a.example/', 'https://a.example/alt', 'https://a.example/alt', 'https://a.example/gibtsnicht', 'https://a.example/robots.txt', 'https://a.example/langsam'])
  assert.equal(f.calls[0].init.redirect, 'manual')
})
test('betriebSeiten: Start- und Impressum-Seite eines Testbetriebs, Telefon je Host verschieden', () => {
  const a = betriebSeiten('chija-elektro.de'), b = betriebSeiten('chijb-elektro.de')
  assert.deepEqual(Object.keys(a), ['https://chija-elektro.de/', 'https://chija-elektro.de/impressum'])
  assert.ok(a['https://chija-elektro.de/impressum'].includes('info@chija-elektro.de'))
  assert.ok(!betriebSeiten('x-elektro.de', { mail: false })['https://x-elektro.de/impressum'].includes('@'))
  assert.ok(!betriebSeiten('x-elektro.de', { phone: false })['https://x-elektro.de/impressum'].includes('Telefon'))
  const tel = (s, h) => /Telefon: ([\d ]+)/.exec(s[`https://${h}/impressum`])[1]
  assert.notEqual(tel(a, 'chija-elektro.de'), tel(b, 'chijb-elektro.de'))
  assert.match(a['https://chija-elektro.de/'], /<a href="\/impressum">Impressum<\/a>/)
})
test('takeJob liefert einen P1-Auftrag im Status laeuft; seedJob ein rohes Dokument', async () => {
  const deps = makeDeps()
  const j = await takeJob(deps, { kind: 'suche', workerId: 'hanna', payload: { room: 'handwerk' } })
  assert.deepEqual([j.kind, j.workerId, j.status, j.attempts, j.payload], ['suche', 'hanna', 'laeuft', 1, { room: 'handwerk' }])
  assert.ok(j.id && !('$id' in j))
  const r = await seedJob(deps.db, {})
  assert.deepEqual([r.kind, r.workerId, r.status, r.payloadJson], ['erstmail', 'max', 'offen', '{}'])
})
test('logSearch schreibt eine Protokollzeile, seedThread die Standardwerte', async () => {
  const deps = makeDeps()
  await logSearch(deps.db, 'handwerk', '2026-10-18T22:00:00Z')
  const t = await seedThread(deps.db, {})
  const z = deps.db.docs('workersSearchLog')[0]
  assert.deepEqual([z.room, z.region, t.status, t.followupSent], ['handwerk', 'Rheinland-Pfalz', 'wartet_auf_kunde', false])
})
  • Step 3: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/p6-schema.test.js test/p6-support.test.js ⇒ p6-schema läuft schon grün, wenn P1 und P3 ihre Teile geliefert haben (ein Fehlschlag dort heißt: Vorgängerplan unvollständig, melden, nichts in P6 nachbauen); p6-support schlägt fehl: Cannot find module './support/p6.js'.
  • Step 4: Implementieren. Keine Änderung an src/settings.js oder src/schema.js: P3 legt privacyInteressentenLive an (Schema, DEFAULTS, Boolean-Prüfung), P1 erzwingt retentionDays 7–90, offerText 1–400 und offerProductKey nur hosting (A11); die Tests p6-schema bleiben als Pins. test/support/p6.js nach diesem Vertrag, aufgebaut auf den Hilfen von P1/P4 (kein zweiter Fake-DB):
    • Re-Exporte: fakeClock (P1: now() in Millisekunden, set(isoOrMs), advance(ms)), fakeMailer (P4).
    • makeDeps({ now = '2026-10-20T03:30:00Z', settings = {}, ...over }): deps = makeP4Deps(over) (Mailer, config.smtp/unsub, notify, claude), danach deps.clock.set(now); deps.db.seed('workersSettings', { ...DEFAULTS, privacyInteressentenLive: true, ...settings }, 'main') (Standard der Testhilfe: Schalter an; Tests des ausgeschalteten Schalters übergeben ausdrücklich false); deps.claude = fakeClaude({}) (A1; ersetzt das Fake von P4, Tests setzen deps.claude neu; es gibt kein deps.llm), deps.fetch = async () => { throw new Error('Netzzugriff im Test') } (Tests setzen siteFetch(...)), deps.sleep = async (ms) => { deps.sleeps.push(ms) } mit deps.sleeps = [] (die 1-s-Pause zwischen den Seiten, T08; optionale Naht von P6, Standard im Produktivcode ein echter Timer), deps.places = { textSearch: async () => ({ places: [], nextPageToken: null }) }, deps.osm = { lookup: async () => null }, deps.dns = { resolveMx: async () => [{ exchange: 'mx.test', priority: 10 }], resolve4: async () => ['192.0.2.1'] } (192.0.2.1 ist öffentlich im Sinne von isPrivateIp); deps.db.docs = (col) => deps.db.all(col); deps.db.writes = Liste { op, col, id } (create, update, remove der Fake-DB werden umhüllt).
    • fakeClaude({ search, ask }): { calls: { search: [], ask: [] }, ask(deps, opts), search(deps, opts) }; jede Funktion zeichnet opts auf und ruft die übergebene Skriptfunktion (Standard: wirft Error('nicht vorgesehen'), damit ein unerwarteter Modellaufruf, etwa search, sofort auffällt).
    • siteFetch(map) → fetch-kompatible Funktion f(url, init) mit f.calls = [{ url, init }] (jeder Aufruf wird aufgezeichnet, auch ein timeout-Fall, mit der normalisierten Adresse new URL(url).href; ebenso werden die Schlüssel von map normalisiert). Werte von map: string (Status 200, content-type: text/html; charset=utf-8, für */robots.txt text/plain) oder { status = 200, headers = {}, body = '', timeout = false } (headers ergänzen bzw. überschreiben den Standard-content-type: text/html; charset=utf-8); timeout: true lässt den Aufruf sofort mit einem Fehler name: 'TimeoutError' scheitern (kein echtes Warten). Eine Adresse, die nicht in map steht, liefert Status 404 mit leerem Körper (für /robots.txt heißt das: alles erlaubt, RFC 9309). Die Antwort ist { ok, status, url, headers: { get(name) } (Name ohne Beachtung der Schreibung), text: async () => body }; init.redirect wird nur aufgezeichnet, Weiterleitungen folgt die Funktion nie selbst.
    • betriebSeiten(host, { mail = true, phone, chef = '', firma = 'Elektro Beispiel GmbH', jahr = 2016, ort = 'Kaiserslautern' } = {}) → Map für siteFetch mit genau zwei Seiten. Startseite https://<host>/: <html><head><title><firma> | Elektriker in <ort></title></head><body><p>© <jahr> <firma></p><a href="/impressum">Impressum</a></body></html>. Impressum https://<host>/impressum: <h1>Impressum</h1><p>Angaben gemäß § 5 TMG</p><p><firma><br>Hauptstraße 5<br>67655 <ort></p>, bei gesetztem chef danach <p>Geschäftsführer: <chef></p>, dann <p>Telefon: <phone><br>E-Mail: info@<host></p> (die Telefonzeile entfällt bei phone: false, die Mailzeile bei mail: false). phone ist Standard aus dem Host abgeleitet, damit zwei Testbetriebe nie dieselbe Nummer haben ('0631 ' + (100000 + [...host].reduce((a, c) => (a * 31 + c.charCodeAt(0)) % 900000, 7))); wer eine feste Nummer braucht, übergibt sie.
    • seedProspect(db, over) über db.seed('workersProspects', …); Standard: entrance ohne_website, room handwerk, foundBy hanna, source places, sourceUrl https://beispiel-elektro.de/impressum, foundAt 2026-10-19T20:00:00Z, name 'Elektro Beispiel GmbH', email info@beispiel-elektro.de, emailSource https://beispiel-elektro.de/impressum, city Kaiserslautern, postalCode 67655, score 80, status bewertet, isHealth false, purgeAfter 2027-01-17T20:00:00Z, updatedAt 2026-10-19T20:00:00Z. seedThread (workersThreads): prospectId p1, channel mail, toEmail info@beispiel-elektro.de, subject 'Website-Vorschau für Elektro Beispiel GmbH', status wartet_auf_kunde, followupSent false, reminderSent false, createdAt 2026-10-19T20:00:00Z. seedJob (rohes workersJobs-Dokument): kind erstmail, workerId max, status offen, attempts 0, payloadJson '{}', createdAt 2026-10-19T20:00:00Z. logSearch(db, room, atIso, query = 'q', region = 'Rheinland-Pfalz'): workersSearchLog mit workerId = workerForRoom(room), found 10, fresh 8. takeJob(deps, { kind, workerId, prospectId, threadId, payload = {} }): await enqueue(deps, …), danach return next(deps, { kinds: [kind] }). at(iso) = new Date(iso), page(url, text) = { url, text }. Alle seed* sind mit over überschreibbar und geben das angelegte Dokument zurück; Felder ohne Wert werden weggelassen, nie null.
  • Step 5: Bestehen sehen. Derselbe Befehl ⇒ # fail 0. Danach timeout 300 node --max-old-space-size=1024 --test test/*.test.js ⇒ keine neuen Fehler gegenüber vorher.
  • Step 6: Commit. git add test/support/p6.js test/p6-schema.test.js test/p6-support.test.js && git commit -m "p6: Testhilfen und Pin-Tests für Schalter und Grenzen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P6-T02: (entfällt, siehe H2)

Die Nummer bleibt stehen, damit Verweise stabil bleiben. Weil Impressum und Kontakt ohne Sprachmodell gelesen werden (H2, Übersicht §12; P6-T08), gibt es in P6 kein pages an claude.search, keinen Domainfilter am Abruf, kein src/llm.js und keine Änderung an src/claude.js oder test/claudeSearch.test.js. P6 ruft claude.search nicht auf (Places und OSM entdecken); deps.llm/llmOf gibt es nicht (A1).

P6-T03: Scout-Räume, Gewerke, Heilberufe, Orte

Files:

  • Create: src/scouts/rooms.js, src/scouts/regionen.js
  • Test: test/p6-rooms.test.js

Interfaces:

  • Consumes: role(workerId) aus src/roles.js.

  • Produces: SCOUT_ROOMS ({ [room]: { workerId, label, gewerke: string[] } }), SCOUT_WORKER_IDS: string[], GESUNDHEITS_GEWERKE: string[], isHealth(room: string, gewerk: string): boolean, roomOfWorker(workerId): string|null, REGION_ORTE: { [bundesland]: string[] }, orteFuer(region: string): string[]. Hinweis (H5): Die Dateien prompts/{ali,hanna,sven,lea}.md legt P1 als reservierte Entwürfe an; die Scouts rufen in dieser Runde kein Claude auf, P6 lädt sie nicht.

  • Step 1: Test schreiben.

import test from 'node:test'
import assert from 'node:assert/strict'
import { role } from '../src/roles.js'
import { SCOUT_ROOMS, SCOUT_WORKER_IDS, isHealth, roomOfWorker } from '../src/scouts/rooms.js'
import { orteFuer } from '../src/scouts/regionen.js'

test('vier Räume mit den Scouts der Besetzung', () => {
  assert.deepEqual(Object.entries(SCOUT_ROOMS).map(([r, c]) => [r, c.workerId, c.label]),
    [['aerzte', 'ali', 'Ärzte & Praxen'], ['handwerk', 'hanna', 'Handwerk'], ['selbst', 'sven', 'Selbstständige'], ['laden', 'lea', 'Läden & Gastro']])
  for (const [room, c] of Object.entries(SCOUT_ROOMS)) {
    assert.equal(role(c.workerId).room, room)
    assert.equal(roomOfWorker(c.workerId), room)
    assert.ok(c.gewerke.length >= 12 && new Set(c.gewerke).size === c.gewerke.length)
  }
  assert.deepEqual(SCOUT_WORKER_IDS, ['ali', 'hanna', 'sven', 'lea'])
  assert.equal(roomOfWorker('rita'), null)
})
test('Heilberuf: Raum aerzte immer, Gewerkliste auch in anderen Räumen', () => {
  assert.equal(isHealth('aerzte', 'Hausarzt'), true)
  assert.equal(isHealth('selbst', 'Physiotherapie'), true)
  assert.equal(isHealth('selbst', 'Steuerberater'), false)
  assert.equal(isHealth('handwerk', 'Elektriker'), false)
})
test('Orte: Rheinland-Pfalz beginnt mit Kaiserslautern, unbekannte Region ist ihr eigener Ort', () => {
  const rlp = orteFuer('Rheinland-Pfalz')
  assert.equal(rlp[0], 'Kaiserslautern')
  assert.ok(rlp.length >= 24 && new Set(rlp).size === rlp.length)
  assert.deepEqual(orteFuer('Saarland'), ['Saarland'])
})
  • Step 2: Fehlschlag sehen. timeout 300 node --max-old-space-size=1024 --test test/p6-rooms.test.js ⇒ FAIL, Modul fehlt.
  • Step 3: Implementieren (Daten, exakt so).
// src/scouts/rooms.js
export const SCOUT_ROOMS = {
  aerzte: { workerId: 'ali', label: 'Ärzte & Praxen', gewerke: ['Hausarzt', 'Zahnarzt', 'Kieferorthopäde', 'Augenarzt', 'Frauenarzt', 'Kinderarzt', 'HNO-Arzt', 'Orthopäde', 'Hautarzt', 'Heilpraktiker', 'Tierarzt', 'Psychotherapie'] },
  handwerk: { workerId: 'hanna', label: 'Handwerk', gewerke: ['Elektriker', 'Sanitär und Heizung', 'Maler und Lackierer', 'Schreiner', 'Dachdecker', 'Fliesenleger', 'Zimmerei', 'Gartenbau', 'Metallbau', 'Kfz-Werkstatt', 'Autolackiererei', 'Bauunternehmen'] },
  selbst: { workerId: 'sven', label: 'Selbstständige', gewerke: ['Steuerberater', 'Fotograf', 'Coach', 'Grafikdesign', 'Architekt', 'Hundeschule', 'Nachhilfe', 'Fahrschule', 'Versicherungsmakler', 'Immobilienmakler', 'Physiotherapie', 'Übersetzer'] },
  laden: { workerId: 'lea', label: 'Läden & Gastro', gewerke: ['Bäckerei', 'Metzgerei', 'Café', 'Restaurant', 'Blumenladen', 'Friseur', 'Kosmetikstudio', 'Boutique', 'Buchhandlung', 'Eisdiele', 'Geschenkeladen', 'Fahrradladen'] },
}
export const GESUNDHEITS_GEWERKE = ['Hausarzt', 'Zahnarzt', 'Kieferorthopäde', 'Augenarzt', 'Frauenarzt', 'Kinderarzt', 'HNO-Arzt', 'Orthopäde', 'Hautarzt', 'Heilpraktiker', 'Tierarzt', 'Psychotherapie', 'Physiotherapie']

SCOUT_WORKER_IDS = Object.values(SCOUT_ROOMS).map((c) => c.workerId); isHealth(room, gewerk) = room === 'aerzte' || GESUNDHEITS_GEWERKE.includes(gewerk); roomOfWorker. src/scouts/regionen.js: REGION_ORTE['Rheinland-Pfalz'] = ['Kaiserslautern', 'Mainz', 'Ludwigshafen am Rhein', 'Koblenz', 'Trier', 'Worms', 'Neustadt an der Weinstraße', 'Speyer', 'Bad Kreuznach', 'Pirmasens', 'Zweibrücken', 'Landau in der Pfalz', 'Frankenthal', 'Idar-Oberstein', 'Neuwied', 'Bad Dürkheim', 'Alzey', 'Bingen am Rhein', 'Mayen', 'Andernach', 'Ingelheim am Rhein', 'Kirchheimbolanden', 'Rockenhausen', 'Winnweiler', 'Otterberg'] (Reihenfolge = Größe/Nähe, Kaiserslautern zuerst); orteFuer(region) = REGION_ORTE[region] ?? [region].

  • Step 4: Bestehen sehen. Derselbe Befehl ⇒ # fail 0.
  • Step 5: Commit. git add src/scouts/rooms.js src/scouts/regionen.js test/p6-rooms.test.js && git commit -m "p6: Scout-Räume, Gewerke, Heilberufe, Orte" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P6-T04: Sendetag und freie Mail-Plätze

Files:

  • Create: src/scoring/slots.js
  • Test: test/p6-slots.test.js

Interfaces:

  • Consumes: getSettings(deps), getDaily(deps, day) (P1-E4), berlinNow (P1-T08); Sammlungen workersThreads (Index idx_status), workersJobs (Index status).

  • Produces: berlinDay(date: Date) → { day: 'YYYY-MM-DD', minutes: number, weekday: 1..7 }; sendDayFor(now: Date, settings) → 'YYYY-MM-DD'; followupThreads(deps, { sendDay }) → Promise<Thread[]> (fällige Nachfass-Fäden bis Ende des Sendetags); mailSlots(deps, { now: Date }) → Promise<{ sendDay, cap, followups, openFirst, sentToday, free }>.

  • Step 1: Test schreiben.

import test from 'node:test'
import assert from 'node:assert/strict'
import { sendDayFor, mailSlots } from '../src/scoring/slots.js'
import { makeDeps, at, seedThread, seedJob } from './support/p6.js'

const S = { sendWindowEnd: '16:00', sendWeekdaysOnly: true }
test('R4: sendDayFor: Berliner Werktag; nach Fensterende und am Wochenende der nächste Werktag', () => {
  assert.equal(sendDayFor(at('2026-10-20T03:30:00Z'), S), '2026-10-20') // Di 05:30
  assert.equal(sendDayFor(at('2026-10-20T15:00:00Z'), S), '2026-10-21') // Di 17:00
  assert.equal(sendDayFor(at('2026-10-24T03:30:00Z'), S), '2026-10-26') // Sa 05:30
  assert.equal(sendDayFor(at('2026-10-23T15:00:00Z'), S), '2026-10-26') // Fr 17:00
})
test('R4: mailSlots: Limit minus fällige Nachfass minus offene Erstmail-Aufträge', async () => {
  const deps = makeDeps()
  await seedThread(deps.db, { followupDueAt: '2026-10-20T05:00:00Z' })                      // am Sendetag fällig
  await seedThread(deps.db, { followupDueAt: '2026-10-21T05:00:00Z' })                      // erst morgen
  await seedThread(deps.db, { followupDueAt: '2026-10-19T05:00:00Z', followupSent: true })  // schon gesendet
  await seedJob(deps.db, { kind: 'erstmail', status: 'offen' })
  await seedJob(deps.db, { kind: 'erstmail', status: 'fertig' })
  assert.deepEqual(await mailSlots(deps, { now: at('2026-10-20T03:30:00Z') }), { sendDay: '2026-10-20', cap: 3, followups: 1, openFirst: 1, sentToday: 0, free: 1 })
})
test('R4: mailSlots: heute schon Gesendetes zählt, nie negativ', async () => {
  const deps = makeDeps()
  await deps.db.create('workersDaily', { costUsd: 0, mailsCold: 3, mailsReply: 0, searches: 0, found: 0, contacted: 0, deals: 0, countersJson: '{}', warned: false, stopped: false }, '2026-10-20')
  const s = await mailSlots(deps, { now: at('2026-10-20T03:30:00Z') })
  assert.deepEqual([s.sentToday, s.free], [3, 0])
})
  • Step 2: Fehlschlag sehen. timeout 300 node --max-old-space-size=1024 --test test/p6-slots.test.js ⇒ FAIL, Modul fehlt.
  • Step 3: Implementieren. berlinDay ruft P1s berlinNow({ now: () => date.getTime() }) (nicht neu rechnen) und liefert { day, minutes: hour * 60 + minute, weekday }. sendDayFor: Start = Berliner Tag von now; ist minutes ≥ Fensterende (aus settings.sendWindowEnd, als Minuten) oder (bei sendWeekdaysOnly) Wochenende, auf den nächsten zulässigen Tag vorrücken. followupThreads: listAll('workersThreads', [Query.equal('status', ['wartet_auf_kunde'])]), im Code channel === 'mail', followupSent falsch, followupDueAt gesetzt und dessen Berliner Tag (berlinNow({ now: () => Date.parse(followupDueAt) }).day) ≤ sendDay. mailSlots: followups = followupThreads(...).length; openFirst = Aufträge mit Query.equal('status', ['offen','laeuft']), im Code kind === 'erstmail'; sentToday = (await getDaily(deps, sendDay))?.mailsCold ?? 0, aber nur wenn sendDay der heutige Berliner Tag ist, sonst 0; free = Math.max(0, cap - followups - openFirst - sentToday).
  • Step 4: Bestehen sehen. Derselbe Befehl ⇒ # fail 0.
  • Step 5: Commit. git add src/scoring/slots.js test/p6-slots.test.js && git commit -m "p6: Sendetag und freie Mail-Plätze" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P6-T05: Abfragewahl der Scouts, Tick-Arten top3 und scouts (E5)

Raum und Region eines Nachtlaufs wählt P1s planNight (P1-T20; connectedRooms, Pause, Budget, Rotation nach Suchprotokoll inbegriffen) und legt den Auftrag suche an. P6 wählt nur noch die konkrete Abfrage (Gewerk × Ort) im Läufer (T12); eine zweite Nachtplanung gibt es nicht. Die Ränge der Tick-Arten (scouts 4, top3 4, kuerzen 5, H1) stehen in P1s src/priority.js; P6-T24 nagelt sie fest. Neu in src/schedule.js sind nur top3 und scouts: kuerzen (täglich ab 03:00) hat P1-T08 schon definiert, P6 legt es nicht noch einmal an (zwei Definitionen derselben Art wären ein Fehler) und liefert dafür nur den Lauf runKuerzen (T17) und den Tick-Eintrag (T24).

Files:

  • Create: src/scouts/plan.js
  • Modify: src/schedule.js
  • Test: test/p6-plan.test.js

Interfaces:

  • Consumes: SCOUT_ROOMS, isHealth, orteFuer (T03), Sammlung workersSearchLog (Index room+at).

  • Produces: naechsteAbfrage({ gewerke, orte, log: [{ query, at }], now: Date, sperrTage = 90 }) → { gewerk, ort, query } | null (rein); pickQuery(deps, { room, region, now: Date }) → { gewerk, ort, query, isHealth } | null (null für Räume ohne Scout oder wenn alle Abfragen der letzten sperrTage gesucht sind); P6 ergänzt isDue/runKey um top3 (täglich ab 05:30 Berlin) und scouts (Intervall 2 Minuten, wie anfragen in P3); kuerzen (täglich ab 03:00) kennt isDue seit P1-T08 und bleibt unverändert.

  • Step 1: Test schreiben.

import test from 'node:test'
import assert from 'node:assert/strict'
import { naechsteAbfrage, pickQuery } from '../src/scouts/plan.js'
import { isDue, berlinNow } from '../src/schedule.js'
import { makeDeps, fakeClock, at, logSearch } from './support/p6.js'

const jetzt = at('2026-10-19T20:00:00Z') // Mo 22:00 Berlin
const G = ['Elektriker', 'Schreiner'], O = ['Kaiserslautern', 'Mainz']

test('naechsteAbfrage: Ort außen, Gewerk innen; Ungesuchtes zuerst, sonst das Älteste nach der Sperrfrist', () => {
  assert.deepEqual(naechsteAbfrage({ gewerke: G, orte: O, log: [], now: jetzt }), { gewerk: 'Elektriker', ort: 'Kaiserslautern', query: 'Elektriker in Kaiserslautern' })
  const log = [{ query: 'Elektriker in Kaiserslautern', at: '2026-10-09T00:00:00Z' }]
  assert.equal(naechsteAbfrage({ gewerke: G, orte: O, log, now: jetzt }).query, 'Schreiner in Kaiserslautern')
  const alle = ['Elektriker in Kaiserslautern', 'Schreiner in Kaiserslautern', 'Elektriker in Mainz', 'Schreiner in Mainz']
  assert.equal(naechsteAbfrage({ gewerke: G, orte: O, log: alle.map((query) => ({ query, at: '2026-10-09T00:00:00Z' })), now: jetzt }), null)
  const alt = alle.map((query, i) => ({ query, at: i === 2 ? '2026-07-01T00:00:00Z' : '2026-10-09T00:00:00Z' }))
  assert.equal(naechsteAbfrage({ gewerke: G, orte: O, log: alt, now: jetzt }).query, 'Elektriker in Mainz')
})
test('pickQuery: Orte der Region, Protokoll des Raums, Heilberuf-Merkmal, Räume ohne Scout', async () => {
  const deps = makeDeps()
  const wahl = (room) => pickQuery(deps, { room, region: 'Rheinland-Pfalz', now: jetzt })
  assert.deepEqual(await wahl('handwerk'), { gewerk: 'Elektriker', ort: 'Kaiserslautern', query: 'Elektriker in Kaiserslautern', isHealth: false })
  await logSearch(deps.db, 'handwerk', '2026-10-18T22:00:00Z', 'Elektriker in Kaiserslautern')
  assert.equal((await wahl('handwerk')).query, 'Sanitär und Heizung in Kaiserslautern')
  await logSearch(deps.db, 'handwerk', '2026-07-01T22:00:00Z', 'Maler und Lackierer in Kaiserslautern') // älter als 90 Tage: die Abfrage gilt trotzdem als bekannt
  assert.equal((await wahl('handwerk')).query, 'Sanitär und Heizung in Kaiserslautern')
  assert.deepEqual([(await wahl('aerzte')).isHealth, (await wahl('selbst')).isHealth], [true, false])
  assert.equal(await wahl('netz'), null)
  assert.equal(await pickQuery(deps, { room: 'handwerk', region: 'Nirgendwo', now: jetzt }).then((q) => q.ort), 'Nirgendwo')
})
test('Ticks top3 (05:30) und scouts (alle 2 Minuten) sind neu; kuerzen (03:00) kommt unverändert aus P1-T08', () => {
  const t = (iso) => berlinNow(fakeClock(iso))
  assert.equal(isDue('top3', t('2026-10-20T03:29:00Z'), null), false)
  assert.equal(isDue('top3', t('2026-10-20T03:30:00Z'), null), true)
  assert.equal(isDue('top3', t('2026-10-20T09:00:00Z'), '2026-10-20'), false)
  // Pin, kein neuer Code: P1-T08 definiert kuerzen schon (täglich ab 03:00, einmal je Berliner Tag); P6 ändert daran nichts
  assert.equal(isDue('kuerzen', t('2026-10-20T00:59:00Z'), null), false)
  assert.equal(isDue('kuerzen', t('2026-10-20T01:00:00Z'), null), true)
  assert.equal(isDue('kuerzen', t('2026-10-20T09:00:00Z'), '2026-10-20'), false)
  const n = t('2026-10-20T09:00:00Z')
  assert.deepEqual([isDue('scouts', n, null), isDue('scouts', n, n.ts - 119999), isDue('scouts', n, n.ts - 120000)], [true, false, true])
})
  • Step 2: Fehlschlag sehen. timeout 300 node --max-old-space-size=1024 --test test/p6-plan.test.js ⇒ FAIL, Cannot find module '../src/scouts/plan.js'.
  • Step 3: Implementieren. naechsteAbfrage: Kandidatenliste in Reihenfolge for ort of orte { for gewerk of gewerke }, Abfrage "<gewerk> in <ort>"; je query zählt der jüngste Logeintrag; erster Kandidat ohne Eintrag gewinnt; sonst der Kandidat mit dem ältesten Eintrag, der älter als sperrTage ist; sonst null. pickQuery: SCOUT_ROOMS[room] fehlt ⇒ null; Orte = orteFuer(region); Log = list('workersSearchLog', [Query.equal('room', [room]), Query.orderDesc('at'), Query.limit(100)]); Ergebnis von naechsteAbfrage mit isHealth(room, gewerk) ergänzt. In src/schedule.js die Tabelle von P1-T08 erweitern: top3 als tägliche Art wie bericht (ab 05:30, Marker = Berliner Tag) und scouts als Intervallart wie posteingang (120 000 ms). kuerzen und pultAlt stehen dort schon (P1-T08) und werden nicht angefasst.
  • Step 4: Bestehen sehen. Derselbe Befehl ⇒ # fail 0; timeout 300 node --max-old-space-size=1024 --test test/schedule.test.js (P1) ⇒ grün. Kontrolle: git diff -U0 -- src/schedule.js | rg -a kuerzen ⇒ keine Ausgabe (Exit 1 ist hier richtig: P6 hat kuerzen nicht neu angelegt).
  • Step 5: Commit. git add src/scouts/plan.js src/schedule.js test/p6-plan.test.js && git commit -m "p6: Abfragewahl der Scouts, Tick-Arten top3 und scouts" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P6-T06: Places-Client (Text Search mit fester Feldmaske)

Files:

  • Create: src/scouts/places.js
  • Test: test/p6-places.test.js

Interfaces:

  • Produces: PLACES_FIELD_MASK: string, PLACES_REQUEST_USD = 0.035, class PlacesError extends Error { status: number }, createPlacesClient({ apiKey, fetchImpl = fetch }) → { textSearch({ query, pageToken? }) → Promise<{ places: [{ placeId, name, websiteUri, rating, userRatingCount, businessStatus }], nextPageToken: string|null }> }. Passt zu deps.places (E4).

  • Step 1: Test schreiben.

import test from 'node:test'
import assert from 'node:assert/strict'
import { createPlacesClient, PLACES_FIELD_MASK, PLACES_REQUEST_USD, PlacesError } from '../src/scouts/places.js'

const antwort = (status, body) => ({ ok: status < 300, status, json: async () => body, text: async () => JSON.stringify(body) })

test('textSearch: feste Feldmaske, Schlüssel nur im Header, schlanke Antwort', async () => {
  const calls = []
  const fetchImpl = async (url, init) => { calls.push({ url, init }); return antwort(200, { places: [{ id: 'ChIJa', displayName: { text: 'Elektro Beispiel' }, websiteUri: 'https://beispiel-elektro.de', rating: 4.7, userRatingCount: 80, businessStatus: 'OPERATIONAL' }], nextPageToken: 'T2' }) }
  const r = await createPlacesClient({ apiKey: 'KEY123', fetchImpl }).textSearch({ query: 'Elektriker in Kaiserslautern' })
  assert.equal(calls[0].url, 'https://places.googleapis.com/v1/places:searchText')
  assert.equal(PLACES_FIELD_MASK, 'places.id,places.displayName,places.websiteUri,places.rating,places.userRatingCount,places.businessStatus,nextPageToken')
  assert.equal(calls[0].init.headers['X-Goog-FieldMask'], PLACES_FIELD_MASK)
  assert.equal(calls[0].init.headers['X-Goog-Api-Key'], 'KEY123')
  assert.deepEqual(JSON.parse(calls[0].init.body), { textQuery: 'Elektriker in Kaiserslautern', languageCode: 'de', regionCode: 'DE', pageSize: 20 })
  assert.deepEqual(r, { places: [{ placeId: 'ChIJa', name: 'Elektro Beispiel', websiteUri: 'https://beispiel-elektro.de', rating: 4.7, userRatingCount: 80, businessStatus: 'OPERATIONAL' }], nextPageToken: 'T2' })
  assert.equal(PLACES_REQUEST_USD, 0.035)
})
test('Folgeseite sendet pageToken; fehlende Felder bekommen neutrale Werte; Einträge ohne id entfallen', async () => {
  const bodies = []
  const fetchImpl = async (_u, init) => { bodies.push(JSON.parse(init.body)); return antwort(200, { places: [{ id: 'ChIJb', displayName: { text: 'X' } }, { displayName: { text: 'ohne id' } }] }) }
  const r = await createPlacesClient({ apiKey: 'k', fetchImpl }).textSearch({ query: 'q', pageToken: 'T2' })
  assert.equal(bodies[0].pageToken, 'T2')
  assert.deepEqual(r, { places: [{ placeId: 'ChIJb', name: 'X', websiteUri: '', rating: null, userRatingCount: 0, businessStatus: '' }], nextPageToken: null })
})
test('Fehler: PlacesError mit Status, ohne Schlüssel oder Antworttext in der Meldung', async () => {
  const c = createPlacesClient({ apiKey: 'KEY123', fetchImpl: async () => antwort(403, { error: { message: 'API key KEY123 not valid' } }) })
  await assert.rejects(() => c.textSearch({ query: 'q' }), (e) => e instanceof PlacesError && e.status === 403 && !e.message.includes('KEY123') && e.message === 'Places-Anfrage fehlgeschlagen (HTTP 403).')
  const d = createPlacesClient({ apiKey: 'k', fetchImpl: async () => { throw new Error('ECONNRESET KEY123') } })
  await assert.rejects(() => d.textSearch({ query: 'q' }), (e) => e instanceof PlacesError && e.status === 0 && !e.message.includes('KEY123'))
})
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/p6-places.test.js ⇒ FAIL, Modul fehlt.
  • Step 3: Implementieren. POST mit Headern Content-Type: application/json, X-Goog-Api-Key, X-Goog-FieldMask; Körper wie im Test, pageToken nur wenn gesetzt; signal: AbortSignal.timeout(20000); Antwort-Mapping placeId = id, name = displayName?.text ?? '', websiteUri ?? '', rating ?? null, userRatingCount ?? 0, businessStatus ?? '', Einträge ohne id verwerfen, nextPageToken ?? null. Fehlermeldungen fest: Places-Anfrage fehlgeschlagen (HTTP <status>). bzw. Places nicht erreichbar. (Status 0), nie Text der Antwort oder des Netzfehlers.
  • Step 4: Bestehen sehen. Derselbe Befehl ⇒ # fail 0.
  • Step 5: Commit. git add src/scouts/places.js test/p6-places.test.js && git commit -m "p6: Places-Client mit fester Feldmaske" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P6-T07: OpenStreetMap-Client (Overpass, zurückhaltend)

Keine Änderung durch den Abgleich: Der OSM-User-Agent bleibt WEBklar-Workers/1.0 (support@webklar.com); sein Produkt-Token WEBklar-Workers/1.0 passt zu USER_AGENT aus src/net/robots.js.

Files:

  • Create: src/scouts/osm.js
  • Test: test/p6-osm.test.js

Interfaces:

  • Consumes: normalizeName aus src/register/normalize.js.

  • Produces: overpassQuery({ name, ort }) → string; createOsmClient({ fetchImpl = fetch, sleep, userAgent = 'WEBklar-Workers/1.0 (support@webklar.com)', minGapMs = 1500, maxCalls = 12 }) → { lookup({ name, ort }) → Promise<null | { osmUrl, name, phone, website, street, postalCode, city }> }. Passt zu deps.osm (E4).

  • Step 1: Test schreiben.

import test from 'node:test'
import assert from 'node:assert/strict'
import { overpassQuery, createOsmClient } from '../src/scouts/osm.js'

const el = { type: 'node', id: 7, tags: { name: 'Elektro Müller', phone: '+49 631 111', 'contact:email': 'x@y.de', website: 'https://mueller-elektro.de', 'addr:street': 'Hauptstraße', 'addr:housenumber': '5', 'addr:postcode': '67655', 'addr:city': 'Kaiserslautern' } }
const fetchMit = (status, elements = []) => {
  const calls = []
  const f = async (url, init) => { calls.push({ url, init }); return { ok: status === 200, status, json: async () => ({ elements }), text: async () => '' } }
  f.calls = calls
  return f
}

test('overpassQuery: Suchwort nur aus [a-z0-9] (längstes Wort ab 4 Zeichen, bei Gleichstand das erste), Ort maskiert', () => {
  const q = overpassQuery({ name: 'Müller "Elektro" & Söhne', ort: 'Bad "Dürkheim"' })
  assert.ok(q.startsWith('[out:json][timeout:20];'))
  assert.ok(q.includes('["name"~"mueller",i]'))
  assert.ok(q.includes('["name"="Bad \\"Dürkheim\\""]'))
})
test('lookup: Treffer mit gleichem Namen liefert Telefon, Website, Anschrift und nie eine E-Mail; Abstand 1 500 ms', async () => {
  const sleeps = []
  const f = fetchMit(200, [el])
  const osm = createOsmClient({ fetchImpl: f, sleep: async (ms) => sleeps.push(ms) })
  const r = await osm.lookup({ name: 'Elektro Müller GmbH', ort: 'Kaiserslautern' })
  assert.deepEqual(r, { osmUrl: 'https://www.openstreetmap.org/node/7', name: 'Elektro Müller', phone: '+49 631 111', website: 'https://mueller-elektro.de', street: 'Hauptstraße 5', postalCode: '67655', city: 'Kaiserslautern' })
  assert.ok(!JSON.stringify(r).includes('x@y.de'))
  assert.match(f.calls[0].init.headers['User-Agent'], /support@webklar\.com/)
  await osm.lookup({ name: 'Elektro Müller GmbH', ort: 'Kaiserslautern' })
  assert.deepEqual(sleeps, [1500])
})
test('anderer Name oder anderer Ort ergibt null', async () => {
  const osm = createOsmClient({ fetchImpl: fetchMit(200, [el]), sleep: async () => {} })
  assert.equal(await osm.lookup({ name: 'Bäckerei Schmitt', ort: 'Kaiserslautern' }), null)
  assert.equal(await osm.lookup({ name: 'Elektro Müller', ort: 'Mainz' }), null)
})
test('nach dem ersten Fehler keine weitere Anfrage im Lauf; höchstens 12 Anfragen', async () => {
  const f = fetchMit(429)
  const osm = createOsmClient({ fetchImpl: f, sleep: async () => {} })
  assert.equal(await osm.lookup({ name: 'Elektro Müller', ort: 'Mainz' }), null)
  assert.equal(await osm.lookup({ name: 'Elektro Müller', ort: 'Mainz' }), null)
  assert.equal(f.calls.length, 1)
  const g = fetchMit(200)
  const osm2 = createOsmClient({ fetchImpl: g, sleep: async () => {} })
  for (let i = 0; i < 15; i++) await osm2.lookup({ name: `Betrieb Nummer ${i}`, ort: 'Mainz' })
  assert.equal(g.calls.length, 12)
})
  • Step 2: Fehlschlag sehen. timeout 300 node --max-old-space-size=1024 --test test/p6-osm.test.js ⇒ FAIL, Modul fehlt.
  • Step 3: Implementieren. Abfrage: [out:json][timeout:20];area["boundary"="administrative"]["name"="<ort maskiert>"]->.a;nwr(area.a)["name"~"<wort>",i];out tags center 15; (Maskierung: \ und " mit vorgestelltem \, Steuerzeichen entfernen; das Wort kommt aus normalizeName und enthält nur [a-z0-9]). lookup: POST https://overpass-api.de/api/interpreter, Körper data=<encodeURIComponent(query)>, Header Content-Type: application/x-www-form-urlencoded, User-Agent; vor jeder weiteren Anfrage await sleep(minGapMs) (erste Anfrage ohne Wartezeit); Zähler maxCalls; nach Status ≠ 200 oder Netzfehler Schalter gestoerrt ⇒ alle weiteren Aufrufe null ohne Anfrage. Trefferwahl: erstes Element mit tags.name, dessen normalizeName gleich ist oder das kürzere (≥ 8 Zeichen) als ganze Wortfolge im längeren steht, und dessen addr:city fehlt oder gleich ort (normalizeName) ist. Felder: phone aus phone oder contact:phone, website aus website oder contact:website, street = addr:street + Leerzeichen + addr:housenumber, osmUrl = https://www.openstreetmap.org/<type>/<id>; E-Mail-Tags werden nie gelesen.
  • Step 4: Bestehen sehen. Derselbe Befehl ⇒ # fail 0.
  • Step 5: Commit. git add src/scouts/osm.js test/p6-osm.test.js && git commit -m "p6: OSM-Client mit Abstand, Obergrenze und Fehlerschalter" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P6-T08: Eigene Website lesen: Impressum und Kontakt ohne Sprachmodell (H2)

Files:

  • Create: src/scouts/ownsite.js
  • Test: test/p6-ownsite.test.js

Interfaces:

  • Consumes: deps.fetch (P3; Signatur wie Node-fetch), deps.dns (resolveMx, resolve4), deps.sleep(ms) (optional, Standard ein echter Timer; T01), robotsAllows(fetchImpl, url) und USER_AGENT aus src/net/robots.js (P3, A16), extractAnchors(html), htmlToLines(html), decodeEntities(s) aus src/anfragen/alerts/html.js (P3), BOT_VERBOT_DOMAINS aus src/claude.js (P1, A10), normalizeDomain (liefert die registrierbare Domain, '' für Social- und Verzeichnis-Hosts, P1-T17), normalizeName aus src/register/normalize.js, assertCap.

  • Produces: extractEmails(pages) → [{ email, url }] (pages = [{ url, text, mailtos? }]); pickEmail(list, host) → { email, url } | null; latestYear(text, nowYear) → number | null; mailDomainOk(dns, domain) → Promise<boolean> (nur MX); isPrivateIp(ip: string) → boolean; readOwnSite(deps, { workerId, website, ort }) → Promise<OwnSite> mit OwnSite = { status: 'ok'|'keine_website'|'robots'|'nicht_lesbar', host, websiteOrigin, pages: [{url,text}] (nur im Speicher), companyName, contactName, email, emailSource, mailProblem: 'mx'|null, phone, phoneSource, street, postalCode, city, impressumUrl, latestYear } (nicht belegte Felder '', latestYear null). Es gibt kein Schema, keinen Prompt und keinen Modellaufruf: OWNSITE_SCHEMA, SCOUT_SYSTEM und EXTRACT_SYSTEM des früheren Entwurfs entfallen.

  • Step 1: Test schreiben. Alle Tests laufen mit deps.fetch = siteFetch(...) und injiziertem deps.dns, nie mit Netz.

import test from 'node:test'
import assert from 'node:assert/strict'
import { extractEmails, pickEmail, latestYear, mailDomainOk, isPrivateIp, readOwnSite } from '../src/scouts/ownsite.js'
import { USER_AGENT } from '../src/net/robots.js'
import { CapError } from '../src/roles.js'
import { makeDeps, siteFetch, betriebSeiten, page } from './support/p6.js'

const H = 'beispiel-elektro.de'
const URL_START = `https://${H}/`
const IMPRESSUM = (zeilen = ['E-Mail: info@beispiel-elektro.de']) => `<h1>Impressum</h1><p>Angaben gemäß § 5 TMG</p><p>Elektro Beispiel GmbH<br>Hauptstraße 5<br>67655 Kaiserslautern</p><p>Geschäftsführer: Max Beispiel</p><p>Telefon: 0631 123456<br>${zeilen.join('<br>')}</p>`
const STANDARD = { phone: '0631 123456', chef: 'Max Beispiel' }
async function lesen(seiten, { website = URL_START, dns = {}, workerId = 'hanna' } = {}) {
  const deps = makeDeps()
  deps.fetch = siteFetch(seiten)
  Object.assign(deps.dns, dns)
  return { deps, o: await readOwnSite(deps, { workerId, website, ort: 'Kaiserslautern' }) }
}
const nichtRobots = (deps) => deps.fetch.calls.map((c) => c.url).filter((u) => !u.endsWith('/robots.txt'))

test('extractEmails: nur wörtlich Veröffentlichtes, keine Verschleierung, keine Rollenadressen', () => {
  const text = 'Mail: Info@Beispiel-Elektro.de. Auch: info [at] zweit.de, info(at)dritt.de, logo@2x.png, noreply@beispiel-elektro.de, datenschutz@beispiel-elektro.de, privat@gmail.com'
  assert.deepEqual(extractEmails([page('https://beispiel-elektro.de/impressum', text)]).map((e) => e.email), ['info@beispiel-elektro.de', 'privat@gmail.com'])
})
test('pickEmail: eigene Domain zuerst, dann info/kontakt/mail/office/hallo/post, sonst die erste', () => {
  const L = (...e) => e.map((email) => ({ email, url: 'u' }))
  assert.equal(pickEmail(L('privat@gmail.com', 'chef@beispiel-elektro.de', 'info@beispiel-elektro.de'), 'beispiel-elektro.de').email, 'info@beispiel-elektro.de')
  assert.equal(pickEmail(L('privat@gmail.com', 'kontakt@gmx.de'), 'beispiel-elektro.de').email, 'kontakt@gmx.de')
  assert.equal(pickEmail(L('a@gmx.de', 'b@gmx.de'), 'x.de').email, 'a@gmx.de')
  assert.equal(pickEmail([], 'x.de'), null)
})
test('latestYear: Jahr nach ©, Copyright, Stand, aktualisiert; Spannen zählen ihr Ende; keine Zukunft', () => {
  assert.equal(latestYear('© 2016 Elektro Beispiel, Stand: März 2019', 2026), 2019)
  assert.equal(latestYear('© 2016–2024 Elektro Beispiel', 2026), 2024)
  assert.equal(latestYear('Telefon 0631 20 2010', 2026), null)
  assert.equal(latestYear('© 2031 Elektro Beispiel', 2026), null)
})
test('extractEmails: mailto-Adressen aus dem href zählen wie sichtbarer Text (klein geschrieben, ohne Duplikate)', () => {
  assert.deepEqual(extractEmails([{ url: 'u', text: 'Schreiben Sie uns', mailtos: ['Kontakt@Beispiel-Elektro.de', 'kontakt@beispiel-elektro.de'] }]).map((e) => e.email), ['kontakt@beispiel-elektro.de'])
})
test('mailDomainOk: nur MX zählt – ohne MX ist die Adresse unzustellbar, bei vorübergehenden Fehlern im Zweifel zustellbar', async () => {
  const err = (code) => Object.assign(new Error(code), { code })
  const dns = (mx) => ({ resolveMx: async () => { if (mx instanceof Error) throw mx; return mx } })
  assert.equal(await mailDomainOk(dns([{ exchange: 'mx.x.de', priority: 10 }]), 'x.de'), true)
  assert.equal(await mailDomainOk(dns([]), 'x.de'), false)
  assert.equal(await mailDomainOk(dns(err('ENOTFOUND')), 'x.de'), false)
  assert.equal(await mailDomainOk(dns(err('ENODATA')), 'x.de'), false)
  assert.equal(await mailDomainOk(dns(err('ETIMEOUT')), 'x.de'), true)
})
test('isPrivateIp: Loopback, Link-Local, private und CGNAT-Bereiche; öffentliche Adressen nicht', () => {
  for (const ip of ['0.0.0.0', '10.0.0.1', '100.64.0.1', '127.0.0.1', '169.254.1.1', '172.16.5.4', '172.31.255.255', '192.168.1.1', '::1', 'kein-ip']) assert.equal(isPrivateIp(ip), true, ip)
  for (const ip of ['8.8.8.8', '172.32.0.1', '192.0.2.1', '100.128.0.1']) assert.equal(isPrivateIp(ip), false, ip)
})
test('readOwnSite: Mail, Telefon, Anschrift, Firma und Ansprechpartner aus dem Impressum – kein Modellaufruf, zwei Seiten', async () => {
  const { deps, o } = await lesen(betriebSeiten(H, STANDARD))
  assert.deepEqual([o.status, o.email, o.emailSource, o.phone, o.phoneSource, o.impressumUrl], ['ok', 'info@beispiel-elektro.de', `${URL_START}impressum`, '0631 123456', `${URL_START}impressum`, `${URL_START}impressum`])
  assert.deepEqual([o.companyName, o.contactName, o.street, o.postalCode, o.city, o.websiteOrigin, o.latestYear, o.mailProblem], ['Elektro Beispiel GmbH', 'Max Beispiel', 'Hauptstraße 5', '67655', 'Kaiserslautern', 'https://beispiel-elektro.de', 2016, null])
  assert.deepEqual(nichtRobots(deps), [URL_START, `${URL_START}impressum`])
  assert.deepEqual(o.pages.map((p) => p.url), [URL_START, `${URL_START}impressum`])
  assert.deepEqual(deps.sleeps, [1000], '1 s Pause vor der zweiten Seite')
  for (const c of deps.fetch.calls.filter((x) => !x.url.endsWith('/robots.txt'))) {
    assert.equal(c.init.headers['User-Agent'], USER_AGENT)
    assert.equal(c.init.redirect, 'manual')
    assert.ok(c.init.signal instanceof AbortSignal, 'Zeitlimit je Abruf')
  }
  assert.equal(deps.claude.calls.ask.length + deps.claude.calls.search.length, 0, 'H2: kein Sprachmodell')
})
test('Weiterleitung innerhalb der Domain (http → https, www) wird verfolgt; websiteOrigin ist die Endadresse', async () => {
  const www = (u) => u.replace(`https://${H}/`, `https://www.${H}/`)
  const m = { [`http://${H}/`]: { status: 301, headers: { location: `https://www.${H}/` } } }
  for (const [u, t] of Object.entries(betriebSeiten(H, STANDARD))) m[www(u)] = t
  const { o } = await lesen(m, { website: `http://${H}/` })
  assert.deepEqual([o.status, o.websiteOrigin, o.emailSource], ['ok', `https://www.${H}`, `https://www.${H}/impressum`])
})
test('R2: die Mail kommt nur aus lesbarem Text – Verschleiertes zählt nicht, die übrigen Angaben bleiben', async () => {
  const s = betriebSeiten(H, STANDARD)
  s[`${URL_START}impressum`] = IMPRESSUM(['E-Mail: info [at] beispiel-elektro.de'])
  const { o } = await lesen(s)
  assert.deepEqual([o.status, o.email, o.emailSource, o.mailProblem, o.phone], ['ok', '', '', null, '0631 123456'])
})
test('R2: eine Adresse nur auf der Startseite zählt nicht; Impressum, Kontaktseite und mailto-Links zählen', async () => {
  const start = `<html><body><p>© 2016 Elektro Beispiel – info@${H}</p><a href="/impressum">Impressum</a><a href="/kontakt/">Kontakt</a></body></html>`
  const ohneMail = IMPRESSUM([])
  const kontakt = (html) => ({ [URL_START]: start, [`${URL_START}impressum`]: ohneMail, [`${URL_START}kontakt/`]: html })
  const nurStart = (await lesen(kontakt('<p>Rufen Sie uns an.</p>'))).o
  assert.deepEqual([nurStart.status, nurStart.email, nurStart.emailSource], ['ok', '', ''])
  const text = (await lesen(kontakt('<p>Schreiben Sie uns: kontakt@beispiel-elektro.de</p>'))).o
  assert.deepEqual([text.email, text.emailSource], ['kontakt@beispiel-elektro.de', `${URL_START}kontakt/`])
  const href = (await lesen(kontakt('<p><a href="mailto:post@beispiel-elektro.de?subject=Anfrage">Schreiben Sie uns</a></p>'))).o
  assert.deepEqual([href.email, href.emailSource], ['post@beispiel-elektro.de', `${URL_START}kontakt/`])
})
test('R2: robots.txt verbietet den Abruf ⇒ Status robots ohne Seitenabruf; ein verbotenes Impressum ergibt keine Mail', async () => {
  const s = betriebSeiten(H, STANDARD)
  const alles = await lesen({ ...s, [`${URL_START}robots.txt`]: 'User-agent: *\nDisallow: /' })
  assert.equal(alles.o.status, 'robots')
  assert.deepEqual(nichtRobots(alles.deps), [])
  const token = await lesen({ ...s, [`${URL_START}robots.txt`]: 'User-agent: WEBklar-Workers\nDisallow: /' })
  assert.equal(token.o.status, 'robots')
  const impressum = await lesen({ ...s, [`${URL_START}robots.txt`]: 'User-agent: *\nDisallow: /impressum' })
  assert.deepEqual([impressum.o.status, impressum.o.email, impressum.o.emailSource], ['ok', '', ''])
  assert.deepEqual(nichtRobots(impressum.deps), [URL_START])
})
test('Social-Hosts und Plattformen sind keine Website; ein privater oder nicht auflösbarer Host wird nicht abgerufen', async () => {
  for (const w of ['https://www.facebook.com/beispiel', 'https://www.instagram.com/beispiel', 'https://www.kleinanzeigen.de/s-anzeige/x', '', 'ftp://beispiel-elektro.de/']) {
    const { deps, o } = await lesen(betriebSeiten(H), { website: w })
    assert.equal(o.status, 'keine_website', w)
    assert.equal(deps.fetch.calls.length, 0, w)
  }
  const privat = await lesen(betriebSeiten(H), { dns: { resolve4: async () => ['192.168.0.10'] } })
  assert.deepEqual([privat.o.status, privat.deps.fetch.calls.length], ['nicht_lesbar', 0]) // auch robots.txt wird nicht geholt
  const fehlt = await lesen(betriebSeiten(H), { dns: { resolve4: async () => { throw Object.assign(new Error('x'), { code: 'ENOTFOUND' }) } } })
  assert.deepEqual([fehlt.o.status, fehlt.deps.fetch.calls.length], ['nicht_lesbar', 0])
})
test('Seitenlimit: höchstens 3 Seiten (Start, Impressum, Kontakt), 1 s Pause dazwischen; Links auf fremde Domains und weitere Treffer werden nicht abgerufen', async () => {
  const start = `<html><body>© 2016 <a href="https://www.facebook.com/impressum">Impressum bei Facebook</a><a href="/kontakt/">Kontakt</a><a href="/impressum">Impressum</a><a href="/impressum-alt">Impressum alt</a><a href="/kontakt-2">Kontakt 2</a><a href="/datenschutz">Datenschutz</a></body></html>`
  const seiten = { [URL_START]: start, [`${URL_START}impressum`]: IMPRESSUM(), [`${URL_START}kontakt/`]: '<p>Kontakt: kontakt@beispiel-elektro.de</p>' }
  const { deps, o } = await lesen(seiten)
  assert.deepEqual(nichtRobots(deps), [URL_START, `${URL_START}impressum`, `${URL_START}kontakt/`])
  assert.deepEqual(deps.sleeps, [1000, 1000])
  assert.deepEqual([o.email, o.emailSource], ['info@beispiel-elektro.de', `${URL_START}impressum`]) // gleiche Punktzahl: die erste (Impressum)
})
test('Zeitüberschreitung, Fehlerstatus, Typ, Größe und Weiterleitungen: die Startseite entscheidet über nicht_lesbar, spätere Seiten werden ausgelassen', async () => {
  const s = betriebSeiten(H, STANDARD)
  assert.equal((await lesen({ ...s, [URL_START]: { timeout: true } })).o.status, 'nicht_lesbar')
  const spaet = (await lesen({ ...s, [`${URL_START}impressum`]: { timeout: true } })).o
  assert.deepEqual([spaet.status, spaet.email], ['ok', ''])
  assert.equal((await lesen({ ...s, [URL_START]: { status: 404 } })).o.status, 'nicht_lesbar')
  assert.equal((await lesen({ ...s, [URL_START]: { headers: { 'content-type': 'application/pdf' }, body: '%PDF' } })).o.status, 'nicht_lesbar')
  assert.equal((await lesen({ ...s, [URL_START]: { headers: { 'content-length': '2000000' }, body: 'x' } })).o.status, 'nicht_lesbar')
  const weiter = (n) => { // n Weiterleitungen innerhalb der Domain, dann die Startseite
    const pfad = (i) => (i === 0 ? URL_START : `https://${H}/w${i}`)
    const m = { ...s }
    for (let i = 0; i < n; i++) m[pfad(i)] = { status: 301, headers: { location: pfad(i + 1) } }
    m[pfad(n)] = s[URL_START]
    return m
  }
  assert.equal((await lesen(weiter(3))).o.status, 'ok')
  assert.equal((await lesen(weiter(4))).o.status, 'nicht_lesbar')
  assert.equal((await lesen({ ...s, [URL_START]: { status: 302, headers: { location: 'https://anderswo.example/' } } })).o.status, 'nicht_lesbar')
})
test('R2: Mail-Domain ohne MX ⇒ Adresse verworfen, mailProblem "mx"; ein vorübergehender DNS-Fehler verwirft nichts', async () => {
  const fehler = (code) => async () => { throw Object.assign(new Error('x'), { code }) }
  const kein = (await lesen(betriebSeiten(H, STANDARD), { dns: { resolveMx: fehler('ENODATA') } })).o
  assert.deepEqual([kein.status, kein.email, kein.emailSource, kein.mailProblem, kein.phone], ['ok', '', '', 'mx', '0631 123456'])
  assert.equal((await lesen(betriebSeiten(H, STANDARD), { dns: { resolveMx: fehler('ETIMEOUT') } })).o.email, 'info@beispiel-elektro.de')
})
test('kein Raten: fehlende Angaben bleiben leer (Fax zählt nicht als Telefon); der Firmenname fällt auf og:site_name, dann auf den Titel zurück', async () => {
  const impressum = '<h1>Impressum</h1><p>Elektro Beispiel<br>Fax: 0631 999999<br>E-Mail: info@beispiel-elektro.de</p>'
  const mitOg = { [URL_START]: '<html><head><meta property="og:site_name" content="Elektro Beispiel – Meisterbetrieb"><title>Start | Elektro</title></head><body><a href="/impressum">Impressum</a></body></html>', [`${URL_START}impressum`]: impressum }
  const a = (await lesen(mitOg)).o
  assert.deepEqual([a.phone, a.phoneSource, a.street, a.postalCode, a.city, a.contactName, a.companyName, a.email], ['', '', '', '', '', '', 'Elektro Beispiel – Meisterbetrieb', 'info@beispiel-elektro.de'])
  const nurTitel = (await lesen({ ...mitOg, [URL_START]: mitOg[URL_START].replace(/<meta[^>]*>/, '') })).o
  assert.equal(nurTitel.companyName, 'Start')
})
test('Fähigkeit web_fetch: nur Rollen mit dieser Fähigkeit lesen Websites', async () => {
  const deps = makeDeps()
  deps.fetch = siteFetch(betriebSeiten(H))
  await assert.rejects(() => readOwnSite(deps, { workerId: 'max', website: URL_START, ort: 'Kaiserslautern' }), CapError)
  assert.equal(deps.fetch.calls.length, 0)
})
  • Step 2: Fehlschlag sehen. timeout 300 node --max-old-space-size=1024 --test test/p6-ownsite.test.js ⇒ FAIL, Modul fehlt.
  • Step 3: Implementieren.
    • extractEmails: Muster [A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,} über den Text jeder Seite und über deren mailtos (Adressen aus mailto:-Hrefs), klein geschrieben, Duplikate entfernt; verworfen werden Endungen png jpg jpeg gif svg webp css js und Lokalteile noreply no-reply donotreply datenschutz privacy dpo abuse webmaster postmaster. Keine Entschleierung.
    • pickEmail: Punkte 2 für Domain gleich host oder Unterdomain, 1 für Lokalteil in info kontakt mail office hallo post; höchste Punktzahl, bei Gleichstand die erste.
    • latestYear: Jahre (19xx|20xx) hinter ©, (c), copyright, stand, aktualisiert, letzte änderung (bis 25 Nicht-Ziffern dazwischen), mit optionalem Spannenende nach – oder -; größtes Jahr ≤ nowYear, sonst null.
    • mailDomainOk(dns, domain): nur dns.resolveMx(domain) (H2: MX-only, kein Rückgriff auf resolve4): nicht leer ⇒ true; leere Antwort oder Fehlercode ENOTFOUND/ENODATA ⇒ false; jeder andere Fehlercode ⇒ true (im Zweifel zustellbar).
    • isPrivateIp(ip): IPv4 in 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16; alles, was keine IPv4-Adresse ist (auch ::1), gilt als privat (nicht abrufen).
    • readOwnSite(deps, { workerId, website, ort }), Ablauf in dieser Reihenfolge, nie ein Modellaufruf:
      1. assertCap(workerId, 'web_fetch') (wie search prüft auch das Lesen der Website die Fähigkeit; Rollen ohne sie bekommen CapError).
      2. host = normalizeDomain(website); ist er leer, das Schema weder http noch https (fehlt es, wird https:// vorangestellt), der Host in BOT_VERBOT_DOMAINS (Punktgrenze) oder eine IP-Adresse ⇒ { status: 'keine_website' } ohne Abruf (das deckt Facebook, Instagram und Verzeichnisse ab).
      3. Vor dem ersten Abruf je Hostname: deps.dns.resolve4(hostname); Fehler, leere Antwort oder isPrivateIp für irgendeine Adresse ⇒ Startseite: nicht_lesbar, spätere Seite: auslassen; es folgt dann kein Abruf, auch nicht von robots.txt.
      4. Jede Seite wird so geholt: robotsAllows(deps.fetch, url) (P3; Startseite verboten ⇒ Status robots, andere Seite ⇒ auslassen), dann deps.fetch(url, { headers: { 'User-Agent': USER_AGENT, Accept: 'text/html,text/plain;q=0.9' }, redirect: 'manual', signal: AbortSignal.timeout(10000) }). Weiterleitungen (301, 302, 303, 307, 308) folgt der Code selbst, höchstens 3, nur wenn das Ziel http/https ist und normalizeDomain(ziel) === host (andere Domain ⇒ Seite fehlgeschlagen); jedes Ziel durchläuft wieder Punkt 3 und robotsAllows. Eine Antwort gilt nur mit Status 200, content-type fehlend oder text/html, application/xhtml+xml, text/plain, content-length ≤ 1 MB und Textlänge ≤ 1 048 576 Zeichen; alles andere, Netzfehler und Zeitüberschreitung ⇒ Seite fehlgeschlagen (Startseite ⇒ nicht_lesbar; spätere Seite ⇒ auslassen, kein Abbruch).
      5. Höchstens drei Seitenabrufe (robots.txt zählt nicht): Startseite, dann der erste Impressum-Link, dann der erste Kontakt-Link. Die Links stammen aus extractAnchors(html) der Startseite, aufgelöst gegen deren Endadresse; Impressum-Link = Linktext oder href passt auf /impressum|imprint/i, Kontakt-Link = /kontakt|contact/i (ein Link, der beide trifft, zählt als Impressum); nur gleiche registrierbare Domain, nur http/https, ohne #-Fragment, nicht die Startseite selbst, keine Adresse doppelt. Es werden keine Pfade geraten: fehlt der Link, fehlt die Seite. Vor jeder weiteren Seite await (deps.sleep ?? warte)(1000) (warte = Timer; nicht vor der Startseite).
      6. Text: htmlToLines(html) je Seite; pages = [{ url (Endadresse), text, rolle: 'start'|'impressum'|'kontakt', mailtos }] mit mailtos = Adressen aus den mailto:-Hrefs der Seite (vor ?, dekodiert). Die Mail kommt nur aus Seiten mit Rolle impressum oder kontakt: pickEmail(extractEmails(<diese Seiten, Impressum zuerst>), host); emailSource = deren URL; eine Adresse von der Startseite oder aus einem Beitrag zählt nie (so verspricht es der Datenschutztext in T20).
      7. Regex-Auslesen aus den Zeilen der Impressum-, dann der Kontaktseite (kein Modell, nichts erfinden, nichts raten):
        • Telefon: Zeile mit Tel(efon)?|Fon|Phone|Mobil (nicht Fax), danach optional : oder . und eine Nummer aus Ziffern, Leerzeichen und + ( ) / . - mit 6 bis 15 Ziffern; phoneSource = URL der Seite.
        • PLZ und Ort: \b(\d{5})\s+(\p{Lu}[\p{L}.\-]*(?:[ \-]\p{L}[\p{L}.\-]*){0,3}) (Flag u); bei mehreren Treffern gewinnt der mit normalizeName(city) === normalizeName(ort), sonst der erste. Straße: der Text vor der PLZ in derselben Zeile (nach Komma oder Semikolon) oder die Zeile davor, wenn sie auf ^\p{Lu}[\p{L}.\- ]{2,40}\s\d{1,4}\s?[a-zA-Z]?$ passt.
        • Firmenname: nach der Zeile Angaben gemäß § 5 (TMG|DDG) die erste der nächsten drei Zeilen mit Rechtsform (GmbH, GmbH & Co. KG, UG, e. K., e. V., OHG, KG, GbR, AG, PartG, Inh., Inhaber); sonst og:site_name der Startseite; sonst der erste Abschnitt des <title> (vor |, – oder -); höchstens 120 Zeichen.
        • Ansprechpartner: nach Geschäftsführer(in)?, Inhaber(in)? oder Vertreten durch (mit oder ohne :) zwei bis vier Wörter mit großem Anfangsbuchstaben, bis Zeilenende, Komma oder „ und “.
      8. MX: hat pickEmail eine Adresse geliefert, prüft mailDomainOk(deps.dns, <Domain der Adresse>); false ⇒ email und emailSource bleiben '', mailProblem 'mx'.
      9. impressumUrl = URL der Seite mit der Mail, sonst die der Impressum-Seite, sonst ''; websiteOrigin = Ursprung der Endadresse der Startseite; latestYear aus dem Text der Startseite mit dem Jahr aus deps.clock.now().
  • Step 4: Bestehen sehen. Derselbe Befehl ⇒ # fail 0.
  • Step 5: Commit. git add src/scouts/ownsite.js test/p6-ownsite.test.js && git commit -m "p6: eigene Website lesen ohne Sprachmodell (Impressum und Kontakt per HTTP und Regex)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P6-T09: Kandidat wird Interessent (ohne Places-Rohdaten, ohne Telefon bei Heilberufen)

Files:

  • Create: src/scouts/assemble.js
  • Test: test/p6-assemble.test.js

Interfaces:

  • Consumes: Ergebnisse aus T06 (place), T07 (osm), T08 (own); settings.retentionDays.

  • Produces: mapsUrl(placeId) → 'https://www.google.com/maps/place/?q=place_id:' + placeId; buildStub({ now: Date, run, place, settings }) → Prospect-Dokumentfelder; buildPatch({ now: Date, run, place, own, osm, settings }) → Teilfelder für update. run = { room, workerId, gewerk, ort, isHealth }.

  • Step 1: Test schreiben.

import test from 'node:test'
import assert from 'node:assert/strict'
import { buildStub, buildPatch } from '../src/scouts/assemble.js'

const jetzt = new Date('2026-10-20T03:30:00Z')
const run = { room: 'handwerk', workerId: 'hanna', gewerk: 'Elektriker', ort: 'Kaiserslautern', isHealth: false }
const place = { placeId: 'ChIJa', name: 'Original Google Name', websiteUri: 'https://alt.example/', rating: 4.7, userRatingCount: 987, businessStatus: 'OPERATIONAL' }
const own = { status: 'ok', websiteOrigin: 'https://beispiel-elektro.de', companyName: 'Elektro Beispiel GmbH', contactName: 'Max Beispiel', email: 'info@beispiel-elektro.de', emailSource: 'https://beispiel-elektro.de/impressum', phone: '0631 123456', phoneSource: 'https://beispiel-elektro.de/impressum', street: 'Hauptstraße 5', postalCode: '67655', city: 'Kaiserslautern', impressumUrl: 'https://beispiel-elektro.de/impressum' }
const osm = { osmUrl: 'https://www.openstreetmap.org/node/7', name: 'Elektro Müller', phone: '+49 631 111', website: 'https://mueller-elektro.de', street: 'Hauptstraße 9', postalCode: '67655', city: 'Kaiserslautern' }
const settings = { retentionDays: 90 }

test('R1: Stub und Patch enthalten keine Places-Rohdaten, nur placeId', () => {
  const stub = buildStub({ now: jetzt, run, place, settings })
  const alles = JSON.stringify([stub, buildPatch({ now: jetzt, run, place, own, osm: null, settings }), buildPatch({ now: jetzt, run, place, own: { status: 'keine_website' }, osm: null, settings })])
  for (const roh of ['Original Google Name', '4.7', '987', 'alt.example']) assert.ok(!alles.includes(roh), roh)
  assert.deepEqual([stub.placeId, stub.name, stub.sourceUrl, stub.city], ['ChIJa', 'nicht verfügbar', 'https://www.google.com/maps/place/?q=place_id:ChIJa', 'Kaiserslautern'])
  assert.deepEqual([stub.status, stub.entrance, stub.room, stub.foundBy, stub.source, stub.isHealth, stub.foundAt, stub.purgeAfter], ['gefunden', 'ohne_website', 'handwerk', 'hanna', 'places', false, '2026-10-20T03:30:00.000Z', '2027-01-18T03:30:00.000Z'])
})
test('Patch: Name aus dem Impressum vor OSM, Telefon nur mit Quelle, Website nur aus eigener Seite oder OSM', () => {
  const p = buildPatch({ now: jetzt, run, place, own, osm, settings })
  assert.deepEqual([p.name, p.contactName, p.email, p.emailSource, p.phone, p.phoneSource, p.website, p.street, p.postalCode, p.city, p.sourceUrl],
    ['Elektro Beispiel GmbH', 'Max Beispiel', 'info@beispiel-elektro.de', 'https://beispiel-elektro.de/impressum', '0631 123456', 'https://beispiel-elektro.de/impressum', 'https://beispiel-elektro.de', 'Hauptstraße 5', '67655', 'Kaiserslautern', 'https://beispiel-elektro.de/impressum'])
  const nurOsm = buildPatch({ now: jetzt, run, place, own: { status: 'keine_website' }, osm, settings })
  assert.deepEqual([nurOsm.name, nurOsm.phone, nurOsm.phoneSource, nurOsm.website, nurOsm.sourceUrl], ['Elektro Müller', '+49 631 111', 'https://www.openstreetmap.org/node/7', 'https://mueller-elektro.de', 'https://www.openstreetmap.org/node/7'])
  const nichts = buildPatch({ now: jetzt, run, place, own: { status: 'keine_website' }, osm: null, settings })
  assert.deepEqual([nichts.name, nichts.website, nichts.email, nichts.city, nichts.sourceUrl], ['nicht verfügbar', '', '', 'Kaiserslautern', 'https://www.google.com/maps/place/?q=place_id:ChIJa'])
})
test('R5: Heilberuf – keine Telefonnummer aus Impressum oder OSM', () => {
  const arzt = { ...run, room: 'aerzte', workerId: 'ali', isHealth: true }
  assert.deepEqual([buildPatch({ now: jetzt, run: arzt, place, own, osm, settings }).phone, buildPatch({ now: jetzt, run: arzt, place, own, osm, settings }).phoneSource], ['', ''])
  const physio = { ...run, room: 'selbst', workerId: 'sven', gewerk: 'Physiotherapie', isHealth: true }
  const q = buildPatch({ now: jetzt, run: physio, place, own: { status: 'keine_website' }, osm, settings })
  assert.deepEqual([q.phone, q.phoneSource], ['', ''])
})
  • Step 2: Fehlschlag sehen. timeout 300 node --max-old-space-size=1024 --test test/p6-assemble.test.js ⇒ FAIL, Modul fehlt.
  • Step 3: Implementieren. buildStub: { entrance: 'ohne_website', room, foundBy: workerId, source: 'places', sourceUrl: mapsUrl(placeId), foundAt: now.toISOString(), name: 'nicht verfügbar', placeId, city: run.ort, isHealth: run.isHealth, status: 'gefunden', purgeAfter: new Date(now.getTime() + retentionDays * 86400000).toISOString(), updatedAt: now.toISOString() }. buildPatch: Vorrang je Feld own vor osm vor Standard (name ⇒ 'nicht verfügbar', Rest '', city ⇒ run.ort); website = own.websiteOrigin wenn own.status === 'ok', sonst osm.website oder ''; sourceUrl = own.impressumUrl oder osm.osmUrl oder mapsUrl(placeId); phone und phoneSource (own.phoneSource bzw. osm.osmUrl) nur wenn !run.isHealth; E-Mail und Quelle nur aus own; kein Feld liest place.name, place.rating, place.userRatingCount oder place.websiteUri; updatedAt setzen.
  • Step 4: Bestehen sehen. Derselbe Befehl ⇒ # fail 0.
  • Step 5: Commit. git add src/scouts/assemble.js test/p6-assemble.test.js && git commit -m "p6: Interessent ohne Places-Rohdaten, Heilberufe ohne Telefon" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P6-T10: Bewertung (Punkteformel, Ausschlussgründe, Beas Urteil)

Files:

  • Create: src/scoring/business.js, src/scoring/bea.js
  • Test: test/p6-scoring.test.js

Interfaces:

  • Consumes: deps.claude.ask(deps, { workerId:'bea', system, input, schema, maxTokens }) (A1), buildSystem(workerId, aufgabe) (src/prompts.js, P1; die Persona liegt in der von P3 angelegten, aufgabenneutralen prompts/bea.md), assertCap('bea','score').

  • Produces (business.js, rein): MIN_SCORE_MAIL = 55, MAX_AUDIT_FOR_MAIL = 5, GRUND (feste Grundtexte, siehe Step 3), reviewPoints(count) → 0..20, ratingPoints(rating|null) → 0..10, gapPoints({ websiteState, auditScore }) → 0..35, scoreBusiness(f) → { score, parts, reason }, ausschlussGrund(f) → string | null, rankCandidates(list) → list. f = { websiteState: 'keine'|'vorhanden', auditScore: number|null, reviewCount, rating, sympathie, kette, hasMail, mailProblem: 'mx'|null }.

  • Produces (bea.js): BEA_SCHEMA, AUFGABE_BETRIEB (Aufgabentext im Code), beaSystem() (= buildSystem('bea', AUFGABE_BETRIEB), H5; als Funktion statt Konstante BEA_SYSTEM, damit Änderungen an prompts/bea.md ohne Neustart wirken), ersatzUrteil({ latestYear, nowYear }) → { auditScore, sympathie, kette, belege }, rateCandidates(deps, { items, nowYear }) → Promise<{ urteile: Map<id, { auditScore, sympathie, kette, belege }>, ersatz: boolean }> mit items = [{ id, gewerk, ort, websiteState, siteText, latestYear }].

  • Step 1: Test schreiben.

import test from 'node:test'
import assert from 'node:assert/strict'
import { MIN_SCORE_MAIL, reviewPoints, ratingPoints, gapPoints, scoreBusiness, ausschlussGrund, rankCandidates } from '../src/scoring/business.js'
import { ersatzUrteil, rateCandidates, beaSystem, AUFGABE_BETRIEB } from '../src/scoring/bea.js'
import { buildSystem } from '../src/prompts.js'
import { makeDeps, fakeClaude } from './support/p6.js'

const f = (o = {}) => ({ websiteState: 'keine', auditScore: null, reviewCount: 120, rating: 4.8, sympathie: 8, kette: false, hasMail: true, mailProblem: null, ...o })

test('Punkte: Beispielrechnungen mit Begründung ohne Rohwerte', () => {
  const a = scoreBusiness(f())
  assert.deepEqual(a.parts, { gap: 35, reviews: 20, rating: 10, sympathy: 8, noChain: 10, mail: 15 })
  assert.equal(a.score, 98)
  assert.equal(a.reason, 'Lücke 35, Bewertungen 20, Note 10, Sympathie 8, keine Kette 10, Mail 15; Summe 98')
  assert.equal(scoreBusiness(f({ websiteState: 'vorhanden', auditScore: 4, reviewCount: 30, rating: 4.5, sympathie: 6 })).score, 70)
})
test('Stufen an den Grenzen', () => {
  assert.deepEqual([100, 99, 50, 49, 25, 24, 15, 14, 5, 4].map(reviewPoints), [20, 16, 16, 12, 12, 8, 8, 4, 4, 0])
  assert.deepEqual([4.8, 4.79, 4.6, 4.3, 4.0, 3.99, null].map(ratingPoints), [10, 8, 8, 6, 3, 0, 0])
  assert.deepEqual([['keine', null], ['vorhanden', 0], ['vorhanden', 5], ['vorhanden', 10]].map(([websiteState, auditScore]) => gapPoints({ websiteState, auditScore })), [35, 35, 18, 0])
})
test('Ausschlussgründe in fester Reihenfolge', () => {
  assert.equal(ausschlussGrund(f({ hasMail: false, kette: true })), 'keine veröffentlichte Mail')
  assert.equal(ausschlussGrund(f({ hasMail: false, mailProblem: 'mx' })), 'Adresse nicht zustellbar')
  assert.equal(ausschlussGrund(f({ kette: true })), 'Kette oder Franchise')
  assert.equal(ausschlussGrund(f({ websiteState: 'vorhanden', auditScore: 6 })), 'Website in gutem Zustand')
  assert.equal(ausschlussGrund(f({ websiteState: 'vorhanden', auditScore: 5 })), null)
  assert.equal(MIN_SCORE_MAIL, 55)
})
test('rankCandidates: Punkte absteigend, bei Gleichstand früherer Fund, dann id', () => {
  const l = [{ id: 'b', score: 70, foundAt: '2026-10-20T01:00:00Z' }, { id: 'a', score: 70, foundAt: '2026-10-20T01:00:00Z' }, { id: 'c', score: 90, foundAt: '2026-10-20T02:00:00Z' }, { id: 'd', score: 70, foundAt: '2026-10-19T23:00:00Z' }]
  assert.deepEqual(rankCandidates(l).map((x) => x.id), ['c', 'd', 'a', 'b'])
})

const item = (id, o = {}) => ({ id, gewerk: 'Elektriker', ort: 'Mainz', websiteState: 'vorhanden', siteText: 'x', latestYear: 2016, ...o })
test('rateCandidates: Werte begrenzt, fehlende Urteile ersetzt, "keine Website" erzwingt Audit null', async () => {
  const deps = makeDeps()
  deps.claude = fakeClaude({ ask: async () => ({ data: { urteile: [{ id: 'a', auditScore: 14, sympathie: -3, kette: true, belege: ['Copyright 2016'] }, { id: 'b', auditScore: 3, sympathie: 7, kette: false, belege: [] }] }, usage: {}, costUsd: 0.01 }) })
  const { urteile, ersatz } = await rateCandidates(deps, { items: [item('a'), item('b', { websiteState: 'keine', siteText: '', latestYear: null }), item('c', { latestYear: 2025 })], nowYear: 2026 })
  assert.deepEqual(urteile.get('a'), { auditScore: 10, sympathie: 0, kette: true, belege: ['Copyright 2016'] })
  assert.deepEqual(urteile.get('b'), { auditScore: null, sympathie: 7, kette: false, belege: [] })
  assert.deepEqual(urteile.get('c'), { auditScore: 7, sympathie: 5, kette: false, belege: ['ohne KI-Urteil'] })
  assert.equal(ersatz, false)
  assert.equal(deps.claude.calls.ask[0].workerId, 'bea')
  assert.equal(deps.claude.calls.ask[0].system, beaSystem())
  assert.equal(beaSystem(), buildSystem('bea', AUFGABE_BETRIEB), 'H5: Persona aus prompts/bea.md plus Aufgabe im Code')
  assert.ok(AUFGABE_BETRIEB.includes('0 = sehr schlecht'))
})
test('rateCandidates: scheitert der Aufruf, kommt ein Ersatzurteil statt Verlust', async () => {
  const deps = makeDeps()
  deps.claude = fakeClaude({ ask: async () => { throw new Error('API down') } })
  const { urteile, ersatz } = await rateCandidates(deps, { items: [item('a')], nowYear: 2026 })
  assert.deepEqual([ersatz, urteile.get('a').auditScore], [true, 3])
  assert.deepEqual([2016, 2022, 2025, null].map((latestYear) => ersatzUrteil({ latestYear, nowYear: 2026 }).auditScore), [3, 5, 7, 5])
})
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/p6-scoring.test.js ⇒ FAIL, Modul fehlt.
  • Step 3: Implementieren.
    • Stufen: reviewPoints ≥100:20, ≥50:16, ≥25:12, ≥15:8, ≥5:4, sonst 0; ratingPoints ≥4.8:10, ≥4.6:8, ≥4.3:6, ≥4.0:3, sonst 0 (null ⇒ 0); gapPoints: keine ⇒ 35, sonst Math.round(35 - 3.5 * auditScore); scoreBusiness: noChain 10 wenn !kette, mail 15 wenn hasMail, sympathy = sympathie; reason genau im Format des Tests.
    • GRUND (exakt): KEINE_MAIL 'keine veröffentlichte Mail', MX 'Adresse nicht zustellbar', KETTE 'Kette oder Franchise', WEBSITE_GUT 'Website in gutem Zustand', SCHWELLE 'Punktzahl unter 55', VERALTET 'veraltet (nicht ausgewählt)', BEKANNT 'schon bekannt (E-Mail oder Telefon)', GESPERRT 'Sperrliste', LESEN 'Lesen fehlgeschlagen', ABGEBROCHEN 'Lauf unterbrochen'.
    • ausschlussGrund prüft in der Reihenfolge mailProblem ⇒ MX, !hasMail ⇒ KEINE_MAIL, kette ⇒ KETTE, (websiteState === 'vorhanden' und auditScore > MAX_AUDIT_FOR_MAIL) ⇒ WEBSITE_GUT, sonst null.
    • rankCandidates: Kopie, sortiert nach score absteigend, foundAt aufsteigend, id aufsteigend.
    • ersatzUrteil: latestYear ≤ nowYear − 5 ⇒ auditScore 3; ≤ nowYear − 3 ⇒ 5; null ⇒ 5; sonst 7; sympathie 5, kette false, belege ['ohne KI-Urteil'].
    • rateCandidates: assertCap('bea','score'); deps.claude.ask(deps, { workerId: 'bea', system: beaSystem(), input: JSON.stringify({ kandidaten: items }), schema: BEA_SCHEMA, maxTokens: 2500 }). BEA_SCHEMA = Objekt mit urteile (Liste aus Objekten mit id string, auditScore integer, sympathie integer, kette boolean, belege Liste von Strings), überall additionalProperties:false. AUFGABE_BETRIEB (Aufgabentext im Code; die Persona steht in prompts/bea.md, beaSystem() hängt ihn per buildSystem an) verlangt: Website nur nach dem gelieferten Text beurteilen (0 = sehr schlecht, 10 = sehr gut; technisch 4, Inhalt 4, Pflege 2 Punkte), Sympathie 0–10 aus dem Ton des Textes (Bewertungstexte werden nicht abgerufen), kette true bei Filial- oder Franchise-Auftritt, höchstens 3 kurze Belege, nichts erfinden. Auswertung: Zahlen auf 0…10 begrenzen und runden, websiteState 'keine' ⇒ auditScore null, belege höchstens 3 Strings zu je 80 Zeichen, fehlende Ids ⇒ ersatzUrteil; wirft der Aufruf (auch BudgetStop), für alle Ersatzurteile und ersatz: true.
  • Step 4: Bestehen sehen. Derselbe Befehl ⇒ # fail 0.
  • Step 5: Commit. git add src/scoring/business.js src/scoring/bea.js test/p6-scoring.test.js && git commit -m "p6: Punkteformel, Ausschlussgründe, Beas Urteil" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P6-T11: Arne legt Bot-Leads ab

Files:

  • Create: src/archive/botleads.js
  • Test: test/p6-botleads-ablage.test.js

Interfaces:

  • Consumes: emit, assertCap('arne','archive'), SCOUT_ROOMS (Raumbeschriftung).

  • Produces: fileBotLead(deps, { prospectId, reason, dropContact = true }) → Promise<void>. Setzt status 'botlead', botLeadReason (höchstens 500 Zeichen), leert bei dropContact email, emailSource, contactName (Datensparsamkeit: Bot-Leads werden nie angeschrieben), behält Telefon samt Quelle, Punkte und Register-Schlüssel; Ereignis botlead (Arne).

  • Step 1: Test schreiben.

import test from 'node:test'
import assert from 'node:assert/strict'
import { fileBotLead } from '../src/archive/botleads.js'
import { makeDeps, seedProspect } from './support/p6.js'

test('fileBotLead: Status, Grund, Kontaktdaten weg, Ereignis ohne Namen oder Adressen', async () => {
  const deps = makeDeps()
  const p = await seedProspect(deps.db, { phone: '0631 111', phoneSource: 'https://beispiel-elektro.de/impressum', contactName: 'Max Beispiel' })
  const grund = 'Platz 4 von 6; freie Mail-Plätze: 3'
  await fileBotLead(deps, { prospectId: p.$id, reason: grund })
  const d = await deps.db.get('workersProspects', p.$id)
  assert.deepEqual([d.status, d.botLeadReason, d.email, d.emailSource, d.contactName, d.phone, d.score], ['botlead', grund, '', '', '', '0631 111', 80])
  const ev = deps.db.docs('workersEvents').find((e) => e.kind === 'botlead')
  assert.deepEqual([ev.workerId, ev.text, ev.prospectId, ev.link], ['arne', `Bot-Lead (Handwerk, Kaiserslautern): ${grund}`, p.$id, `/hustler?tab=botleads&bot=${p.$id}`])
  assert.ok(!/Beispiel|@/.test(ev.text))
  await fileBotLead(deps, { prospectId: p.$id, reason: grund })
  assert.equal(deps.db.docs('workersEvents').filter((e) => e.kind === 'botlead').length, 1, 'zweiter Aufruf ändert nichts')
})
test('Grund wird auf 500 Zeichen gekürzt; weitergeschrittene oder für Mail vorgesehene Interessenten werden nie Bot-Lead', async () => {
  const deps = makeDeps()
  const a = await seedProspect(deps.db, {})
  await fileBotLead(deps, { prospectId: a.$id, reason: 'x'.repeat(900) })
  assert.equal((await deps.db.get('workersProspects', a.$id)).botLeadReason.length, 500)
  const b = await seedProspect(deps.db, { status: 'kontaktiert' })
  const c = await seedProspect(deps.db, { threadId: 't1' })
  await assert.rejects(() => fileBotLead(deps, { prospectId: b.$id, reason: 'x' }), /Bot-Lead/)
  await assert.rejects(() => fileBotLead(deps, { prospectId: c.$id, reason: 'x' }), /Bot-Lead/)
})
  • Step 2: Fehlschlag sehen. timeout 300 node --max-old-space-size=1024 --test test/p6-botleads-ablage.test.js ⇒ FAIL, Modul fehlt.
  • Step 3: Implementieren. Interessenten lesen (db.get liefert null ⇒ Error('Interessent fehlt')); ist status schon botlead ⇒ zurück ohne Schreiben; ist status nicht gefunden oder bewertet oder threadId gesetzt ⇒ Error('Bot-Lead nur für Interessenten ohne Kontakt'); update mit status, botLeadReason, bei dropContact die drei Felder auf '', updatedAt; Ereignis emit(deps, { workerId: 'arne', kind: 'botlead', prospectId, link: '/hustler?tab=botleads&bot=<id>', text: 'Bot-Lead (<Raumbeschriftung>, <Ort oder „Ort unbekannt“>): <Grund, höchstens 200 Zeichen>' }).
  • Step 4: Bestehen sehen. Derselbe Befehl ⇒ # fail 0.
  • Step 5: Commit. git add src/archive/botleads.js test/p6-botleads-ablage.test.js && git commit -m "p6: Arne legt Bot-Leads ab, ohne Kontaktdaten" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P6-T12: Suchlauf eines Scouts

Files:

  • Create: src/scouts/run.js
  • Test: test/p6-run.test.js

Interfaces:

  • Consumes: claimKeys, keysFor, isSuppressed, emit, checkBudget, book, BudgetStop, getSettings, nowOf (P4 src/gespraeche/util.js), mailSlots (T04), pickQuery (T05), PLACES_REQUEST_USD, buildStub, buildPatch, readOwnSite (liest über deps.fetch und deps.dns, T08), scoreBusiness, ausschlussGrund, GRUND, rateCandidates, fileBotLead, keyId (src/register/keys.js), ConflictError (src/db.js), deps.places, deps.osm.

  • Produces: runScoutJob(deps, job) → Promise<{ fresh, doppelt, botleads, bewertet, gestoppt: null|'budget'|'zeit'|'plaetze_voll'|'keine_abfrage' }>; job ist ein P1-Auftrag (id, workerId, payload = { night, run, room, region, candidates, entrance } von P1s planNight; region ist das Bundesland aus settings.regions). runScoutJob beendet den Auftrag nicht selbst: drainScouts (T24) ruft danach finish bzw. fail. Ein Fehler beim Abruf einer Places-Seite wirft; Fehler bei einem einzelnen Kandidaten werden im Lauf aufgefangen (Schritt 7).

  • Step 1: Test schreiben. Domain-Schlüssel gelten je registrierbarer Domain; die Testbetriebe haben deshalb je eine eigene Domain chija-elektro.de, chijd-elektro.de usw.

import test from 'node:test'
import assert from 'node:assert/strict'
import { runScoutJob } from '../src/scouts/run.js'
import { claimKeys, keysFor, keyId } from '../src/register/keys.js'
import { CapError } from '../src/roles.js'
import { makeDeps, fakeClaude, siteFetch, betriebSeiten, seedJob, takeJob } from './support/p6.js'

const IDS = ['ChIJA', 'ChIJB', 'ChIJC', 'ChIJD', 'ChIJE', 'ChIJH']
const hostVon = (id) => `${id.toLowerCase()}-elektro.de`
const pidVon = (placeId) => 'p' + keyId('pid', placeId).slice(0, 30) // A7: die ID ist eine Funktion der place_id
const platz = (id, over = {}) => ({ placeId: id, name: `Original-Name ${id}`, websiteUri: `https://${hostVon(id)}`, rating: 4.7, userRatingCount: 80, businessStatus: 'OPERATIONAL', ...over })
const seitenAbrufe = (deps) => deps.fetch.calls.map((c) => c.url).filter((u) => !u.endsWith('/robots.txt'))
const jobIn = (deps, over = {}, kopf = {}) => takeJob(deps, { kind: 'suche', workerId: 'hanna', payload: { night: '2026-10-19', run: 1, room: 'handwerk', region: 'Rheinland-Pfalz', candidates: 10, entrance: 'ohne_website', ...over }, ...kopf })

function szenario({ places } = {}) {
  const deps = makeDeps({ settings: { connectedRooms: ['handwerk'] } })
  deps.places = { textSearch: async () => ({ places: places ?? [platz('ChIJA'), platz('ChIJB'), platz('ChIJC', { businessStatus: 'CLOSED_PERMANENTLY' })], nextPageToken: null }) }
  deps.fetch = siteFetch(Object.assign({}, ...IDS.map((id) => betriebSeiten(hostVon(id))))) // Startseite und Impressum je Betrieb; das Lesen braucht kein Sprachmodell (H2)
  deps.claude = fakeClaude({
    ask: async (_d, o) => { // nur Bea ruft Claude auf
      assert.equal(o.workerId, 'bea')
      return { data: { urteile: JSON.parse(o.input).kandidaten.map((k) => ({ id: k.id, auditScore: 3, sympathie: 8, kette: false, belege: ['Copyright 2016'] })) }, usage: {}, costUsd: 0.01 }
    },
  })
  return deps
}
const nach = (deps, id) => deps.db.docs('workersProspects').find((p) => p.placeId === id)

test('Register zuerst, nur der frische Kandidat kostet; Bewertung 82; R1: keine Places-Rohdaten gespeichert', async () => {
  const deps = szenario()
  await claimKeys(deps, keysFor({ placeId: 'ChIJB' }), { prospectId: '', origin: 'seed_kunde', blocked: true })
  const r = await runScoutJob(deps, await jobIn(deps))
  assert.deepEqual([r.fresh, r.doppelt, r.gestoppt], [1, 1, null])
  assert.equal(deps.db.docs('workersProspects').length, 1)
  const a = nach(deps, 'ChIJA')
  assert.deepEqual([a.status, a.score, a.email, a.name, a.isHealth, a.$id], ['bewertet', 82, 'info@chija-elektro.de', 'Elektro Beispiel GmbH', false, pidVon('ChIJA')])
  assert.deepEqual(seitenAbrufe(deps), ['https://chija-elektro.de/', 'https://chija-elektro.de/impressum'], 'nur der frische Kandidat wird gelesen')
  const r2 = await runScoutJob(deps, await jobIn(deps))
  assert.deepEqual([r2.fresh, r2.doppelt], [0, 3])
  assert.equal(seitenAbrufe(deps).length, 2, 'zweiter Lauf kostet nichts: keine zusätzlichen Abrufe')
  const alles = JSON.stringify(['workersProspects', 'workersEvents', 'workersJobs', 'workersSearchLog'].map((c) => deps.db.docs(c)))
  assert.ok(!alles.includes('Original-Name') && !alles.includes('4.7'))
  const log = deps.db.docs('workersSearchLog')[0]
  assert.deepEqual([log.workerId, log.room, log.region, log.query, log.found, log.fresh], ['hanna', 'handwerk', 'Rheinland-Pfalz', 'Elektriker in Kaiserslautern', 3, 1])
  const ev = deps.db.docs('workersEvents')
  for (const k of ['fund', 'doppelt', 'uebergabe', 'bewertet']) assert.ok(ev.some((e) => e.kind === k), k)
  assert.ok(ev.every((e) => !/Beispiel|@chija|Original/.test(e.text)))
})
test('Budget aus ⇒ kein Places-Aufruf', async () => {
  const deps = szenario()
  await deps.db.create('workersDaily', { costUsd: 6, mailsCold: 0, mailsReply: 0, searches: 0, found: 0, contacted: 0, deals: 0, countersJson: '{}', warned: false, stopped: false }, '2026-10-20')   // Tagesgrenze 6 $ erreicht
  let n = 0
  deps.places = { textSearch: async () => { n++; return { places: [], nextPageToken: null } } }
  const r = await runScoutJob(deps, await jobIn(deps))
  assert.deepEqual([r.gestoppt, n], ['budget', 0])
})
test('Fehler bei einem Kandidaten ⇒ Bot-Lead "Lesen fehlgeschlagen", der Lauf geht weiter', async () => {
  let n = 0
  const deps = szenario({ places: [platz('ChIJA', { websiteUri: '' }), platz('ChIJD')] })
  deps.osm = { lookup: async () => { n++; throw new Error('boom') } } // ChIJA hat keine Website ⇒ OSM wird gefragt und scheitert
  const r = await runScoutJob(deps, await jobIn(deps))
  assert.equal(r.fresh, 2)
  assert.deepEqual([nach(deps, 'ChIJA').status, nach(deps, 'ChIJA').botLeadReason, nach(deps, 'ChIJD').status], ['botlead', 'Lesen fehlgeschlagen', 'bewertet'])
  assert.equal(n, 1, 'ChIJD hat Telefon und Straße im Impressum: kein OSM-Aufruf')
})
test('Zeitgrenze 20 Minuten: unverarbeitete Kandidaten bleiben ungeclaimt und kommen im nächsten Lauf dran', async () => {
  const deps = szenario({ places: [platz('ChIJA'), platz('ChIJD'), platz('ChIJE')] })
  const echt = deps.fetch
  deps.fetch = Object.assign(async (u, i) => { deps.clock.advance(21 * 60 * 1000); return echt(u, i) }, { calls: echt.calls })
  const r = await runScoutJob(deps, await jobIn(deps))
  assert.deepEqual([r.fresh, r.gestoppt], [1, 'zeit'])
  deps.fetch = echt
  assert.equal((await runScoutJob(deps, await jobIn(deps))).fresh, 2)
})
test('R5: Heilberuf – keine Nummer gespeichert (weder aus dem Impressum noch aus OSM) und kein Telefon-Schlüssel', async () => {
  const deps = szenario({ places: [platz('ChIJH'), platz('ChIJE')] })
  deps.fetch = siteFetch({ ...betriebSeiten(hostVon('ChIJH')), ...betriebSeiten(hostVon('ChIJE'), { phone: false }) }) // H: Nummer im Impressum, E: keine ⇒ OSM wird gefragt
  let n = 0
  deps.osm = { lookup: async () => { n++; return { osmUrl: 'https://www.openstreetmap.org/node/7', name: 'Praxis', phone: '+49 631 111', website: '', street: '', postalCode: '', city: '' } } }
  await runScoutJob(deps, await jobIn(deps, { room: 'aerzte' }, { workerId: 'ali' })) // Abfrage „Hausarzt in Kaiserslautern“ ⇒ Heilberuf
  for (const id of ['ChIJH', 'ChIJE']) {
    const h = nach(deps, id)
    assert.deepEqual([h.isHealth, h.phone, h.phoneSource, h.room], [true, '', '', 'aerzte'], id)
  }
  assert.equal(n, 1)
  assert.ok(deps.db.docs('workersKeys').every((k) => k.kind !== 'tel'))
})
test('Mail-Plätze voll ⇒ Leerlauf-Ereignis und kein Places-Aufruf', async () => {
  const deps = szenario()
  for (let i = 0; i < 3; i++) await seedJob(deps.db, { kind: 'erstmail', status: 'offen' })
  let n = 0
  deps.places = { textSearch: async () => { n++; return { places: [], nextPageToken: null } } }
  const r = await runScoutJob(deps, await jobIn(deps))
  assert.deepEqual([r.gestoppt, n], ['plaetze_voll', 0])
  assert.ok(deps.db.docs('workersEvents').some((e) => e.kind === 'leerlauf' && e.workerId === 'hanna'))
})
test('Rollentrennung: nur Scouts mit der Fähigkeit places suchen, und nur in ihrem Raum', async () => {
  const deps = szenario()
  await assert.rejects(async () => runScoutJob(deps, await jobIn(deps, {}, { workerId: 'rita' })), CapError)
  await assert.rejects(async () => runScoutJob(deps, await jobIn(deps, { room: 'aerzte' }, { workerId: 'hanna' })), /Scout/)
})
test('A7: die Interessenten-ID ist eine Funktion der place_id – ein Absturz verliert den Fund nicht', async () => {
  // (a) abgestürzt nach dem Anlegen der Schlüssel, vor dem Dokument: derselbe Fund gilt nicht als „schon bekannt“
  const a = szenario({ places: [platz('ChIJA')] })
  await claimKeys(a, keysFor({ placeId: 'ChIJA', website: `https://${hostVon('ChIJA')}` }), { prospectId: pidVon('ChIJA'), origin: 'workers' })
  const ra = await runScoutJob(a, await jobIn(a))
  assert.deepEqual([ra.fresh, ra.doppelt, nach(a, 'ChIJA').$id, nach(a, 'ChIJA').status], [1, 0, pidVon('ChIJA'), 'bewertet'])
  // (b) abgestürzt nach dem Dokument, vor der Bewertung (das Ereignis „fund“ scheitert einmal): die Wiederholung setzt dasselbe Dokument fort
  const b = szenario({ places: [platz('ChIJD')] })
  const create = b.db.create
  let gefallen = false
  b.db.create = async (col, ...rest) => { if (col === 'workersEvents' && !gefallen) { gefallen = true; throw new Error('Absturz') } return create(col, ...rest) }
  const job = await jobIn(b)
  await assert.rejects(() => runScoutJob(b, job), /Absturz/)
  assert.equal(nach(b, 'ChIJD').status, 'gefunden')
  const rb = await runScoutJob(b, job)
  assert.deepEqual([rb.fresh, rb.doppelt, b.db.docs('workersProspects').length, nach(b, 'ChIJD').$id, nach(b, 'ChIJD').status], [1, 0, 1, pidVon('ChIJD'), 'bewertet'])
})
test('R2: Mail-Domain ohne MX ⇒ keine Mail gespeichert, Bot-Lead "Adresse nicht zustellbar"', async () => {
  const deps = szenario({ places: [platz('ChIJA')] })
  deps.dns.resolveMx = async () => { throw Object.assign(new Error('x'), { code: 'ENODATA' }) }
  await runScoutJob(deps, await jobIn(deps))
  const a = nach(deps, 'ChIJA')
  assert.deepEqual([a.status, a.botLeadReason, a.email, a.emailSource], ['botlead', 'Adresse nicht zustellbar', '', ''])
})
  • Step 2: Fehlschlag sehen. timeout 300 node --max-old-space-size=1024 --test test/p6-run.test.js ⇒ FAIL, Modul fehlt.
  • Step 3: Implementieren (Ablauf, in dieser Reihenfolge).
    1. assertCap(job.workerId, 'places') (wirft CapError für Rollen ohne diese Fähigkeit); payload = job.payload; SCOUT_ROOMS[payload.room]?.workerId muss job.workerId sein, sonst Error('Raum passt nicht zum Scout'); now = nowOf(deps), start = deps.clock.now() (Millisekunden). Dann der Reihe nach: mailSlots(deps, { now }).free <= 0 ⇒ Ereignis leerlauf (Scout, Text „: Alle Mail-Plätze sind vergeben, kein Suchlauf.“, = Rollenname) und gestoppt 'plaetze_voll'; checkBudget(deps, 0.2) nicht ok ⇒ gestoppt 'budget'; pickQuery(deps, { room: payload.room, region: payload.region, now }) ist null ⇒ Ereignis leerlauf („… hat alle Abfragen der letzten 90 Tage gesucht.“) und gestoppt 'keine_abfrage'; sonst liefert es gewerk, ort, query, isHealth für den ganzen Lauf.
    2. Seiten holen (höchstens 3, solange weniger als payload.candidates frische): vor jeder Seite checkBudget, dann deps.places.textSearch({ query, pageToken }) und book(deps, { workerId, usd: PLACES_REQUEST_USD, counters: { places: 1 } }).
    3. Je Treffer der Reihe nach, erst jetzt: Zeitgrenze (deps.clock.now() − start > 20 · 60 000 ⇒ gestoppt 'zeit', Schleife verlassen), checkBudget; dann id = 'p' + keyId('pid', placeId).slice(0, 30) (A7, Übersicht §4.14: die ID ist nie zufällig, damit eine Wiederholung nach einem Absturz dieselbe ID bekommt und der eigene Fund nicht als „schon bekannt“ gilt) und claimKeys(deps, keysFor({ placeId, website: websiteUri }), { prospectId: id, origin: 'workers' }); bei einem Betrieb, dessen businessStatus gesetzt und ≠ OPERATIONAL ist, mit prospectId: '' (es entsteht kein Dokument, ein späterer Fund gilt als „schon bekannt“). fresh === false ⇒ doppelt++, Ereignis doppelt (Arne, Text „Schon bekannt: Kandidat in “), weiter. Nicht OPERATIONAL ⇒ weiter (Schlüssel bleiben, kein Dokument). Sonst db.create('workersProspects', buildStub(...), id); wirft das einen ConflictError (das Dokument besteht, Wiederholung), wird es gelesen: Status gefunden ⇒ die Verarbeitung wird fortgesetzt (abgestürzter Lauf), jeder andere Status ⇒ wie doppelt überspringen (Zähler und Ereignis). fresh zählt die Kandidaten, die dieser Lauf verarbeitet (Dokument neu angelegt oder fortgesetzt), jeweils mit Ereignis fund (Scout, Text „: neuer Kandidat in ()“).
    4. try: readOwnSite(deps, { workerId, website: websiteUri, ort }) (T08: liest Startseite, Impressum und Kontaktseite per deps.fetch ohne Modell, liefert bei unbenutzbarer Website keine_website ohne Abruf). OSM-Aufruf (nach assertCap(workerId, 'osm')) deps.osm.lookup({ name: place.name, ort }) (Name nur im Speicher), wenn own.status !== 'ok' oder Telefon oder Straße fehlen. Hat own keine Website und osm.website ist benutzbar ⇒ readOwnSite(deps, { workerId, website: osm.website, ort }) statt own.
    5. Mail vorhanden (own.email; eine Adresse ohne MX hat T08 schon verworfen und own.mailProblem 'mx' gesetzt, der Grund „Adresse nicht zustellbar“ entsteht in Schritt 8 über ausschlussGrund): isSuppressed ⇒ fileBotLead(GRUND.GESPERRT); claimKeys(deps, keysFor({ email, phone (nur wenn nicht isHealth), name, postalCode }), …) mit fresh === false ⇒ fileBotLead(GRUND.BEKANNT) und Ereignis doppelt.
    6. db.update mit buildPatch(...); Merkmale für Beas Urteil im Speicher sammeln (websiteState 'vorhanden', wenn Places oder OSM eine benutzbare Website nannten; siteText = erste 3 000 Zeichen der Startseite (own.pages[0].text); latestYear; reviewCount, rating, hasMail, mailProblem = own.mailProblem).
    7. catch: BudgetStop ⇒ fileBotLead(GRUND.ABGEBROCHEN), gestoppt 'budget', Schleife verlassen; sonst fileBotLead(GRUND.LESEN), Ereignis fehler (Text ohne Namen), weiter.
    8. Nach den Seiten (schon abgelegte Kandidaten aus Schritt 5 und 7 sind nicht mehr dabei): Protokollzeile in workersSearchLog (found = geprüfte Treffer, fresh = frische, region = payload.region (das Bundesland, damit P1s Raumwahl das Protokoll liest), query = gewählte Abfrage, at jetzt); Ereignis uebergabe (Scout → to 'bea'); rateCandidates (ohne Kandidaten kein Aufruf); je Kandidat scoreBusiness, update mit score und scoreReason, dann ausschlussGrund ⇒ fileBotLead(grund) sonst status 'bewertet'; ein Ereignis bewertet (Bea, Text „ Kandidaten bewertet, bester Wert “, meta.score = bester Wert, P2-E6); book(deps, { workerId, usd: 0, counters: { gefunden: fresh, doppelt } }); Rückgabe der Zahlen (kein finish).
  • Step 4: Bestehen sehen. Derselbe Befehl ⇒ # fail 0.
  • Step 5: Commit. git add src/scouts/run.js test/p6-run.test.js && git commit -m "p6: Suchlauf eines Scouts mit Register, Places, Impressum und Bewertung" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P6-T13: Top 3 über alle Räume der Nacht

Files:

  • Create: src/scoring/night.js
  • Test: test/p6-night.test.js

Interfaces:

  • Consumes: mailSlots (T04), rankCandidates, MIN_SCORE_MAIL, GRUND, fileBotLead, enqueue, emit, ensureThread(deps, { prospectId, toEmail, subject, channel? }) (P4 src/gespraeche/threads.js: legt den Faden an und setzt prospect.threadId).

  • Produces: selectNightTop3(deps, { now: Date }) → Promise<{ slots, selected: string[], botleads: string[] }>. Auswahl: Interessenten mit entrance 'ohne_website', status 'bewertet', ohne threadId, foundAt der letzten 72 Stunden; je gewähltem Betrieb ein Faden (über ensureThread, toEmail = gespeicherte Adresse), Auftrag erstmail (Max, threadId), Ereignis top3 (Bea → Max). Zusätzlich werden Nachzügler abgelegt: Interessenten status 'gefunden' mit foundAt älter als 12 Stunden (der Lauf ist abgestürzt, sie wurden nie bewertet) ⇒ Bot-Lead mit Grund GRUND.ABGEBROCHEN.

  • Step 1: Test schreiben.

import test from 'node:test'
import assert from 'node:assert/strict'
import { selectNightTop3 } from '../src/scoring/night.js'
import { makeDeps, at, seedProspect, seedThread } from './support/p6.js'

const JETZT = at('2026-10-20T03:30:00Z')
async function szenario(settings = {}) {
  const deps = makeDeps({ settings: { privacyInteressentenLive: true, ...settings } })
  const mk = (score, over = {}) => seedProspect(deps.db, { score, name: `Betrieb ${score}`, status: 'bewertet', foundAt: '2026-10-19T20:00:00Z', ...over })
  const p = {}
  for (const s of [90, 80, 70, 60, 54]) p[s] = await mk(s)
  p.zugewiesen = await mk(99, { threadId: 't-alt' })
  p.anfrage = await mk(95, { entrance: 'anfrage' })
  p.alt = await mk(85, { foundAt: '2026-10-16T20:00:00Z' })
  p.abgestuerzt = await mk(0, { status: 'gefunden', foundAt: '2026-10-19T10:00:00Z' })  // Lauf abgestürzt, nie bewertet
  p.frisch = await mk(0, { status: 'gefunden', foundAt: '2026-10-20T00:00:00Z' })       // Lauf vielleicht noch aktiv
  await seedThread(deps.db, { followupDueAt: '2026-10-20T05:00:00Z' })
  return { deps, p }
}
const grund = async (deps, p) => (await deps.db.get('workersProspects', p.$id)).botLeadReason

test('Top 3 über alle Räume: freie Plätze = Limit minus fällige Nachfass; Rest mit Grund in die Liste', async () => {
  const { deps, p } = await szenario()
  const r = await selectNightTop3(deps, { now: JETZT })
  assert.equal(r.slots.free, 2)
  assert.deepEqual([...r.selected].sort(), [p[90].$id, p[80].$id].sort())
  const jobs = deps.db.docs('workersJobs').filter((j) => j.kind === 'erstmail')
  assert.deepEqual(jobs.map((j) => j.prospectId).sort(), [p[90].$id, p[80].$id].sort())
  assert.ok(jobs.every((j) => j.workerId === 'max' && j.threadId))
  assert.equal((await deps.db.get('workersProspects', p[90].$id)).threadId, jobs.find((j) => j.prospectId === p[90].$id).threadId)
  assert.equal(await grund(deps, p[70]), 'Platz 3 von 4; freie Mail-Plätze: 2')
  assert.equal(await grund(deps, p[60]), 'Platz 4 von 4; freie Mail-Plätze: 2')
  assert.equal(await grund(deps, p[54]), 'Punktzahl unter 55')
  assert.equal(await grund(deps, p.alt), 'veraltet (nicht ausgewählt)')
  assert.equal(await grund(deps, p.abgestuerzt), 'Lauf unterbrochen')
  assert.equal((await deps.db.get('workersProspects', p.frisch.$id)).status, 'gefunden')
  for (const unberuehrt of [p.zugewiesen, p.anfrage]) assert.equal((await deps.db.get('workersProspects', unberuehrt.$id)).status, 'bewertet')
  assert.deepEqual(deps.db.docs('workersEvents').filter((e) => e.kind === 'top3').map((e) => e.text).sort(), ['Platz 1 von 4: Punktzahl 90', 'Platz 2 von 4: Punktzahl 80'])
})
test('R4: ein zweiter Lauf ändert nichts', async () => {
  const { deps } = await szenario()
  await selectNightTop3(deps, { now: JETZT })
  const vorher = deps.db.writes.length
  const r2 = await selectNightTop3(deps, { now: JETZT })
  assert.deepEqual([r2.selected, r2.botleads], [[], []])
  assert.equal(deps.db.writes.length, vorher)
})
test('R4: ohne freie Plätze geht niemand in die Mail-Schlange', async () => {
  const { deps, p } = await szenario({ mailColdPerDay: 1 }) // ein Platz, ein Nachfass fällig ⇒ 0 frei
  const r = await selectNightTop3(deps, { now: JETZT })
  assert.deepEqual(r.selected, [])
  assert.equal(deps.db.docs('workersJobs').length, 0)
  assert.equal(await grund(deps, p[90]), 'Platz 1 von 4; freie Mail-Plätze: 0')
})
  • Step 2: Fehlschlag sehen. timeout 300 node --max-old-space-size=1024 --test test/p6-night.test.js ⇒ FAIL, Modul fehlt.
  • Step 3: Implementieren. slots = await mailSlots(deps, { now }). Nachzügler: Query.equal('entrance', ['ohne_website']) und Query.equal('status', ['gefunden']) (Index entrance+status), im Code foundAt älter als 12 Stunden ⇒ fileBotLead(GRUND.ABGEBROCHEN). Laden der Kandidaten: Query.equal('entrance', ['ohne_website']) und Query.equal('status', ['bewertet']), danach im Code ohne threadId. Älter als 72 Stunden ⇒ fileBotLead(GRUND.VERALTET). Rest: Punkte < MIN_SCORE_MAIL ⇒ fileBotLead(GRUND.SCHWELLE); die übrigen (m Stück) mit rankCandidates ordnen: Rang 1…slots.free bekommen Faden, Auftrag und Ereignis (Text Platz <n> von <m>: Punktzahl <score>), alle weiteren fileBotLead('Platz <n> von <m>; freie Mail-Plätze: <free>'). Ohne Kandidaten nichts schreiben. Reihenfolge je gewähltem Betrieb: ensureThread(deps, { prospectId, toEmail: prospect.email, subject: '' }) (setzt prospect.threadId), Auftrag einreihen (enqueue(deps, { kind: 'erstmail', workerId: 'max', prospectId, threadId, payload: { rank, of: m, score } })), Ereignis.
  • Step 4: Bestehen sehen. Derselbe Befehl ⇒ # fail 0.
  • Step 5: Commit. git add src/scoring/night.js test/p6-night.test.js && git commit -m "p6: Top-3-Auswahl über alle Räume der Nacht" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P6-T14: Max entwirft die Kaltmail (Prüfung, Ersatztext, nur Code für Pflichtteile)

Files:

  • Create: src/cold/draft.js
  • Test: test/p6-draft.test.js

Interfaces:

  • Consumes: deps.claude.ask(deps, { workerId:'max', system, input, schema, maxTokens }) (A1), buildSystem(workerId, aufgabe) (src/prompts.js, P1; die Persona liegt in der von P4 angelegten, aufgabenneutralen prompts/max.md), assertCap('max','draft'), normalizeName, SCOUT_ROOMS (Branche); aus src/mail/rules.js (P4, H3) BANNED_PHRASES, PLACEHOLDER_PATTERNS, COLD_WORDS, countWords (und in Tests checkOutgoing); WEBKLAR_URL aus src/mail/links.js (P3).

  • Produces: KI_ZEILE, AUFGABE_KALT (Aufgabentext im Code), maxKaltSystem() (= buildSystem('max', AUFGABE_KALT), H5; als Funktion statt Konstante MAX_KALT_SYSTEM, damit Änderungen an prompts/max.md ohne Neustart wirken), KALT_SCHEMA, qaColdMail({ subject, absaetze }, prospect, { settings }?) → string[] (Befunde; mit settings wird zusätzlich die Gesamtlänge der fertig zusammengesetzten Mail gegen COLD_WORDS geprüft), composeColdMail({ variante: 'erst'|'nachfass', prospect, absaetze?, settings, erstDatum? }) → string (Text ohne Pflichtblock, mit genau einer Zeile Mehr über uns: <WEBKLAR_URL> vor dem Gruß), fallbackColdMail({ prospect, settings }) → { subject, absaetze }, draftColdMail(deps, { prospect, settings }) → Promise<{ subject, text, usedFallback }>.

  • Step 1: Test schreiben.

import test from 'node:test'
import assert from 'node:assert/strict'
import { qaColdMail, composeColdMail, fallbackColdMail, draftColdMail, KI_ZEILE, AUFGABE_KALT, maxKaltSystem } from '../src/cold/draft.js'
import { buildSystem } from '../src/prompts.js'
import { checkOutgoing, countWords, COLD_WORDS } from '../src/mail/rules.js'
import { WEBKLAR_URL } from '../src/mail/links.js'
import { makeDeps, fakeClaude } from './support/p6.js'

const prospekt = { name: 'Elektro Beispiel GmbH', city: 'Kaiserslautern', room: 'handwerk', contactName: 'Max Beispiel', email: 'info@beispiel-elektro.de', phone: '0631 123456' }
const settings = { offerText: 'Gratis-Vorschau, unverbindlich. Wenn sie gefällt, ist die Website im Hosting-Abo enthalten: 20 € im Monat, 12 Monate Mindestlaufzeit, ohne MwSt. nach § 19 UStG.' }
const gut = { subject: 'Website-Vorschau für Elektro Beispiel GmbH', absaetze: ['Ich bin auf Elektro Beispiel GmbH in Kaiserslautern gestoßen und habe mir erlaubt, Ihnen etwas vorzuschlagen.', 'Wir erstellen Ihnen eine Vorschau einer neuen Website, ohne dass Sie dafür etwas tun müssen.'] }

test('qaColdMail: gute Mail ohne Befund, jede Regel schlägt einzeln an', () => {
  assert.deepEqual(qaColdMail(gut, prospekt), [])
  const mit = (o) => qaColdMail({ ...gut, ...o }, prospekt)
  assert.ok(mit({ subject: 'Jetzt Website sichern!' }).length)
  assert.ok(mit({ subject: 'x'.repeat(56) }).length) // P4s checkMailDraft erlaubt höchstens 55 Zeichen
  assert.deepEqual(mit({ subject: 'x'.repeat(55) }), [])
  assert.ok(mit({ absaetze: [...gut.absaetze, 'Mehr unter https://beispiel.de'] }).length)
  assert.ok(mit({ absaetze: [...gut.absaetze, 'Für nur 29 € im Monat.'] }).length)
  assert.ok(mit({ absaetze: [...gut.absaetze, 'Wir geben 20 % Rabatt.'] }).length)
  assert.ok(mit({ absaetze: [...gut.absaetze, 'Wie geht es dir und deinem Betrieb?'] }).length)
  assert.ok(mit({ absaetze: ['Sehr geehrte Damen und Herren, wir bieten Websites an.'] }).length)
  assert.ok(mit({ absaetze: [...gut.absaetze, 'Das ist eine einzigartige Gelegenheit.'] }).length, 'verbotene Phrase aus BANNED_PHRASES (src/mail/rules.js)')
  for (const rest of ['Hallo {{name}}.', 'Bis bald [[ort]].', 'Das klären wir TODO.', 'Guten Tag <Firma>.']) assert.ok(mit({ absaetze: [...gut.absaetze, rest] }).length, `Platzhalter: ${rest}`)
  assert.ok(mit({ absaetze: [...gut.absaetze, 'wort '.repeat(100)] }).length)
  assert.ok(mit({ absaetze: ['Wir bieten Websites an.'] }).length, 'weder Name noch Ort')
})
test('H3: höchstens 90 Wörter in den Absätzen; der Schutz-Test hält die fertige Mail unter 180 Wörtern', () => {
  const mitWoertern = (n) => ({ ...gut, absaetze: [...gut.absaetze, 'wort '.repeat(n).trim()] }) // gut hat 31 Wörter
  assert.deepEqual(qaColdMail(mitWoertern(59), prospekt, { settings }), []) // genau 90 Wörter in den Absätzen, fertige Mail 163 Wörter
  assert.ok(qaColdMail(mitWoertern(60), prospekt, { settings }).length, '91 Wörter in den Absätzen')
  const wortreich = { ...settings, offerText: 'ab '.repeat(133).trim() } // 398 Zeichen, 133 Wörter: sprengt mit 90 Wörtern die Gesamtlänge
  assert.ok(qaColdMail(mitWoertern(59), prospekt, { settings: wortreich }).length, 'Gesamtlänge über 180 Wörter')
})
test('composeColdMail erst: Anrede, KI-Hinweis, Absätze, Angebot wörtlich, Antwortzeile, genau ein Link (WEBKLAR_URL), Gruß', () => {
  const t = composeColdMail({ variante: 'erst', prospect: prospekt, absaetze: gut.absaetze, settings })
  const z = t.split('\n\n')
  assert.deepEqual([z[0], z[1]], ['Guten Tag Max Beispiel,', KI_ZEILE])
  assert.equal(KI_ZEILE, 'Ich bin ein KI-Assistent von WEBklar und schreibe Ihnen im Auftrag von Kenso Grimm und Justin Klein.')
  assert.ok(t.includes(settings.offerText))
  assert.ok(t.includes('Wenn Sie die Vorschau sehen möchten, antworten Sie einfach mit „Ja“.'))
  assert.ok(t.endsWith('Freundliche Grüße\nKI-Assistent von WEBklar, im Auftrag von Kenso Grimm und Justin Klein'))
  assert.deepEqual(t.match(/https?:\/\/\S+/g), [WEBKLAR_URL], 'genau ein Link, vom Code gesetzt (H3, A22)')
  assert.ok(t.includes(`Mehr über uns: ${WEBKLAR_URL}\n\nFreundliche Grüße`), 'eigene Zeile direkt vor dem Gruß')
  assert.equal(composeColdMail({ variante: 'erst', prospect: { ...prospekt, contactName: '' }, absaetze: gut.absaetze, settings }).split('\n\n')[0], 'Guten Tag,')
})
test('composeColdMail nachfass: kurze Erinnerung mit Datum, ohne Angebotssatz, genau ein Link', () => {
  const t = composeColdMail({ variante: 'nachfass', prospect: prospekt, settings, erstDatum: '2026-10-20' })
  assert.ok(t.includes('Ich melde mich noch einmal kurz zu unserer Nachricht vom 20.10.2026'))
  assert.ok(t.includes('Die Gratis-Vorschau einer neuen Website für Elektro Beispiel GmbH steht weiterhin bereit.'))
  assert.ok(!t.includes(settings.offerText))
  assert.deepEqual(t.match(/https?:\/\/\S+/g), [WEBKLAR_URL])
})
test('H3: Erstmail, Nachfass und Ersatztext liegen zwischen 60 und 180 Wörtern und bestehen die Regeln des Sende-Tors', () => {
  const ersatz = fallbackColdMail({ prospect: prospekt, settings })
  const mails = {
    erst: { subject: gut.subject, text: composeColdMail({ variante: 'erst', prospect: prospekt, absaetze: gut.absaetze, settings }) },
    nachfass: { subject: `Re: ${gut.subject}`, text: composeColdMail({ variante: 'nachfass', prospect: prospekt, settings, erstDatum: '2026-10-20' }) },
    ersatz: { subject: ersatz.subject, text: composeColdMail({ variante: 'erst', prospect: prospekt, absaetze: ersatz.absaetze, settings }) },
  }
  for (const [name, m] of Object.entries(mails)) {
    const n = countWords(m.text)
    assert.ok(n >= COLD_WORDS.min && n <= COLD_WORDS.max, `${name}: ${n} Wörter`)
    assert.equal(checkOutgoing({ subject: m.subject, text: m.text, cold: true, author: 'max' }, { exclude: [] }), null, name)
  }
})
test('draftColdMail: zweite Antwort bekommt die Befundliste; die Eingabe an das Modell enthält weder Mail noch Telefon', async () => {
  let n = 0
  const deps = makeDeps()
  deps.claude = fakeClaude({ ask: async () => { n++; return { data: n === 1 ? { subject: 'Hallo!', absaetze: ['Für 29 € im Monat.'] } : gut, usage: {}, costUsd: 0.01 } } })
  const r = await draftColdMail(deps, { prospect: prospekt, settings })
  assert.deepEqual([r.usedFallback, n, r.subject], [false, 2, gut.subject])
  assert.ok(deps.claude.calls.ask[1].input.includes('Befund'))
  const erste = deps.claude.calls.ask[0]
  assert.ok(!erste.input.includes('info@beispiel-elektro.de') && !erste.input.includes('0631'))
  assert.equal(erste.workerId, 'max')
  assert.equal(erste.system, maxKaltSystem())
  assert.equal(maxKaltSystem(), buildSystem('max', AUFGABE_KALT), 'H5: Persona aus prompts/max.md plus Aufgabe im Code')
  for (const s of ['Sie-Form', 'keine Links', 'keine Preise', 'höchstens 90 Wörter']) assert.ok(maxKaltSystem().includes(s), s)
})
test('draftColdMail: zwei schlechte Antworten ⇒ Ersatztext, der selbst alle Prüfungen besteht', async () => {
  const deps = makeDeps()
  deps.claude = fakeClaude({ ask: async () => ({ data: { subject: 'Hallo!', absaetze: ['x'] }, usage: {}, costUsd: 0.01 }) })
  const r = await draftColdMail(deps, { prospect: prospekt, settings })
  const e = fallbackColdMail({ prospect: prospekt, settings })
  assert.equal(r.usedFallback, true)
  assert.deepEqual(qaColdMail(e, prospekt, { settings }), [])
  assert.ok(r.text.includes(settings.offerText))
})
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/p6-draft.test.js ⇒ FAIL, Modul fehlt.
  • Step 3: Implementieren.
    • Befunde von qaColdMail (je ein deutscher Satz): Betreff länger als 55 Zeichen (wie P4s checkMailDraft), mit „!“ oder mit „Re:“; Absätze mit https?:, www. oder @; mit €, EUR, Euro, %, Prozent, Rabatt, Preis, Festpreis, garantier; Du-Form (\b(du|dich|dir|dein\w*)\b, ohne Beachtung der Schreibung); jede verbotene Phrase aus BANNED_PHRASES (src/mail/rules.js, klein geschrieben und mit vereinheitlichtem Leerraum verglichen; darunter „Sehr geehrte“); jedes Muster aus PLACEHOLDER_PATTERNS (Platzhalterreste) in Betreff oder Absätzen; mehr als 90 Wörter insgesamt in den Absätzen (H3: bei dem üblichen Angebotssatz bleibt die fertige Mail so unter 180 Wörtern); weder ein Wort (≥ 4 Zeichen) aus normalizeName(prospect.name) noch prospect.city kommt vor. Mit dem dritten Parameter { settings } setzt qaColdMail die Mail mit composeColdMail({ variante: 'erst', … }) zusammen und beanstandet eine Gesamtlänge außerhalb von COLD_WORDS (countWords; ein sehr langer Angebotssatz bis 400 Zeichen kann die 180 Wörter sonst sprengen und das Tor würde mit text_zu_lang verweigern).
    • composeColdMail baut aus Absätzen getrennt durch Leerzeile: Anrede (Guten Tag <contactName>, oder Guten Tag,), KI_ZEILE, bei erst die Absätze, dann settings.offerText wörtlich, dann Wenn Sie die Vorschau sehen möchten, antworten Sie einfach mit „Ja“.; bei nachfass statt dessen genau: Ich melde mich noch einmal kurz zu unserer Nachricht vom <TT.MM.JJJJ>: Die Gratis-Vorschau einer neuen Website für <Name> steht weiterhin bereit. Wenn Sie sie sehen möchten, antworten Sie einfach mit „Ja“. Wenn nicht, ist das völlig in Ordnung – dann melden wir uns nicht mehr bei Ihnen.; bei beiden Varianten danach genau eine Zeile Mehr über uns: <WEBKLAR_URL> (H3, A22: der eine erlaubte Link, vom Code gesetzt, nie vom Modell; das Modell liefert weiter keine Links); Schluss immer Freundliche Grüße\nKI-Assistent von WEBklar, im Auftrag von Kenso Grimm und Justin Klein. Der Pflichtblock (T15) kommt erst nach den Inhaltsprüfungen des Tors dazu und zählt dort nicht mit.
    • fallbackColdMail: Betreff Website-Vorschau für <Name> (ist er länger als 55 Zeichen: Website-Vorschau für Ihren Betrieb), Absätze Wir sind auf <Name> in <Ort> aufmerksam geworden und möchten Ihnen etwas anbieten: Wir erstellen Ihnen eine Vorschau einer neuen Website, ohne dass Sie dafür etwas tun müssen. und Sie sehen sich die Vorschau in Ruhe an und entscheiden danach, ob sie Ihnen gefällt.
    • draftColdMail: assertCap('max','draft'); bis zu zwei Versuche mit deps.claude.ask(deps, { workerId: 'max', system: maxKaltSystem(), input: JSON.stringify({ betrieb, ort, branche: <Raumbeschriftung>, ansprechpartner }), schema: KALT_SCHEMA, maxTokens: 700 }) (zweiter Versuch hängt \n\nBefund aus der letzten Prüfung:\n- … an); qaColdMail(…, prospect, { settings }) leer ⇒ fertig; nach dem zweiten Befund oder bei einem Fehler, der kein BudgetStop ist, Ersatztext (usedFallback: true); BudgetStop wird nicht abgefangen. AUFGABE_KALT (Aufgabentext im Code; die Persona steht in prompts/max.md, P4, aufgabenneutral, und wird von maxKaltSystem() per buildSystem davor gesetzt, H5) muss die Stichworte „Sie-Form“, „keine Links“ (den einen erlaubten Link setzt das Programm ein), „keine Preise“ und „höchstens 90 Wörter“ enthalten und verlangen: persönlich und kurz, nur Name, Ort und Branche verwenden, nichts über die Website des Betriebs behaupten, nichts versprechen. Die Kaltmail-Regeln bleiben im Code, nicht in der Datei.
  • Step 4: Bestehen sehen. Derselbe Befehl ⇒ # fail 0.
  • Step 5: Commit. git add src/cold/draft.js test/p6-draft.test.js && git commit -m "p6: Kaltmail entwerfen mit Prüfung und Ersatztext" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P6-T15: Pflichtblock der Kaltmail und Zusatzprüfung am Sende-Tor (E6)

Files:

  • Create: src/mail/coldFooter.js, src/mail/coldChecks.js
  • Modify: src/mail/footer.js (P4: Zweig cold:true), die davon betroffenen P4-Tests (src/mail/gate.js bleibt unberührt: P4 liest nur settings.privacyInteressentenLive, A5)
  • Test: test/p6-gate-cold.test.js

Interfaces:

  • Consumes (P4): registerGateCheck(fn) → unregister (fn(deps, ctx) → reason|null, ctx = { prospect, thread, to, text, cold, author, settings, first }), sendMail(deps, { workerId:'max', prospectId, threadId?, to, subject, text, cold, kind?, author?, inReplyTo?, references?, idempotencyKey? }) → { sent, reason?, messageId? }, appendCompliance(text, { prospect, cold, unsubscribeUrl, first, human, offerText }) (P4-E3); DATENSCHUTZ_URL aus src/mail/links.js (P3, A6); composeColdMail (T14, für den H3-gültigen Testtext); makeDeps, seedProspect, seedThread (T01).

  • Produces: buildColdFooter({ prospect, unsubscribeUrl }) → string (wirft Error('Quelle der Adresse fehlt') ohne emailSource; nimmt die Datenschutz-Adresse aus src/mail/links.js, coldFooter.js exportiert keine eigene Konstante); appendCompliance(..., cold:true) = text.trimEnd() + Leerzeile + Pflichtblock, für Erst- und Nachfass-Mail, unabhängig von first; coldChecks({ prospect, thread, to, cold, settings }) → { ok: true } | { ok: false, reason } mit Gründen datenschutz (derselbe Grund wie in P4, vorübergehend: der Auftrag bleibt offen), kein_kaltkontakt, status_unpassend, adresse_nicht_belegt, kaltmail_ohne_antwort; coldGateCheck(deps, ctx) → reason|null; registerColdGateCheck() → unregister.

  • Step 1: Test schreiben. Das echte Tor, der Mailer und die Konfiguration (Abmelde-Basis, Geheimnis) kommen über makeDeps aus P4s Hilfen.

import test from 'node:test'
import assert from 'node:assert/strict'
import { buildColdFooter } from '../src/mail/coldFooter.js'
import { DATENSCHUTZ_URL } from '../src/mail/links.js'
import { appendCompliance } from '../src/mail/footer.js'
import { coldChecks, registerColdGateCheck } from '../src/mail/coldChecks.js'
import { composeColdMail } from '../src/cold/draft.js'
import { getSettings } from '../src/settings.js'
import { sendMail } from '../src/mail/gate.js'
import { makeDeps, seedProspect, seedThread } from './support/p6.js'

const prospekt = { entrance: 'ohne_website', status: 'bewertet', email: 'info@beispiel-elektro.de', emailSource: 'https://beispiel-elektro.de/impressum' }
const UNSUB = 'https://entwurf.project.webklar.com/abmelden/abc.p1'

test('buildColdFooter: alle Pflichtangaben und genau drei Adressen', () => {
  const f = buildColdFooter({ prospect: prospekt, unsubscribeUrl: UNSUB })
  for (const s of ['KI-Assistent von WEBklar', 'WEBklar GbR (Kenso Grimm und Justin Klein), Am Schwimmbad 10, 67722 Winnweiler', 'Ihrem Impressum: https://beispiel-elektro.de/impressum', 'Art. 6 Abs. 1 lit. f DSGVO', 'widersprechen (Art. 21 DSGVO)', UNSUB, 'Mensch']) assert.ok(f.includes(s), s)
  assert.equal(DATENSCHUTZ_URL, 'https://www.webklar.com/datenschutz#interessenten')
  assert.deepEqual(f.match(/https?:\/\/\S+/g), ['https://beispiel-elektro.de/impressum', DATENSCHUTZ_URL, UNSUB])
  assert.ok(buildColdFooter({ prospect: { ...prospekt, emailSource: 'https://beispiel-elektro.de/kontakt' }, unsubscribeUrl: UNSUB }).includes('Ihrer Kontaktseite: https://beispiel-elektro.de/kontakt'))
  assert.throws(() => buildColdFooter({ prospect: { ...prospekt, emailSource: '' }, unsubscribeUrl: UNSUB }), /Quelle/)
})
test('appendCompliance cold: Text, Leerzeile, Pflichtblock – erste und spätere Kaltmail gleich', () => {
  const erwartet = `Hallo\n\n${buildColdFooter({ prospect: prospekt, unsubscribeUrl: UNSUB })}`
  for (const first of [true, false]) assert.equal(appendCompliance('Hallo\n', { prospect: prospekt, cold: true, first, unsubscribeUrl: UNSUB }), erwartet)
})

const ABSAETZE = ['Ich bin auf Elektro Beispiel GmbH in Kaiserslautern gestoßen.', 'Wir erstellen Ihnen eine Vorschau einer neuen Website.']
const T = (o = {}) => ({ prospect: { ...prospekt, ...o.prospect }, thread: { lastInboundAt: '', ...o.thread }, to: 'info@beispiel-elektro.de', cold: true, settings: { privacyInteressentenLive: true }, ...o.rest })
test('R3: coldChecks – jeder Verstoß hat seinen Grund, Tarnung als Antwort wird erkannt', () => {
  assert.deepEqual(coldChecks(T()), { ok: true })
  assert.equal(coldChecks(T({ rest: { settings: { privacyInteressentenLive: false } } })).reason, 'datenschutz')
  assert.equal(coldChecks(T({ rest: { to: 'andere@beispiel-elektro.de' } })).reason, 'adresse_nicht_belegt')
  assert.equal(coldChecks(T({ rest: { to: 'INFO@Beispiel-Elektro.de' } })).ok, true)
  assert.equal(coldChecks(T({ prospect: { emailSource: '' } })).reason, 'adresse_nicht_belegt')
  assert.equal(coldChecks(T({ prospect: { entrance: 'anfrage' } })).reason, 'kein_kaltkontakt')
  assert.equal(coldChecks(T({ prospect: { status: 'gespraech' } })).reason, 'status_unpassend')
  assert.equal(coldChecks(T({ rest: { cold: false } })).reason, 'kaltmail_ohne_antwort')
  assert.equal(coldChecks(T({ rest: { cold: false }, thread: { lastInboundAt: '2026-10-20T09:00:00Z' } })).ok, true)
  assert.equal(coldChecks(T({ prospect: { entrance: 'anfrage' }, rest: { cold: false } })).ok, true)
})
test('R3: Tor – Verstöße verweigert, sonst Pflichtblock, Abmelde-Header und Nachweis der Quelle', async (t0) => {
  t0.after(registerColdGateCheck())
  const deps = makeDeps({ now: '2026-10-20T09:00:00Z' }) // Di 11:00 Berlin, Schalter an
  const p = await seedProspect(deps.db, {})
  const t = await seedThread(deps.db, { prospectId: p.$id, toEmail: p.email })
  const text = composeColdMail({ variante: 'erst', prospect: p, absaetze: ABSAETZE, settings: await getSettings(deps) }) // H3-gültig: 60–180 Wörter, genau ein Link
  const basis = { workerId: 'max', prospectId: p.$id, threadId: t.$id, to: p.email, subject: 'Website-Vorschau für Elektro Beispiel GmbH', text, cold: true }
  assert.equal((await sendMail(deps, { ...basis, cold: false })).reason, 'kaltmail_ohne_antwort')
  assert.equal((await sendMail(deps, { ...basis, to: 'andere@beispiel-elektro.de' })).reason, 'adresse_ungueltig') // H3, P4: gleich der Adresse des Fadens und des Interessenten
  await deps.db.update('workersProspects', p.$id, { emailSource: '' })
  assert.equal((await sendMail(deps, basis)).reason, 'adresse_nicht_belegt') // keine Quelle der Adresse
  await deps.db.update('workersProspects', p.$id, { emailSource: p.emailSource })
  assert.equal(deps.mailer.sent.length, 0)
  assert.equal((await sendMail(deps, basis)).sent, true)
  const m = deps.mailer.sent[0]
  assert.ok(m.text.includes('Ihrem Impressum: https://beispiel-elektro.de/impressum') && m.text.includes('Art. 21 DSGVO'))
  assert.match(m.headers['List-Unsubscribe'], /<https:\/\/entwurf\.project\.webklar\.com\/abmelden\/[A-Za-z0-9_-]{22}\.[^>]+>/)
  assert.equal(m.headers['List-Unsubscribe-Post'], 'List-Unsubscribe=One-Click')
  const msg = deps.db.docs('workersMessages').find((d) => d.direction === 'out')
  assert.deepEqual([msg.countsAsCold, msg.contactSource], [true, p.emailSource])
  const aus = makeDeps({ now: '2026-10-20T09:00:00Z', settings: { privacyInteressentenLive: false } })
  const p2 = await seedProspect(aus.db, {})
  const t2 = await seedThread(aus.db, { prospectId: p2.$id, toEmail: p2.email })
  assert.equal((await sendMail(aus, { ...basis, prospectId: p2.$id, threadId: t2.$id, to: p2.email })).reason, 'datenschutz')
  assert.equal(aus.mailer.sent.length, 0)
})
  • Step 2: Fehlschlag sehen. timeout 300 node --max-old-space-size=1024 --test test/p6-gate-cold.test.js ⇒ FAIL, Cannot find module '../src/mail/coldFooter.js'.
  • Step 3: Implementieren.
    • coldFooter.js (exakter Wortlaut, <…> wird eingesetzt; Quelle: „Ihrem Impressum“, wenn die URL impress enthält, sonst „Ihrer Kontaktseite“; die Datenschutz-Adresse in der Zeile „Wir nutzen sie …“ ist DATENSCHUTZ_URL aus src/mail/links.js und wird von dort eingesetzt, nicht neu geschrieben):
--
Hinweise zu dieser Nachricht
Diese Nachricht hat ein KI-Assistent von WEBklar geschrieben und verschickt, im Auftrag der WEBklar GbR (Kenso Grimm und Justin Klein), Am Schwimmbad 10, 67722 Winnweiler, Telefon 0176 23726355, support@webklar.com.
Ihre Kontaktdaten haben wir aus <Ihrem Impressum|Ihrer Kontaktseite>: <emailSource>
Wir nutzen sie, um Ihnen einmalig ein Angebot zu machen (berechtigtes Interesse, Art. 6 Abs. 1 lit. f DSGVO). Alle Angaben dazu: https://www.webklar.com/datenschutz#interessenten
Sie können der Verarbeitung Ihrer Daten jederzeit widersprechen (Art. 21 DSGVO): Antworten Sie einfach auf diese Nachricht oder melden Sie sich mit einem Klick ab: <unsubscribeUrl>
Danach schreiben wir Ihnen nicht mehr. Möchten Sie lieber mit einem Menschen sprechen? Schreiben Sie uns „Mensch“, dann antwortet Ihnen ein Mitarbeiter.
  • footer.js (P4-T04): Zweig cold ⇒ ${text.trimEnd()}\n\n${buildColdFooter({ prospect, unsubscribeUrl })} (statt P4s kurzem Block); cold:false unverändert. P4s Tests, die für Kaltmails den alten Block erwarten (test/mail-footer.test.js: Fall „Kalt, ohne_website“ und „Kalt ohne emailSource“; test/mail-gate.test.js: „Erfolg … body endet mit Impressum-Zeile“), werden auf buildColdFooter bzw. dessen letzte Zeile umgestellt.
  • coldChecks.js (rein): bei cold:true in der Reihenfolge Datenschutz-Schalter (settings.privacyInteressentenLive falsch ⇒ datenschutz), entrance !== 'ohne_website' ⇒ kein_kaltkontakt, status nicht bewertet|kontaktiert ⇒ status_unpassend, emailSource leer oder to (klein geschrieben, getrimmt) ≠ prospect.email ⇒ adresse_nicht_belegt; bei cold:false: entrance === 'ohne_website' und thread.lastInboundAt leer ⇒ kaltmail_ohne_antwort; sonst { ok: true }. coldGateCheck = (deps, ctx) => { const r = coldChecks({ prospect: ctx.prospect, thread: ctx.thread, to: ctx.to, cold: ctx.cold, settings: ctx.settings }); return r.ok ? null : r.reason }; registerColdGateCheck = () => registerGateCheck(coldGateCheck). P4s Tor ruft die Prüfung nach den Inhaltsregeln (H3), der Sperrliste und der SMTP-Tagessperre und vor dem Datenschutz-Gate, den Tageszählern und dem Sendefenster auf (Übersicht §6, A15; P4-T07); den Nachweis der Quelle (contactSource), die Abmelde-Header und die Message-ID liefert P4-T08 bereits. Der Datenschutz-Schalter ist allein settings.privacyInteressentenLive (A5); ein Env-Schalter existiert nicht.
  • Step 4: Bestehen sehen. Derselbe Befehl ⇒ # fail 0; danach timeout 300 node --max-old-space-size=1024 --test test/mail-footer.test.js test/mail-gate-precheck.test.js test/mail-gate.test.js (P4) ⇒ weiter grün.
  • Step 5: Commit. git add src/mail/coldFooter.js src/mail/coldChecks.js src/mail/footer.js test/p6-gate-cold.test.js test/mail-footer.test.js test/mail-gate-precheck.test.js test/mail-gate.test.js && git commit -m "p6: Pflichtblock der Kaltmail und Zusatzprüfung am Sende-Tor" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>" (nur die tatsächlich geänderten Dateien aufnehmen).

P6-T16: Entwürfe für P4s Sende-Tick und Nachfass-Aufträge (E8)

P4s Tick senden (runSenden, P4-T30) holt die Aufträge erstmail und nachfass selbst (Arten nach rankOf, H1), prüft Pause, Notbremse (versand_gestoppt, H4), Sendefenster, Tageslimit (Nachfass vor Erstmails), Budget und Sperrliste, stellt zurück (auch versand_gestoppt und budget, A18), ohne Versuche zu verbrauchen, ruft deps.drafters[kind] für den Text, sendet über das Tor und setzt danach Faden, Status kontaktiert, followupDueAt und followupSent (onOutbound). P6 baut deshalb keinen eigenen Sendeablauf, sondern nur die beiden Entwurfsfunktionen und legt die Nachfass-Aufträge an.

Files:

  • Create: src/cold/flow.js
  • Test: test/p6-flow.test.js

Interfaces:

  • Consumes: draftColdMail, composeColdMail (T14), mailSlots, followupThreads (T04), getSettings, enqueue, emit, BudgetStop; P4: runSenden(deps) (src/gespraeche/senden.js), ensureThread, lastOutbound (src/gespraeche/threads.js), getDoc, nowOf (src/gespraeche/util.js), registerColdGateCheck (T15), Hilfen aus T01.

  • Produces: erstmailDrafter(deps, job) → Promise<{ subject, text }>; nachfassDrafter(deps, job) → Promise<{ subject, text, inReplyTo, references }>; registerColdDrafters(deps) → deps (setzt deps.drafters.erstmail und .nachfass, andere Einträge bleiben); scheduleFollowups(deps) → Promise<{ erzeugt: number }>.

  • Step 1: Test schreiben.

import test from 'node:test'
import assert from 'node:assert/strict'
import { BudgetStop } from '../src/claude.js'
import { runSenden } from '../src/gespraeche/senden.js'
import { ensureThread } from '../src/gespraeche/threads.js'
import { enqueue } from '../src/jobs.js'
import { registerColdGateCheck } from '../src/mail/coldChecks.js'
import { registerColdDrafters, scheduleFollowups } from '../src/cold/flow.js'
import { makeDeps, fakeClaude, seedProspect, seedThread } from './support/p6.js'

const gutes = { subject: 'Website-Vorschau für Elektro Beispiel GmbH', absaetze: ['Ich bin auf Elektro Beispiel GmbH in Kaiserslautern gestoßen.', 'Wir erstellen Ihnen eine Vorschau einer neuen Website.'] }
async function szenario(t0, settings = {}) {
  t0.after(registerColdGateCheck())
  const deps = makeDeps({ now: '2026-10-20T09:00:00Z', settings }) // Di 11:00 Berlin, Schalter an
  deps.claude = fakeClaude({ ask: async () => ({ data: gutes, usage: {}, costUsd: 0.01 }) }) // Max entwirft; die Testmails kommen aus composeColdMail (≥ 60 Wörter, ein Link) und bestehen so die Regeln des Tors (H3)
  registerColdDrafters(deps)
  const p = await seedProspect(deps.db, {})
  const t = await ensureThread(deps, { prospectId: p.$id, toEmail: p.email, subject: '' })
  const job = await enqueue(deps, { kind: 'erstmail', workerId: 'max', prospectId: p.$id, threadId: t.$id, payload: {} })
  return { deps, p, t, job }
}

test('Erstmail: Entwurf von P6, Versand, Status und Nachfass-Termin durch P4s Tick', async (t0) => {
  const { deps, p, t, job } = await szenario(t0)
  assert.equal((await runSenden(deps)).sent, 1)
  assert.equal(deps.mailer.sent.length, 1)
  const m = deps.mailer.sent[0]
  assert.ok(JSON.stringify(m.to).includes(p.email))
  assert.equal(m.subject, gutes.subject)
  assert.ok(m.text.includes('KI-Assistent von WEBklar') && m.text.includes('Art. 21 DSGVO'))
  assert.equal((await deps.db.get('workersProspects', p.$id)).status, 'kontaktiert')
  const td = await deps.db.get('workersThreads', t.$id)
  assert.deepEqual([td.status, td.followupDueAt.slice(0, 10), td.followupSent], ['wartet_auf_kunde', '2026-10-27', false])
  assert.ok(deps.db.docs('workersEvents').some((e) => e.kind === 'entwurf' && e.workerId === 'max'))
  assert.equal((await deps.db.get('workersJobs', job.id)).status, 'fertig')
})
test('Drafter ohne Budget: der Auftrag bleibt offen, nichts wird gesendet, kein Versuch verbraucht', async (t0) => {
  const { deps, job } = await szenario(t0)
  deps.claude = fakeClaude({ ask: async () => { throw new BudgetStop('tag') } }) // Konstruktor wie in P1
  await runSenden(deps)
  assert.equal(deps.mailer.sent.length, 0)
  const jb = await deps.db.get('workersJobs', job.id)
  assert.deepEqual([jb.status, jb.attempts], ['offen', 0])
})
test('scheduleFollowups: ein Auftrag je fälligem Faden bis Tagesende, nie ein zweiter', async () => {
  const deps = makeDeps({ now: '2026-10-27T03:30:00Z' }) // Di 04:30 Berlin (MEZ)
  const mk = async (over) => { const p = await seedProspect(deps.db, { status: 'kontaktiert' }); return seedThread(deps.db, { prospectId: p.$id, followupDueAt: '2026-10-27T09:05:00Z', ...over }) }
  const faellig = await mk({})
  await mk({ followupSent: true })                     // schon gesendet
  await mk({ followupDueAt: '2026-10-28T09:05:00Z' })  // erst morgen
  await mk({ status: 'ja' })                           // hat geantwortet
  assert.deepEqual(await scheduleFollowups(deps), { erzeugt: 1 })
  assert.deepEqual(await scheduleFollowups(deps), { erzeugt: 0 })
  const j = deps.db.docs('workersJobs')
  assert.deepEqual([j.length, j[0].kind, j[0].workerId, j[0].threadId, j[0].prospectId], [1, 'nachfass', 'max', faellig.$id, faellig.prospectId])
  await deps.db.update('workersJobs', j[0].$id, { status: 'fertig' })
  assert.deepEqual(await scheduleFollowups(deps), { erzeugt: 0 }) // auch ein beendeter Auftrag zählt: höchstens ein Nachfass je Faden
})
test('R4: Nachfass genau einmal, mit Re:-Betreff, Bezug auf die Erstmail und deren Datum', async (t0) => {
  const { deps, t } = await szenario(t0)
  await runSenden(deps)                       // Erstmail am Di 20.10.
  deps.clock.set('2026-10-27T03:30:00Z')      // Di 27.10. 04:30 Berlin
  assert.deepEqual(await scheduleFollowups(deps), { erzeugt: 1 })
  deps.clock.set('2026-10-27T09:00:00Z')      // 10:00 Berlin, im Sendefenster
  assert.equal((await runSenden(deps)).sent, 1)
  const [erst, nach] = deps.mailer.sent
  assert.equal(nach.subject, `Re: ${erst.subject}`)
  assert.equal(nach.inReplyTo, erst.messageId)
  assert.ok(nach.text.includes('Ich melde mich noch einmal kurz zu unserer Nachricht vom 20.10.2026'))
  const td = await deps.db.get('workersThreads', t.$id)
  assert.deepEqual([td.followupSent, td.followupDueAt ?? null], [true, null])
  assert.deepEqual(await scheduleFollowups(deps), { erzeugt: 0 })
  assert.equal((await runSenden(deps)).sent, 0)
  assert.equal(deps.mailer.sent.length, 2)
})
  • Step 2: Fehlschlag sehen. timeout 300 node --max-old-space-size=1024 --test test/p6-flow.test.js ⇒ FAIL, Cannot find module '../src/cold/flow.js'.
  • Step 3: Implementieren.
    • erstmailDrafter(deps, job): prospect = await getDoc(deps, 'workersProspects', job.prospectId) (fehlt er ⇒ Error('Interessent fehlt')), settings = await getSettings(deps); draftColdMail (T14) und composeColdMail({ variante: 'erst', prospect, absaetze, settings }); Ereignis entwurf (Max, prospectId, Text „Erstmail entworfen ()“); Rückgabe { subject, text }. BudgetStop wird nicht abgefangen.
    • nachfassDrafter(deps, job): Faden (job.threadId) und letzte ausgehende Nachricht (lastOutbound, P4) laden; fehlt eine ⇒ Error('Erstmail fehlt'); erstDatum = Berliner Datum von sentAt; composeColdMail({ variante: 'nachfass', prospect, settings, erstDatum }); Ereignis entwurf (Text „Nachfass entworfen ()“); Rückgabe { subject: 'Re: ' + letzteNachricht.subject, text, inReplyTo: letzteNachricht.messageId, references: [letzteNachricht.messageId] }.
    • registerColdDrafters(deps): deps.drafters = { ...(deps.drafters ?? {}), erstmail: erstmailDrafter, nachfass: nachfassDrafter }; Rückgabe deps.
    • scheduleFollowups(deps): sendDay aus mailSlots(deps, { now: nowOf(deps) }), Fäden aus followupThreads(deps, { sendDay }) (T04, dieselbe Definition wie bei den reservierten Plätzen); je Faden mit vorhandenem Interessenten im Status kontaktiert und ohne irgendeinen Auftrag nachfass für diesen Faden (listAll('workersJobs', [Query.equal('kind', ['nachfass'])]), Rest im Code) genau ein enqueue(deps, { kind: 'nachfass', workerId: 'max', prospectId, threadId, payload: {} }).
    • Die drei Zusagen, die P6 von P4s runMailJob (P4-T16) braucht, sind dort Pflicht (P4-Fixliste 11), kein „falls sie fehlen“ mehr: (1) inReplyTo und references des Drafters werden an sendMail durchgereicht; (2) BudgetStop aus einem Drafter ergibt { deferred: 'budget' } (Auftrag wird mit releaseJob freigegeben, kein Versuch verbraucht); (3) Drafter-Texte laufen nicht durch checkMailDraft (dessen Wortgrenze für Modelltext würde KI-Zeile, Angebotssatz und Gruß mitzählen und jede Kaltmail verwerfen), für sie gelten die Regeln des Sende-Tors aus src/mail/rules.js (H3: 60–180 Wörter, genau ein Link WEBKLAR_URL); eine nicht zurückstellbare H3-Verweigerung ergibt dort Job skipped plus Pult unsicher. P6 ändert keine P4-Datei; die Tests dieser Datei (Nachfass mit Bezug auf die Erstmail, BudgetStop ⇒ offen ohne Versuch, beide Mails passieren das echte Tor) sind der Pin dafür.
  • Step 4: Bestehen sehen. Derselbe Befehl ⇒ # fail 0; danach timeout 300 node --max-old-space-size=1024 --test test/gespraeche-max.test.js test/gespraeche-senden.test.js (P4) ⇒ grün.
  • Step 5: Commit. git add src/cold/flow.js test/p6-flow.test.js && git commit -m "p6: Entwurfsfunktionen für P4s Sende-Tick, Nachfass-Aufträge" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P6-T17: Kürzung nach Frist (Arne, Tick kuerzen)

Files:

  • Create: src/archive/kuerzen.js
  • Test: test/p6-kuerzen.test.js

Interfaces:

  • Consumes: Sammlungen workersProspects (Index status), workersThreads, workersMessages (Index auf threadId, P3-E9), workersPult, workersOffers; book, nowOf (P4 src/gespraeche/util.js).

  • Produces: FRIST_ABSAGE_TAGE = 30, FRIST_RUHE_TAGE = 183, runKuerzen(deps) → Promise<{ gekuerzt: number, uebersprungen: number }>. Regeln: A purgeAfter erreicht und Status gefunden|bewertet|botlead|kontaktiert ohne eingegangene Antwort (thread.lastInboundAt leer) ⇒ kürzen; mit Antwort oder weiterem Status ⇒ nicht kürzen, purgeAfter leeren (ab dann gilt B oder C). B Status verloren|gesperrt und updatedAt älter als 30 Tage ⇒ kürzen. C Status kontaktiert (mit Antwort), gespraech|ja|vorschau_bau|vorschau_gesendet|angebot ohne customerId und letzte Aktivität älter als 183 Tage ⇒ kürzen. deal nie. Kürzen = persönliche Felder leeren, name '[gekürzt]', purgedAt setzen, purgeAfter entfernen, Faden toEmail/subject leeren, Nachrichten subject '', body '[gekürzt]', contactSource '', Pult-Einträge des Interessenten (title '[gekürzt]', originalText '', draft '', sourceUrl '') und Kauf-Links (businessName, email, previewUrl je '[gekürzt]'); workersKeys und workersSuppression bleiben unberührt.

  • Step 1: Test schreiben.

import test from 'node:test'
import assert from 'node:assert/strict'
import { runKuerzen } from '../src/archive/kuerzen.js'
import { makeDeps, seedProspect } from './support/p6.js'

test('runKuerzen: Fristen A, B und C, Ausnahmen, Register und Sperrliste bleiben', async () => {
  const deps = makeDeps({ now: '2026-10-20T01:30:00Z' })
  const voll = { contactName: 'Max Beispiel', phone: '0631 123456', phoneSource: 'https://x.de/i', website: 'https://beispiel-elektro.de', street: 'Hauptstraße 5', placeId: 'ChIJa' }
  const faellig = '2026-10-19T00:00:00Z'
  const a = await seedProspect(deps.db, { ...voll, status: 'botlead', botLeadReason: 'keine veröffentlichte Mail', purgeAfter: faellig })
  const b = await seedProspect(deps.db, { ...voll, status: 'kontaktiert', purgeAfter: faellig, threadId: 't-b' })
  await deps.db.create('workersThreads', { prospectId: b.$id, channel: 'mail', toEmail: b.email, subject: 'S', status: 'wartet_auf_kunde', followupSent: true, reminderSent: false, createdAt: faellig }, 't-b')
  await deps.db.create('workersMessages', { threadId: 't-b', direction: 'out', author: 'max', subject: 'S', body: 'Guten Tag Max Beispiel', channel: 'mail', contactSource: 'https://x.de/i', countsAsCold: true, createdAt: faellig })
  const c = await seedProspect(deps.db, { ...voll, status: 'kontaktiert', purgeAfter: faellig, threadId: 't-c' })
  await deps.db.create('workersThreads', { prospectId: c.$id, channel: 'mail', toEmail: c.email, subject: 'S', status: 'offen', lastInboundAt: '2026-10-10T08:00:00Z', followupSent: false, reminderSent: false, createdAt: faellig }, 't-c')
  const d = await seedProspect(deps.db, { ...voll, status: 'gespraech', purgeAfter: faellig })
  await seedProspect(deps.db, { ...voll, status: 'botlead', purgeAfter: '2026-10-21T00:00:00Z' })
  const f = await seedProspect(deps.db, { ...voll, status: 'verloren', updatedAt: '2026-09-10T00:00:00Z' })
  const g = await seedProspect(deps.db, { ...voll, status: 'gespraech', updatedAt: '2026-03-01T00:00:00Z' })
  const h = await seedProspect(deps.db, { ...voll, status: 'deal', customerId: 'c1', updatedAt: '2025-01-01T00:00:00Z' })
  await deps.db.create('workersPult', { kind: 'unsicher', prospectId: g.$id, title: 'Antwort von Elektro Beispiel GmbH', originalText: 'Guten Tag, wir hätten Interesse', draft: 'Entwurf', sourceUrl: 'https://x.de/i', status: 'offen', createdAt: faellig }, 'pu1')
  await deps.db.create('workersOffers', { tokenHash: 'h1', prospectId: g.$id, previewId: 'v1', productKey: 'hosting', businessName: 'Elektro Beispiel GmbH', email: 'info@beispiel-elektro.de', previewUrl: 'https://entwurf.project.webklar.com/x/', status: 'abgelaufen', expiresAt: faellig, createdAt: faellig }, 'o1')
  await deps.db.create('workersKeys', { kind: 'mail', prospectId: a.$id, origin: 'workers', blocked: false, hint: false, createdAt: faellig }, 'k1')
  const vorher = deps.db.writes.length

  assert.deepEqual(await runKuerzen(deps), { gekuerzt: 4, uebersprungen: 2 }) // a, b, f, g gekürzt; c, d übersprungen
  const pa = await deps.db.get('workersProspects', a.$id)
  assert.deepEqual([pa.name, pa.email, pa.emailSource, pa.phone, pa.website, pa.street, pa.placeId, pa.contactName, pa.sourceUrl], ['[gekürzt]', '', '', '', '', '', '', '', ''])
  assert.deepEqual([pa.status, pa.room, pa.score, pa.botLeadReason], ['botlead', 'handwerk', 80, 'keine veröffentlichte Mail'])
  assert.ok(pa.purgedAt && !pa.purgeAfter)
  assert.deepEqual([(await deps.db.get('workersThreads', 't-b')).toEmail, deps.db.docs('workersMessages')[0].body, deps.db.docs('workersMessages')[0].contactSource], ['', '[gekürzt]', ''])
  for (const x of [f, g]) assert.equal((await deps.db.get('workersProspects', x.$id)).email, '')
  const pu = await deps.db.get('workersPult', 'pu1')
  assert.deepEqual([pu.title, pu.originalText, pu.draft, pu.sourceUrl], ['[gekürzt]', '', '', ''])
  const o = await deps.db.get('workersOffers', 'o1')
  assert.deepEqual([o.businessName, o.email, o.previewUrl], ['[gekürzt]', '[gekürzt]', '[gekürzt]'])
  for (const x of [c, d, h]) assert.equal((await deps.db.get('workersProspects', x.$id)).email, 'info@beispiel-elektro.de')
  for (const x of [c, d]) assert.ok(!(await deps.db.get('workersProspects', x.$id)).purgeAfter, 'Antwort oder weiterer Status: keine Frist mehr')
  assert.ok(deps.db.writes.slice(vorher).every((w) => !['workersKeys', 'workersSuppression'].includes(w.col)))
  assert.deepEqual(await runKuerzen(deps), { gekuerzt: 0, uebersprungen: 0 })
})
  • Step 2: Fehlschlag sehen. timeout 300 node --max-old-space-size=1024 --test test/p6-kuerzen.test.js ⇒ FAIL, Modul fehlt.
  • Step 3: Implementieren. jetzt = nowOf(deps). Alle Auswahlen über Query.equal('status', [...]) (Index status), Fristen im Code: A über die Status gefunden|bewertet|botlead|kontaktiert mit purgeAfter ≤ jetzt, B über verloren|gesperrt, C über kontaktiert|gespraech|ja|vorschau_bau|vorschau_gesendet|angebot; C zählt nicht als „übersprungen“, wenn die Frist noch läuft. Interessenten mit purgedAt werden übersprungen, ohne zu zählen. Letzte Aktivität (C) = größtes aus updatedAt und den Zeitpunkten lastInboundAt, lastOutboundAt des Fadens (Faden über prospect.threadId, db.get kann null liefern). Pult-Einträge und Kauf-Links: je Lauf einmal listAll('workersPult') und listAll('workersOffers') (kleine Sammlungen, kein Index auf prospectId nötig) und im Code nach prospectId der zu kürzenden Interessenten zuordnen. Beim Kürzen leeren: contactName email emailSource phone phoneSource website street postalCode placeId requestText sourceUrl scoreReason; city bleibt (Ortsangabe ohne Personenbezug, für die Auswertung der Suchläufe). Am Ende book(deps, { workerId: 'arne', usd: 0, counters: { gekuerzt: n } }) (nur wenn n > 0); keine Ereignisse.
  • Step 4: Bestehen sehen. Derselbe Befehl ⇒ # fail 0.
  • Step 5: Commit. git add src/archive/kuerzen.js test/p6-kuerzen.test.js && git commit -m "p6: Kürzung nach Frist, Register und Sperrliste bleiben" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P6-T18: API GET /api/workers/botleads (E7)

Files:

  • Create: src/api/botleads.js (die Route hängt über die routes-Registry von P3-E6 ein; src/api.js bleibt unberührt)
  • Test: test/p6-botleads-api.test.js

Interfaces:

  • Consumes: SCOUT_ROOMS, normalizeName, getDoc (P4 src/gespraeche/util.js), Sammlung workersProspects (Index entrance+status).

  • Produces: class BadRequest extends Error; listBotLeads(deps, { room = '', q = '', page = 1, id = '' }, { pageSize = 50 } = {}) → Promise<{ items, total }> mit item = { id, room, roomLabel, name, street, postalCode, city, website, score, reason, source, sourceUrl, foundAt, isHealth, gekuerzt, phone, phoneSource } (kein email, kein emailSource; phone und phoneSource nur, wenn phoneSource gesetzt und isHealth falsch, sonst ''); BOTLEADS_ROUTES = [{ method: 'GET', pattern: /^\/botleads$/, handler }] mit handler(deps, ctx) → { status, json } (ctx.query ist URLSearchParams mit room, q, page, id; Team-Recht prüft die Registry vorher): 200 { items, total }, BadRequest ⇒ 400 { error: 'Unbekannter Raum.' }.

  • Step 1: Test schreiben.

import test from 'node:test'
import assert from 'node:assert/strict'
import { listBotLeads, BadRequest, BOTLEADS_ROUTES } from '../src/api/botleads.js'
import { makeDeps, seedProspect } from './support/p6.js'

async function szenario() {
  const deps = makeDeps()
  const mk = (over) => seedProspect(deps.db, { status: 'botlead', botLeadReason: 'keine veröffentlichte Mail', email: '', emailSource: '', ...over })
  const a = await mk({ name: 'Elektro Beispiel GmbH', city: 'Kaiserslautern', phone: '0631 111', phoneSource: 'https://x.de/i', score: 82, foundAt: '2026-10-19T20:00:00Z' })
  await mk({ name: 'Praxis Dr. Muster', room: 'aerzte', isHealth: true, phone: '0631 222', phoneSource: 'https://x.de/i', foundAt: '2026-10-18T20:00:00Z' }) // Altbestand mit Nummer
  const c = await mk({ name: 'Bäckerei Korn', room: 'laden', city: 'Mainz', phone: '0613 333', phoneSource: '', foundAt: '2026-10-17T20:00:00Z' })
  await mk({ name: 'Nicht Bot', status: 'bewertet' })
  await mk({ name: 'Anfrage', entrance: 'anfrage' })
  return { deps, a, c }
}

test('listBotLeads: nur Bot-Leads der Etage, neueste zuerst, ohne E-Mail', async () => {
  const { deps } = await szenario()
  const r = await listBotLeads(deps, {})
  assert.equal(r.total, 3)
  assert.deepEqual(r.items.map((i) => i.name), ['Elektro Beispiel GmbH', 'Praxis Dr. Muster', 'Bäckerei Korn'])
  assert.ok(r.items.every((i) => !('email' in i) && !('emailSource' in i)))
  assert.deepEqual([r.items[0].roomLabel, r.items[0].reason, r.items[0].score], ['Handwerk', 'keine veröffentlichte Mail', 82])
})
test('R5: Telefon nur mit Quelle und nie bei Heilberuf, auch nicht aus einem Altbestand', async () => {
  const { deps } = await szenario()
  const n = Object.fromEntries((await listBotLeads(deps, {})).items.map((i) => [i.name, i]))
  assert.deepEqual([n['Elektro Beispiel GmbH'].phone, n['Elektro Beispiel GmbH'].phoneSource], ['0631 111', 'https://x.de/i'])
  assert.deepEqual([n['Praxis Dr. Muster'].phone, n['Praxis Dr. Muster'].phoneSource], ['', ''])
  assert.equal(n['Bäckerei Korn'].phone, '')
})
test('Filter room und q, Seiten, id, ungültiger Raum, gekürzte Einträge', async () => {
  const { deps, a, c } = await szenario()
  assert.deepEqual((await listBotLeads(deps, { room: 'laden' })).items.map((i) => i.id), [c.$id])
  assert.deepEqual((await listBotLeads(deps, { q: 'beispiel kaiserslautern' })).items.map((i) => i.id), [a.$id])
  assert.equal((await listBotLeads(deps, { q: 'mainz' })).total, 1)
  const s2 = await listBotLeads(deps, { page: 2 }, { pageSize: 2 })
  assert.deepEqual([s2.total, s2.items.length], [3, 1])
  assert.deepEqual((await listBotLeads(deps, { id: a.$id })).items.map((i) => i.id), [a.$id])
  assert.equal((await listBotLeads(deps, { id: 'gibtsnicht' })).total, 0)
  await assert.rejects(() => listBotLeads(deps, { room: 'netz' }), BadRequest)
  await seedProspect(deps.db, { status: 'botlead', name: '[gekürzt]', purgedAt: '2026-10-20T00:00:00Z', email: '' })
  assert.equal((await listBotLeads(deps, {})).items.find((i) => i.name === '[gekürzt]').gekuerzt, true)
})
test('Route GET /botleads: Antwort 200 mit items und total, ungültiger Raum ist 400', async () => {
  const [route] = BOTLEADS_ROUTES
  assert.deepEqual([route.method, route.pattern.test('/botleads'), route.pattern.test('/botleads/x')], ['GET', true, false])
  const { deps } = await szenario()
  const ok = await route.handler(deps, { query: new URLSearchParams('room=laden&page=1') })
  assert.deepEqual([ok.status, ok.json.total], [200, 1])
  assert.deepEqual(await route.handler(deps, { query: new URLSearchParams('room=netz') }), { status: 400, json: { error: 'Unbekannter Raum.' } })
})
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/p6-botleads-api.test.js ⇒ FAIL, Modul fehlt.
  • Step 3: Implementieren. listBotLeads: room nicht leer und nicht in SCOUT_ROOMS ⇒ BadRequest('Unbekannter Raum.'); page ganzzahlig ≥ 1 sonst 1; q auf 100 Zeichen gekürzt. Mit id: getDoc(deps, 'workersProspects', id), nur wenn der Interessent existiert (null ⇒ leer), entrance 'ohne_website' und status 'botlead', sonst { items: [], total: 0 }. Sonst db.listAll('workersProspects', [Query.equal('entrance', ['ohne_website']), Query.equal('status', ['botlead'])]), im Speicher nach room filtern, q: alle Wörter von normalizeName(q) müssen in normalizeName(name + ' ' + city + ' ' + botLeadReason + ' ' + roomLabel) vorkommen; Sortierung foundAt absteigend, dann score absteigend; total vor dem Seitenschnitt; Seite 1-basiert. gekuerzt = Boolean(purgedAt). BOTLEADS_ROUTES wie unter „Produces“: der Handler liest room, q, page, id aus ctx.query (page als Zahl, leer ⇒ 1), fängt BadRequest und gibt { status: 400, json: { error: e.message } } zurück; die Team-Prüfung (Label mitarbeiter oder admin) übernimmt die Registry (P3-E6). Die Eintragung in buildRoutes() folgt in T24.
  • Step 4: Bestehen sehen. Derselbe Befehl ⇒ # fail 0.
  • Step 5: Commit. git add src/api/botleads.js test/p6-botleads-api.test.js && git commit -m "p6: API für die Bot-Lead-Liste" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P6-T19: WOMS-Reiter „Bot-Leads“

Files (WOMS /home/webklar/apps/ticket.webklar.com/dev-workers, Branch feature/workers):

  • Create: src/lib/botleadsView.js, src/components/workers/BotLeadList.jsx
  • Modify: src/pages/WorkersPage.jsx (P2-E7: WORKERS_TABS um den Reiter botleads ergänzen), src/styles/workers.css (Klassen wk-bl-*); src/lib/workersApi.js bleibt unberührt (P2-T04 reicht id bei botleads schon durch)
  • Test: tests/unit/botleadsView.test.mjs

Interfaces:

  • Consumes: workersApi.botleads(params) → { items, total } (P2-T04: createWorkersApi, Instanz in src/lib/workersClient.js; params = { room, q, page, id }, id reicht P2 schon durch).

  • Produces (botleadsView.js, rein, ohne React): ROOM_FILTERS, safeHref(url) → string, phoneLabel(item) → string, botleadRow(item) → { id, title, place, roomLabel, scoreLabel, reason, phone, sourceHref, found, gekuerzt }, pageInfo({ total, page, pageSize }) → { pages, hasPrev, hasNext, label }; BotLeadList({ api }) (React).

  • Step 1: Test schreiben. Vorher: cd /home/webklar/apps/ticket.webklar.com/dev-workers && ls node_modules >/dev/null 2>&1 || ln -s ../build/node_modules node_modules (zeigt als ?? node_modules, nie committen). Festgelegt von P2-E7: WorkersPage.jsx hält WORKERS_TABS (Ids hochhaus, freigabe von P3, botleads von P6) und die Instanz const api = workersApi; der Reiter wird über ?tab=botleads gewählt. Zeiger: Das Durchreichen von id (api.botleads({ id: 'p1' }) ⇒ GET /api/workers/botleads?id=p1) liefert und testet schon P2-T04 (workersApi.js, tests/unit/workersApi.test.mjs); P6 reicht dort nichts nach.

import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import { loadModule } from './load.mjs'

const { safeHref, phoneLabel, botleadRow, ROOM_FILTERS, pageInfo } = await loadModule('src/lib/botleadsView.js')

test('safeHref: nur http und https', () => {
  assert.equal(safeHref('https://beispiel-elektro.de/impressum'), 'https://beispiel-elektro.de/impressum')
  for (const bose of ['javascript:alert(1)', '//evil.example', 'data:text/html,x', '']) assert.equal(safeHref(bose), '', bose)
})
test('phoneLabel: Nummer nur mit Quelle, Heilberuf nie', () => {
  assert.equal(phoneLabel({ phone: '0631 111', phoneSource: 'https://x.de/i', isHealth: false }), '0631 111')
  assert.equal(phoneLabel({ phone: '0631 111', phoneSource: '', isHealth: false }), 'nicht verfügbar')
  assert.equal(phoneLabel({ phone: '', phoneSource: '', isHealth: true }), 'bewusst nicht gespeichert')
  assert.equal(phoneLabel({ phone: '0631 111', phoneSource: 'https://x.de/i', isHealth: true }), 'bewusst nicht gespeichert')
})
test('botleadRow, Raumfilter und Seitenzeile', () => {
  const r = botleadRow({ id: 'a', room: 'handwerk', roomLabel: 'Handwerk', name: 'Elektro Beispiel GmbH', postalCode: '67655', city: 'Kaiserslautern', score: 82, reason: 'keine veröffentlichte Mail', sourceUrl: 'javascript:x', phone: '', phoneSource: '', isHealth: false, foundAt: '2026-10-19T20:00:00Z', gekuerzt: false })
  assert.deepEqual([r.title, r.place, r.scoreLabel, r.sourceHref, r.phone, r.found], ['Elektro Beispiel GmbH', '67655 Kaiserslautern', '82 Punkte', '', 'nicht verfügbar', '19.10.2026'])
  assert.deepEqual(ROOM_FILTERS.map((x) => x.value), ['', 'aerzte', 'handwerk', 'selbst', 'laden'])
  assert.deepEqual(pageInfo({ total: 120, page: 2, pageSize: 50 }), { pages: 3, hasPrev: true, hasNext: true, label: 'Seite 2 von 3 · 120 Einträge' })
  assert.equal(pageInfo({ total: 0, page: 1, pageSize: 50 }).label, 'Keine Einträge')
})
test('BotLeadList.jsx: Links nur über safeHref, kein HTML-Einschub', () => {
  const src = fs.readFileSync(new URL('../../src/components/workers/BotLeadList.jsx', import.meta.url), 'utf8')
  assert.ok(src.includes('safeHref') && !src.includes('dangerouslySetInnerHTML'))
})
  • Step 2: Fehlschlag sehen. timeout 600 node --max-old-space-size=1024 --test tests/unit/botleadsView.test.mjs ⇒ FAIL, Cannot find module bzw. Datei BotLeadList.jsx fehlt.
  • Step 3: Implementieren.
    • botleadsView.js: ROOM_FILTERS = [{ value: '', label: 'Alle Räume' }, { value: 'aerzte', label: 'Ärzte & Praxen' }, { value: 'handwerk', label: 'Handwerk' }, { value: 'selbst', label: 'Selbstständige' }, { value: 'laden', label: 'Läden & Gastro' }]; safeHref: new URL(url) in try, nur Protokoll http: oder https: ⇒ href, sonst ''; phoneLabel: isHealth ⇒ „bewusst nicht gespeichert“, sonst Nummer wenn phone und phoneSource gesetzt, sonst „nicht verfügbar“; botleadRow: title = name || 'nicht verfügbar', place = [postalCode, city].filter(Boolean).join(' '), scoreLabel = '<n> Punkte', found = Datum TT.MM.JJJJ in Europe/Berlin; pageInfo: pages = max(1, ceil(total / pageSize)), Label Seite <p> von <n> · <total> Einträge, bei total 0 „Keine Einträge“.
    • BotLeadList.jsx (WOMS-Weißdesign, Klassen aus global.css: .toolbar, .select, .input, .table, .empty, .msg.err, .btn): Kopfzeile mit Hinweis „Reine Liste: keine Anrufe, kein Mailversand.“, Raumfilter (<select> aus ROOM_FILTERS), Suchfeld (300 ms Verzögerung, Seite springt auf 1), Tabelle mit den Spalten Betrieb (Titel, darunter Ort), Raum, Punkte, Grund, Telefon, Quelle (Link „Quelle öffnen“ nur wenn sourceHref nicht leer, target="_blank" rel="noopener noreferrer"), Gefunden; gekürzte Einträge grau mit „gekürzt“; Leerzustand „Noch keine Bot-Leads.“; Fehler als .msg.err mit dem Satz aus der API; Ladeanfragen mit Laufnummer (nur die neueste zählt); Parameter bot in der Adresszeile hebt die Zeile hervor und ruft die API mit id ab; Seitenwechsel „Zurück“/„Weiter“ mit pageInfo. Kein dangerouslySetInnerHTML, kein Polling.
    • workersApi.js: nichts zu tun, botleads({ room, q, page, id }) kennt id seit P2-T04 (Query-Reihenfolge room, q, page, id).
    • WorkersPage.jsx: WORKERS_TABS um { id: 'botleads', label: 'Bot-Leads' } ergänzen (nach freigabe), im Inhalt dieses Reiters <BotLeadList api={api} /> rendern; workers.css: Klassen mit Präfix wk-bl- (Tabelle scrollt auf dem Handy waagerecht innerhalb der Tabelle, die Seite nicht).
  • Step 4: Bestehen sehen. timeout 600 node --max-old-space-size=1024 --test tests/unit/botleadsView.test.mjs tests/unit/workersApi.test.mjs ⇒ # fail 0 (die zweite Datei ist P2s unveränderter Test und sichert, dass id durchgereicht wird). Dann Bau-Prüfung: systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 env NODE_OPTIONS=--max-old-space-size=1200 timeout 600 node node_modules/vite/bin/vite.js build --outDir "${TMPDIR:-/tmp}/p6-woms-dist" ⇒ Zeile built in, keine Fehler.
  • Step 5: Commit. git add src/lib/botleadsView.js src/components/workers/BotLeadList.jsx src/pages/WorkersPage.jsx src/styles/workers.css tests/unit/botleadsView.test.mjs && git commit -m "workers: Reiter Bot-Leads mit Filter, Grund, Telefon und Quelle" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P6-T20: Datenschutz-Abschnitt „Interessenten“ in der Datenschutzerklärung (Portal)

Die Datenschutzerklärung liegt zentral im Portal (public/datenschutz.html, live https://project.webklar.com/datenschutz.html); webklar.com/datenschutz leitet nur dorthin weiter (T22). Abschnitt 11 „Interessenten“ steht dort schon, mit anderem Inhalt (zwölf Monate, keine KI, keine Quellen); er wird ersetzt. Die Überschriftenliste 1–15 ist in test/legalTexts.test.js festgenagelt, deshalb bekommt Abschnitt 11 den Anker als eigene Zeile vor der Überschrift.

Files (Portal-Worktree /home/webklar/apps/kundenbereich.webklar.com/dev-workers-datenschutz):

  • Modify: public/datenschutz.html (Stand, Abschnitte 2, 11, 12, 14), docs/WEBklar-Datenschutzerklaerung.pdf, docs/WEBklar-Datenschutzerklaerung.pdf.sha256, test/legalTexts.test.js

Interfaces:

  • Produces: Anker #interessenten auf https://project.webklar.com/datenschutz.html; die Zusagen 90 Tage, 30 Tage, sechs Monate, höchstens zwei E-Mails je Betrieb, höchstens drei Betriebe pro Tag, keine Telefonnummern bei Heilberufen sind die Fristen und Grenzen aus P6-T17, T12 und settings (Übersicht §4.1); Änderungen an einer Seite verlangen die Änderung der anderen.

  • Step 0: Freigabe durch Kenso (Gate, ohne sie kein Weiterarbeiten an diesem Task). Kenso liest den HTML-Text in Step 3 und entscheidet sechs Punkte: Z1 Mail-Anbieter der Absenderdomain (Variante G = Google Workspace oder Variante M = Migadu; es bleibt genau eine); Z2 Anthropic-Angaben (Vertragspartner Anthropic Ireland, Limited, Auftragsverarbeitung, Standardvertragsklauseln) gegen die aktuellen Anthropic-Bedingungen prüfen und nur belassen, wenn sie dort stehen; Z3 die Fristen 90 Tage, 30 Tage, sechs Monate sind Zusagen, die der Code einhält (T17); Z4 Betriebe, die nie angeschrieben werden (Bot-Leads), informieren wir nur durch diesen öffentlichen Text; ob das nach Art. 14 Abs. 5 lit. b DSGVO genügt, ist eine Kanzleifrage; Z5 nach der Kürzung verschwinden auch der Text unserer Nachrichten und die Quelle der Adresse bei Betrieben, die nie geantwortet haben (kein Nachweis mehr für spätere Abmahnungen; der Sendezeitpunkt bleibt ohne Personenbezug erhalten; Alternative wäre eine längere Aufbewahrung des Nachweises, dann müssen Text und T17 geändert werden); Z6 P3s Erstantworten auf öffentliche Anfragen (Etage „Anfragen“) und P4s Mails verlinken denselben Anker #interessenten; deshalb enthält der Abschnitt auch einen kurzen Absatz zu Anfragen (Quelle ist die Anfrage selbst). Soll dieser Absatz stattdessen in einen eigenen Abschnitt, entscheidet Kenso das hier, und P3 bekommt einen anderen Anker. Antwort „freigegeben“ plus Z1 ⇒ weiter; Änderungswünsche zuerst in diesem Plan und im Text einarbeiten.

  • Step 1: Worktree anlegen. cd /home/webklar/apps/kundenbereich.webklar.com/build && git fetch origin && git worktree add ../dev-workers-datenschutz -b feature/workers-datenschutz origin/main && cd ../dev-workers-datenschutz && ln -s ../build/node_modules node_modules (zeigt als ?? node_modules, nie committen). Hinweis: P5 ändert unter Umständen denselben Abschnitt 7; die Absätze sind getrennt, ein Konflikt ist trivial zu lösen.

  • Step 2: Fehlschlagende Tests schreiben (ans Ende von test/legalTexts.test.js, dessen Hilfen read, strip, between und die Konstante DS schon da sind).

test('legal_datenschutz_interessenten: Abschnitt 11 mit Anker, Quelle, Zweck, Rechtsgrundlage, Fristen und Widerspruch', () => {
  const html = read(DS)
  assert.match(html, /<a id="interessenten"><\/a>\s*<h2>11\. Interessenten<\/h2>/)
  const s = between(strip(html), '11. Interessenten', '12. Empfänger im Überblick')
  for (const t of ['höchstens zwei E-Mails', 'höchstens drei Betriebe pro Tag', 'KI-Assistent im Auftrag von WEBklar', 'Art. 14 DSGVO', 'Place-ID', 'öffentlich nach einer Website gefragt',
    'ausschließlich dem Impressum oder der Kontaktseite Ihrer eigenen Website', 'bei Heilberufen nie', 'Art. 6 Abs. 1 lit. f DSGVO', 'teilweise automatisiert',
    'Anthropic Ireland, Limited', 'Standardvertragsklauseln (Art. 46 Abs. 2 lit. c DSGVO)', 'spätestens 90 Tage nach dem Fund', 'innerhalb von 30 Tagen',
    'sechs Monate nach der letzten Nachricht', 'Hash-Werte', 'Ihr Widerspruchsrecht gegen diese Werbung (Art. 21 DSGVO)', 'support@webklar.com']) assert.ok(s.includes(t), t)
  assert.ok(!s.includes('zwölf Monaten'))
  assert.equal(Number(s.includes('Google Workspace')) + Number(s.includes('Migadu')), 1, 'genau ein Mail-Anbieter')
  assert.ok(!/\{|TODO|XXX/.test(s))
})
test('legal_datenschutz_interessenten: Geltungsbereich, Empfänger und Art. 22 passen zu Abschnitt 11', () => {
  const ds = strip(read(DS))
  assert.ok(between(ds, '2. Geltungsbereich', '3. Hosting').includes('(Abschnitt 11)'))
  const emp = between(ds, '12. Empfänger im Überblick', '13. Ihre Rechte')
  assert.ok(emp.includes('Anthropic Ireland, Limited') && (emp.includes('Google Ireland Limited') || emp.includes('Migadu GmbH')))
  const s14 = between(ds, '14. Pflicht zur Bereitstellung', '15. Änderungen')
  assert.ok(s14.includes('Die automatisierte Bewertung von Interessenten beschreibt Abschnitt 11.') && !s14.includes('finden nicht statt'))
  const stand = /Stand (\d{2})\.(\d{2})\.(\d{4})/.exec(ds)
  assert.ok(`${stand[3]}-${stand[2]}-${stand[1]}` >= '2026-10-17', 'Stand nicht vor dem 17.10.2026')
})
  • Step 3: Fehlschlag sehen, dann Text einarbeiten. cd /home/webklar/apps/kundenbereich.webklar.com/dev-workers-datenschutz && timeout 900 node --max-old-space-size=1024 --test test/legalTexts.test.js ⇒ nur die beiden neuen Tests schlagen fehl (Anker fehlt). Dann in public/datenschutz.html (exakter Wortlaut; Stand = Freigabedatum, dasselbe Datum wie später --freigabe):
    • <p class="meta">Stand 07.10.2026</p> ⇒ <p class="meta">Stand TT.MM.JJJJ</p>.
    • Abschnitt 2, erster Absatz endet mit „… (Hosting-Abo und Wartungs-Abo).“ ⇒ danach im selben Absatz anfügen: Sie gilt auch, wenn wir Unternehmen als mögliche Kunden ansprechen (Abschnitt 11).
    • Abschnitt 11 ersetzen (alter Absatz entfällt):
  <a id="interessenten"></a>
  <h2>11. Interessenten</h2>
  <p>Wir suchen Betriebe, die voraussichtlich eine neue oder überarbeitete Website brauchen, und schreiben einzelne davon an, um ihnen eine unverbindliche Gratis-Vorschau einer Website anzubieten. Pro Betrieb senden wir höchstens zwei E-Mails: ein Angebot und, wenn keine Antwort kommt, eine Erinnerung nach etwa sieben Tagen. Neu anschreiben wir höchstens drei Betriebe pro Tag. Die Nachrichten schreibt und versendet ein KI-Assistent im Auftrag von WEBklar; darauf weisen wir in jeder Nachricht hin. Antworten sortiert ebenfalls ein KI-Assistent; Beschwerden, rechtliche Anliegen und den Wunsch nach einem Menschen bearbeitet ein Mitarbeiter.</p>
  <p>Weil wir Ihre Daten nicht bei Ihnen erhoben haben, informieren wir Sie hier und in unserer ersten Nachricht darüber (Art. 14 DSGVO). Wir verarbeiten öffentlich zugängliche Geschäftsdaten: Firmenname, Ansprechpartner, Anschrift, geschäftliche E-Mail-Adresse und Telefonnummer, die Adresse der Website, die Kennung des Karteneintrags (Place-ID), eine von uns vergebene Punktzahl mit Kurzbegründung sowie den Verlauf der Kontaktaufnahme (Text und Zeitpunkt unserer Nachrichten und die Quelle Ihrer E-Mail-Adresse).</p>
  <p>Die Daten stammen von der Website des Betriebs, vor allem aus dem Impressum und der Kontaktseite, aus OpenStreetMap und aus dem Kartendienst Google Maps; von dort speichern wir nur die Kennung des Eintrags. Die E-Mail-Adresse, an die wir schreiben, entnehmen wir ausschließlich dem Impressum oder der Kontaktseite Ihrer eigenen Website. Wir erraten keine Adressen und lesen keine Profile in sozialen Netzwerken aus. Telefonnummern speichern wir nur, wenn sie öffentlich genannt sind, und bei Heilberufen nie.</p>
  <p>Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse ist die Gewinnung neuer Kunden. Wir verarbeiten nur öffentlich bereitgestellte Geschäftsdaten in geringem Umfang, und Sie können der Verarbeitung jederzeit widersprechen.</p>
  <p>Welche Betriebe wir anschreiben, entscheiden wir teilweise automatisiert: Ein Programm bewertet öffentlich sichtbare Merkmale, etwa ob der Betrieb eine Website hat und wie aktuell sie wirkt, und legt danach die Reihenfolge fest. Die Bewertung hat für Sie keine rechtliche Wirkung; sie entscheidet nur, ob und wann wir Ihnen die beschriebenen Nachrichten schicken.</p>
  <p>Haben Sie selbst öffentlich nach einer Website gefragt, etwa in einem Forum oder auf einem Kleinanzeigen- oder Projektportal, antworten wir Ihnen einmal auf diese Anfrage, auf dem Weg, den Sie dort gewählt haben. Dafür verarbeiten wir den Inhalt Ihrer Anfrage mit Link und Zeitpunkt sowie den Namen und die Kontaktmöglichkeit, die Sie dort selbst genannt haben. Quelle ist Ihre Anfrage. Rechtsgrundlage ist ebenfalls Art. 6 Abs. 1 lit. f DSGVO (unser Interesse, Ihre Anfrage zu beantworten); auch hier können Sie jederzeit widersprechen, und die Fristen unten gelten entsprechend.</p>
  <p>Wir speichern die Daten auf unseren Servern bei der Hetzner Online GmbH in Deutschland. Zum Lesen und Schreiben der Nachrichten nutzen wir das Sprachmodell von Anthropic über die Anthropic Ireland, Limited, Dublin, Irland, als Auftragsverarbeiter; dabei können Daten in die USA übermittelt werden, abgesichert durch Standardvertragsklauseln (Art. 46 Abs. 2 lit. c DSGVO). Die E-Mails versenden wir über {Z1-SATZ}.</p>
  <p>Haben Sie nicht geantwortet oder haben wir Sie nicht angeschrieben, löschen wir Ihre Daten spätestens 90 Tage nach dem Fund, einschließlich unserer Nachrichten an Sie. Antworten Sie, dass kein Interesse besteht, oder widersprechen Sie, löschen wir Ihre Daten innerhalb von 30 Tagen. Antworten Sie und kommt kein Vertrag zustande, löschen wir die Unterhaltung spätestens sechs Monate nach der letzten Nachricht; kommt ein Vertrag zustande, gelten die Abschnitte 7 und 9. In allen Fällen bleiben nur nicht umkehrbare Prüfwerte (Hash-Werte) Ihrer E-Mail-Adresse, Ihrer Domain und Ihrer Telefonnummer, damit wir Sie nicht noch einmal recherchieren oder anschreiben.</p>
  <div class="box widerspruch">
    <strong>Ihr Widerspruchsrecht gegen diese Werbung (Art. 21 DSGVO)</strong>
    <p>Sie können der Verarbeitung Ihrer Daten zu Werbezwecken jederzeit ohne Angabe von Gründen widersprechen. Antworten Sie dazu auf unsere Nachricht, nutzen Sie den Abmelde-Link in der Nachricht oder schreiben Sie an support@webklar.com. Wir schreiben Ihnen dann nicht mehr und löschen Ihre Daten bis auf die genannten Prüfwerte. Weitere Rechte (Art. 15 bis 20 DSGVO) und das Beschwerderecht beschreibt Abschnitt 13.</p>
  </div>

{Z1-SATZ} ersetzen durch genau eine Variante: G den E-Mail-Dienst Google Workspace der Google Ireland Limited, Irland (Auftragsverarbeiter) oder M den E-Mail-Dienst der Migadu GmbH, Schweiz (Auftragsverarbeiter; für die Schweiz besteht ein Angemessenheitsbeschluss der EU-Kommission).

  • Abschnitt 12: nach dem Stripe-Eintrag zwei Listenpunkte einfügen: <li>Anthropic Ireland, Limited: Lesen und Schreiben von Nachrichten an Interessenten durch ein Sprachmodell (Auftragsverarbeiter, Abschnitt 11)</li> und <li><Google Ireland Limited | Migadu GmbH>: Versand und Empfang der E-Mails an Interessenten (Auftragsverarbeiter, Abschnitt 11)</li> (passend zu Z1).
  • Abschnitt 14: den letzten Satz „Automatisierte Entscheidungen einschließlich Profiling im Sinne von Art. 22 DSGVO finden nicht statt.“ ersetzen durch Entscheidungen, die Ihnen gegenüber rechtliche Wirkung entfalten oder Sie in ähnlicher Weise erheblich beeinträchtigen (Art. 22 DSGVO), treffen wir nicht automatisiert. Die automatisierte Bewertung von Interessenten beschreibt Abschnitt 11.
  • Step 4: PDF neu drucken und alle Rechtstexttests laufen lassen. free -m; dann cd /home/webklar/apps/kundenbereich.webklar.com/dev-workers-datenschutz && systemd-run --scope -q -p MemoryMax=700M -p MemorySwapMax=0 nice scripts/build-legal-pdfs.sh datenschutz ⇒ Zeile -> docs/WEBklar-Datenschutzerklaerung.pdf, neue .sha256. Dann timeout 900 node --max-old-space-size=1024 --test test/legalTexts.test.js ⇒ # fail 0 (inklusive pdf_pruefsumme und datenschutz_pdf).
  • Step 5: Commit. git add public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf docs/WEBklar-Datenschutzerklaerung.pdf.sha256 test/legalTexts.test.js && git commit -m "Datenschutz: Abschnitt 11 Interessenten (Quelle, Zweck, KI, Fristen, Widerspruch), Anker #interessenten" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P6-T21: Datenschutzerklärung als neue Version in WOMS „Rechtliches“ hochladen (Skript, Portal)

Files (Portal-Worktree wie T20):

  • Create: scripts/datenschutz-hochladen.mjs
  • Modify: scripts/rechtliches-hochladen.mjs (nur export vor vorhandenen Hilfen, kein neues Verhalten)
  • Test: test/datenschutzHochladen.test.js

Interfaces:

  • Consumes aus rechtliches-hochladen.mjs (neu exportiert): client(fetchFn, schluessel), alleDokumente(api, sammlung), versionen(api, documentKey), neueId(), ddmmjjjj(iso), planVersion({ alle, source, validFrom }), BUCKET, DB, KEY_DATEI, ROOT.

  • Produces: parseArgs(argv) → { modus: 'probe'|'hochladen', freigabe: string|null }; main(argv, deps) → Promise<0|1> mit deps = { fetch, schluessel, log, git, dateien } wie das Vorbild. Karte datenschutz; Dateien docs/WEBklar-Datenschutzerklaerung.pdf und public/datenschutz.html; source = kundenbereich@<Commit>:public/datenschutz.html; changeNote = 'Fassung vom <TT.MM.JJJJ>: Abschnitt 11 Interessenten (Ansprache per E-Mail)' (steht in WOMS „Rechtliches“ und kann für Kunden sichtbar werden: darum weder „Workers“ noch „Hustler“). Der Aufruf dokumentFuer('datenschutz') des Vorbilds bleibt unverändert ein Fehler (bestehender Test).

  • Step 1: Test schreiben.

import { test } from 'node:test'
import assert from 'node:assert/strict'
const m = await import('../scripts/datenschutz-hochladen.mjs') // der Import liest weder Schlüssel noch Netz

const frueher = ['v1', 'v2', 'v3', 'v4', 'v5'].map((v, i) => ({ $id: `s${i}`, version: v, validFrom: `2026-09-0${i + 1}`, status: 'superseded', source: `x${i}` }))
const AKTUELL = { $id: 'alt', version: 'v6', validFrom: '2026-10-05', status: 'current', source: 'kundenbereich@aaaaaaa:public/datenschutz.html' }
function backend({ versionen = [...frueher, AKTUELL], karten = [{ $id: 'datenschutz', key: 'datenschutz' }], gitStatus = '' } = {}) {
  const aufrufe = [], log = []
  const antwort = (obj) => ({ ok: true, status: 200, text: async () => JSON.stringify(obj) })
  const fetch = async (url, opt = {}) => {
    const methode = opt.method ?? 'GET', pfad = new URL(url).pathname.replace(/^\/v1/, '')
    aufrufe.push({ methode, pfad, body: opt.body })
    if (methode === 'GET' && pfad.endsWith('/collections/legalDocuments/documents')) return antwort({ total: karten.length, documents: karten })
    if (methode === 'GET' && pfad.endsWith('/collections/legalVersions/documents')) return antwort({ total: versionen.length, documents: versionen })
    if (methode === 'GET' && pfad.startsWith('/storage/buckets/legal-files/files')) return antwort({ total: 0, files: [] })
    if (methode === 'POST' && pfad === '/storage/buckets/legal-files/files') return antwort({ $id: `f${aufrufe.length}` })
    if (methode === 'POST' && pfad.endsWith('/collections/legalVersions/documents')) return antwort({ $id: 'v-neu' })
    if (methode === 'PATCH' && pfad.includes('/collections/legalVersions/documents/')) return antwort({})
    throw new Error(`unerwartet: ${methode} ${pfad}`)
  }
  return { aufrufe, log, deps: { fetch, schluessel: () => 'test-schluessel', log: (z) => log.push(z), git: (args) => (args[0] === 'status' ? gitStatus : 'abc1234\n'), dateien: { existsSync: () => true, readFileSync: () => Buffer.from('x') } } }
}
const schreibend = (b) => b.aufrufe.filter((a) => a.methode !== 'GET')

test('parseArgs: --probe allein oder --freigabe=Tag, sonst Aufruf-Fehler', () => {
  assert.deepEqual(m.parseArgs(['--probe']), { modus: 'probe', freigabe: null })
  assert.deepEqual(m.parseArgs(['--freigabe=2026-10-20']), { modus: 'hochladen', freigabe: '2026-10-20' })
  for (const bad of [[], ['--freigabe=20.10.2026'], ['--freigabe=2026-13-40'], ['--probe', '--freigabe=2026-10-20'], ['datenschutz']]) assert.throws(() => m.parseArgs(bad), /Aufruf|Freigabe/)
})
test('Hochladen: Dateien zuerst (PDF, dann HTML), dann Version v7, dann die bisherige ablösen', async () => {
  const b = backend()
  assert.equal(await m.main(['--freigabe=2026-10-20'], b.deps), 0)
  assert.deepEqual(schreibend(b).map((a) => `${a.methode} ${a.pfad.split('/').slice(-1)[0]}`), ['POST files', 'POST files', 'POST documents', 'PATCH alt'])
  const neu = JSON.parse(schreibend(b)[2].body).data
  assert.deepEqual([neu.documentKey, neu.version, neu.validFrom, neu.status, neu.source, neu.fileIds.length], ['datenschutz', 'v7', '2026-10-20', 'current', 'kundenbereich@abc1234:public/datenschutz.html', 2])
  assert.match(neu.changeNote, /^Fassung vom 20\.10\.2026: Abschnitt 11 Interessenten/)
  assert.deepEqual(JSON.parse(schreibend(b)[3].body).data, { status: 'superseded', validTo: '2026-10-19' })
})
test('schon hochgeladen, fehlende Karte, nicht eingecheckter Stand und --probe schreiben nichts', async () => {
  const schon = backend({ versionen: [{ ...AKTUELL, source: 'kundenbereich@abc1234:public/datenschutz.html' }] })
  assert.equal(await m.main(['--freigabe=2026-10-20'], schon.deps), 0)
  const ohneKarte = backend({ karten: [] })
  assert.equal(await m.main(['--freigabe=2026-10-20'], ohneKarte.deps), 1)
  const schmutzig = backend({ gitStatus: ' M public/datenschutz.html\n' })
  assert.equal(await m.main(['--freigabe=2026-10-20'], schmutzig.deps), 1)
  const probe = backend()
  assert.equal(await m.main(['--probe'], probe.deps), 0)
  for (const b of [schon, ohneKarte, schmutzig, probe]) assert.equal(schreibend(b).length, 0)
})
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/kundenbereich.webklar.com/dev-workers-datenschutz && timeout 900 node --max-old-space-size=1024 --test test/datenschutzHochladen.test.js ⇒ FAIL, Cannot find module '../scripts/datenschutz-hochladen.mjs'.
  • Step 3: Implementieren. In rechtliches-hochladen.mjs nur export ergänzen vor BUCKET, DB, KEY_DATEI, ROOT, client, alleDokumente, versionen, neueId, ddmmjjjj (kein Verhalten ändern; möglicher Merge-Konflikt mit feature/kundenansicht-neu bleibt auf diese Zeilen begrenzt). datenschutz-hochladen.mjs nach dem Muster der Funktion hochladen des Vorbilds: Schlüssel aus deps.schluessel (Standard Datei KEY_DATEI), Karte datenschutz muss existieren (sonst Meldung und Exit 1 vor jedem Schreiben), Dateien müssen existieren, git status --porcelain -- <beide Dateien> muss leer sein, commit per git rev-parse --short HEAD, planVersion ⇒ schon ⇒ Exit 0 ohne Schreiben; Reihenfolge: erst beide Dateien in den Bucket legal-files (PDF, dann HTML), dann Versionsdokument (version aus planVersion, validFrom = Freigabe, validTo null, status 'current', fileIds, source, changeNote), zuletzt die bisherige current-Version auf superseded mit validTo = planVersion.validToAltes. --probe liest nur (Karte, Version-Liste, Bucket) und gibt aus. Der Einstieg am Dateiende wie im Vorbild (process.argv[1]-Prüfung).
  • Step 4: Bestehen sehen. Derselbe Befehl ⇒ # fail 0; timeout 900 node --max-old-space-size=1024 --test test/rechtlichesHochladen.test.js ⇒ weiter grün.
  • Step 5: Commit. git add scripts/datenschutz-hochladen.mjs scripts/rechtliches-hochladen.mjs test/datenschutzHochladen.test.js && git commit -m "Rechtliches: Skript zum Hochladen der Datenschutzerklärung als neue Version" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P6-T22: webklar.com leitet /datenschutz#interessenten mit Anker weiter

Die Kaltmail verlinkt https://www.webklar.com/datenschutz#interessenten. Die Seite src/pages/Datenschutz.tsx leitet per window.location.replace auf https://project.webklar.com/datenschutz.html weiter und verliert dabei den Anker.

Files (Website-Worktree /home/webklar/apps/webklar.com/dev-workers-ds, Branch fix/datenschutz-anker):

  • Create: src/lib/datenschutzZiel.ts
  • Modify: src/pages/Datenschutz.tsx
  • Test: src/test/datenschutz-weiterleitung.test.tsx

Interfaces:

  • Produces: weiterleitungsZiel(hash: string): string (Basis https://project.webklar.com/datenschutz.html, Anker nur wenn er ^#[A-Za-z0-9_-]{1,64}$ erfüllt).

  • Step 1: Worktree und Test. cd /home/webklar/apps/webklar.com/build && git fetch origin && git worktree add ../dev-workers-ds -b fix/datenschutz-anker origin/main && cd ../dev-workers-ds && ln -s ../build/node_modules node_modules. Test:

import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import Datenschutz from "@/pages/Datenschutz";
import { weiterleitungsZiel } from "@/lib/datenschutzZiel";

const BASIS = "https://project.webklar.com/datenschutz.html";

describe("Weiterleitung /datenschutz", () => {
  it("nimmt einen gültigen Anker mit", () => {
    expect(weiterleitungsZiel("#interessenten")).toBe(`${BASIS}#interessenten`);
    expect(weiterleitungsZiel("#chat-anhaenge")).toBe(`${BASIS}#chat-anhaenge`);
  });
  it("ohne Anker oder mit ungültigem Anker bleibt es bei der Seite", () => {
    for (const hash of ["", "#", "#javascript:alert(1)", '#a"b', "#a b", `#${"a".repeat(65)}`]) {
      expect(weiterleitungsZiel(hash)).toBe(BASIS);
    }
  });
  it("der Link im Text trägt denselben Anker", () => {
    vi.spyOn(console, "error").mockImplementation(() => {}); // jsdom meldet die nicht umgesetzte Navigation
    window.history.pushState({}, "", "/datenschutz#interessenten");
    render(<Datenschutz />);
    expect(screen.getByRole("link", { name: "Datenschutzerklärung" })).toHaveAttribute("href", `${BASIS}#interessenten`);
  });
});
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/webklar.com/dev-workers-ds && timeout 600 node --max-old-space-size=1024 node_modules/vitest/vitest.mjs run src/test/datenschutz-weiterleitung.test.tsx ⇒ FAIL (Failed to resolve import "@/lib/datenschutzZiel").
  • Step 3: Implementieren. src/lib/datenschutzZiel.ts: const BASIS = "https://project.webklar.com/datenschutz.html"; export const weiterleitungsZiel = (hash: string): string => (/^#[A-Za-z0-9_-]{1,64}$/.test(hash) ? BASIS + hash : BASIS);. Datenschutz.tsx: const ziel = weiterleitungsZiel(window.location.hash), useEffect(() => { window.location.replace(ziel) }, [ziel]), der sichtbare Link nutzt ziel; Kommentar zur zentralen Ablage im Portal bleibt.
  • Step 4: Bestehen sehen. Derselbe Befehl ⇒ 3 Tests grün; dann timeout 600 node --max-old-space-size=1024 node_modules/vitest/vitest.mjs run ⇒ keine neuen Fehler; Bau-Prüfung systemd-run --scope -q -p MemoryMax=1500M -p MemorySwapMax=0 env NODE_OPTIONS=--max-old-space-size=1200 timeout 600 node node_modules/vite/bin/vite.js build --outDir "${TMPDIR:-/tmp}/p6-site-dist" ⇒ built in.
  • Step 5: Commit. git add src/lib/datenschutzZiel.ts src/pages/Datenschutz.tsx src/test/datenschutz-weiterleitung.test.tsx && git commit -m "Datenschutz-Weiterleitung nimmt den Anker mit" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P6-T23: Freischalt-Skript (Datenschutz-Schalter, Räume) und manuelle Live-Probe

Files:

  • Create: scripts/p6-freischalten.mjs, scripts/probe-scout.mjs
  • Test: test/p6-freischalten.test.js

Interfaces:

  • Consumes: getSettings, updateSettings, SCOUT_ROOMS, loadConfig, createDb; für die Probe createPlacesClient, readOwnSite, erweitereDeps (T24); die Modellprüfung (evaluate(models), Haiku-Websuche) gehört zu P1/P3 und nicht in diese Probe.

  • Produces (p6-freischalten.mjs): pruefeDatenschutzSeite(html) → { ok: boolean, fehlt: string[] }; main(argv, deps) → Promise<0|1> mit deps = { db, clock, log, config, fetchImpl? } und den Aufrufen datenschutz --check | --set | --off (A5: derselbe Schalter privacyInteressentenLive wie P3s datenschutz-schalter.mjs, --set erst nach der Seitenprüfung) sowie raeume --set <a,b> | --clear (die Branchen-Räume setzt Kenso hiermit, P3-E7; P3s syncConnectedRooms lässt sie unangetastet). Produces (probe-scout.mjs): parseArgs(argv) → { room, query, max }, main(argv, deps) mit deps = { places, fetch, dns, clock, sleep?, log }.

  • Step 1: Test schreiben.

import test from 'node:test'
import assert from 'node:assert/strict'
import { pruefeDatenschutzSeite, main } from '../scripts/p6-freischalten.mjs'
import { parseArgs as probeArgs, main as probeMain } from '../scripts/probe-scout.mjs'
import { getSettings } from '../src/settings.js'
import { makeDeps, siteFetch, betriebSeiten } from './support/p6.js'

const PFLICHT = ['Art. 6 Abs. 1 lit. f DSGVO', 'spätestens 90 Tage nach dem Fund', 'innerhalb von 30 Tagen', 'sechs Monate nach der letzten Nachricht', 'Anthropic Ireland, Limited', 'Ihr Widerspruchsrecht gegen diese Werbung (Art. 21 DSGVO)']
const SEITE = `<h2>10. Domains</h2><a id="interessenten"></a><h2>11. Interessenten</h2><p>${PFLICHT.join(' … ')}</p>`
const seite = (text) => async () => ({ ok: true, status: 200, text: async () => text })

test('pruefeDatenschutzSeite: Anker und Pflichtsätze müssen live stehen', () => {
  assert.deepEqual(pruefeDatenschutzSeite(SEITE), { ok: true, fehlt: [] })
  assert.deepEqual(pruefeDatenschutzSeite(SEITE.replace('<a id="interessenten"></a>', '')).fehlt, ['Anker #interessenten'])
  assert.deepEqual(pruefeDatenschutzSeite(SEITE.replace('90 Tage', '12 Monate')).fehlt, ['spätestens 90 Tage nach dem Fund'])
})
test('R3: datenschutz --set schaltet nur bei bestandener Prüfung, --check schreibt nie, --off schaltet aus', async () => {
  const deps = makeDeps({ settings: { privacyInteressentenLive: false } })
  assert.equal(await main(['datenschutz', '--set'], { ...deps, fetchImpl: seite('leer') }), 1)
  assert.equal(await main(['datenschutz', '--check'], { ...deps, fetchImpl: seite(SEITE) }), 0)
  assert.equal((await getSettings(deps)).privacyInteressentenLive, false)
  assert.equal(await main(['datenschutz', '--set'], { ...deps, fetchImpl: seite(SEITE) }), 0)
  assert.equal((await getSettings(deps)).privacyInteressentenLive, true)
  assert.equal(await main(['datenschutz', '--off'], deps), 0)
  assert.equal((await getSettings(deps)).privacyInteressentenLive, false)
})
test('raeume --set verbindet nur bekannte Scout-Räume bei vorhandenem Places-Schlüssel und ergänzt statt zu ersetzen', async () => {
  const deps = makeDeps({ settings: { connectedRooms: ['netz'] }, config: { places: { apiKey: 'test' } } })
  assert.equal(await main(['raeume', '--set', 'handwerk,xyz'], deps), 1)
  assert.equal(await main(['raeume', '--set', 'handwerk'], deps), 0)
  assert.deepEqual([...(await getSettings(deps)).connectedRooms].sort(), ['handwerk', 'netz'])
  assert.equal(await main(['raeume', '--clear'], deps), 0)
  assert.deepEqual((await getSettings(deps)).connectedRooms, ['netz'])
  const ohneSchluessel = makeDeps({ config: { places: { apiKey: '' } } })
  assert.equal(await main(['raeume', '--set', 'handwerk'], ohneSchluessel), 1)
})
test('probe-scout: Argumente', () => {
  assert.deepEqual(probeArgs(['--room', 'handwerk', '--query', 'Elektriker in Kaiserslautern', '--max', '3']), { room: 'handwerk', query: 'Elektriker in Kaiserslautern', max: 3 })
  assert.deepEqual(probeArgs(['--room', 'laden']), { room: 'laden', query: 'Bäckerei in Kaiserslautern', max: 3 })
  for (const bad of [[], ['--room', 'netz'], ['--room', 'handwerk', '--max', '6'], ['--room', 'handwerk', '--max', '0'], ['--x']]) assert.throws(() => probeArgs(bad), /Aufruf/)
})
test('probe-scout main: eine Zeile je Betrieb mit status, Mail, Quelle, Telefon und MX; Kosten nur Places; nichts Geheimes, keine Modellzeile', async () => {
  const deps = makeDeps()
  const zeilen = []
  deps.log = (z) => zeilen.push(String(z))
  deps.places = { textSearch: async () => ({ places: [{ placeId: 'ChIJa', name: 'Elektro Beispiel', websiteUri: 'https://beispiel-elektro.de', rating: 4.7, userRatingCount: 80, businessStatus: 'OPERATIONAL' }, { placeId: 'ChIJb', name: 'Ohne Website', websiteUri: '', rating: null, userRatingCount: 0, businessStatus: '' }], nextPageToken: null }) }
  deps.fetch = siteFetch(betriebSeiten('beispiel-elektro.de'))
  assert.equal(await probeMain(['--room', 'handwerk', '--max', '3'], deps), 0)
  const aus = zeilen.join('\n')
  assert.match(aus, /Places: 2 Treffer, davon mit Website: 1/)
  assert.match(aus, /beispiel-elektro\.de status=ok Mail=gefunden Quelle=beispiel-elektro\.de Telefon=ja MX=ok/)
  assert.match(aus, /Kosten gesamt: 0,035 \$/)
  assert.ok(!/Haiku|@|0631/.test(aus), 'weder Adresse noch Nummer noch Modellzeile')
  assert.deepEqual(deps.db.writes, [], 'die Probe schreibt nichts')
})
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/p6-freischalten.test.js ⇒ FAIL, Modul fehlt.
  • Step 3: Implementieren.
    • pruefeDatenschutzSeite: Anker <a id="interessenten"></a> (Meldung „Anker #interessenten“) und die sechs Pflichtsätze aus PFLICHT im Klartext der Seite (Tags entfernt, Leerraum vereinheitlicht); fehlt enthält die Meldung bzw. den Satz.
    • main: Aufrufform prüfen (sonst Hinweis und 1). datenschutz: Seite https://project.webklar.com/datenschutz.html mit deps.fetchImpl ?? fetch holen, --check und --set prüfen und die Funde ausgeben; --set setzt nur bei ok über updateSettings(deps, { privacyInteressentenLive: true }, 'script:p6-freischalten'); --off setzt false ohne Prüfung (derselbe Schalter wie P3s scripts/datenschutz-schalter.mjs --an|--aus, aber --set setzt ihn erst nach der Seitenprüfung; er gilt auch für Claras Erstantworten). raeume: Namen gegen SCOUT_ROOMS prüfen (unbekannt ⇒ 1, nichts schreiben), deps.config.places?.apiKey muss gesetzt sein (sonst 1), --set ergänzt connectedRooms ohne Duplikate, --clear entfernt nur die vier Scout-Räume. Einstieg: loadConfig(process.env), createDb, { db, clock: { now: () => Date.now() }, log: console.log, config }.
    • probe-scout.mjs (manuell, schreibt nichts: weder Interessenten noch Register noch Suchprotokoll noch Kosten; kein Sprachmodell): parseArgs (--room Pflicht und Scout-Raum, --query Standard <erstes Gewerk> in Kaiserslautern, --max 1…5, Standard 3, sonst Error mit „Aufruf: …“; die Argumente bleiben unverändert). main: (1) eine Seite Places, Zeile Places: <n> Treffer, davon mit Website: <k>; (2) für die ersten max Treffer mit Website readOwnSite(deps, { workerId: <Scout des Raums>, website, ort }) (H2: Impressum und Kontaktseite per deps.fetch), je Zeile <Host> status=<ok|robots|nicht_lesbar|keine_website> Mail=<gefunden|keine> Quelle=<Host der Quelle oder -> Telefon=<ja|nein> MX=<ok|nein|-> (MX=nein bei mailProblem 'mx', - ohne Adresse); (3) Kosten gesamt: <x,xxx> $ (nur die eine Places-Anfrage, 0,035 $, ungebucht); es gibt keine Modellprüfung und keine Zeile zur Haiku-Websuche (die gehört zu P1/P3); Schlüssel, Antworttexte, Adressen und Telefonnummern werden nie ausgegeben. Der Einstieg baut deps aus loadConfig(process.env): places = createPlacesClient(...), fetch, dns aus node:dns/promises, clock, log = console.log.
  • Step 4: Bestehen sehen. Derselbe Befehl ⇒ # fail 0.
  • Step 5: Commit. git add scripts/p6-freischalten.mjs scripts/probe-scout.mjs test/p6-freischalten.test.js && git commit -m "p6: Freischalt-Skript für Datenschutz-Schalter und Räume, manuelle Probe" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

P6-T24: Fassade, Verdrahtung in main.js und Nachtlauf-Integrationstest

Files:

  • Create: src/ohneWebsite.js
  • Modify: src/main.js (P1/P3: buildDeps, buildTickJobs(), buildRoutes(), start)
  • Test: test/p6-nacht.test.js, test/p6-wiring.test.js

Interfaces:

  • Consumes: alle Bausteine aus T03–T18 (T02 entfällt); P1: next(deps, { kinds, workerIds? }) (Arten nach rankOf, H1), finish, fail(deps, id, error, { retry }), planNight, rankOf (src/priority.js); P4: runSenden, registerGateCheck; P3: buildTickJobs(), buildRoutes() in src/main.js, deps.fetch.

  • Produces: SCOUT_WORKER_IDS (Wiederausfuhr aus T03); SCOUT_KINDS = ['suche'] (die Auftragsarten, die drainScouts holt; nach rankOf geordnet, H1); erweitereDeps(base, config) → deps (E4: setzt places, osm, dns; kein llm; deps.fetch und deps.claude bleiben unangetastet); registerColdPieces(deps) → unregister (setzt deps.drafters über registerColdDrafters und hängt registerColdGateCheck() ein; einmal in start); drainScouts(deps, { maxJobs = 2 } = {}) → Promise<{ fertig, fehler }> (holt suche-Aufträge nur der Scouts, ruft runScoutJob, danach finish; wirft der Lauf, fail(deps, job.id, <Meldung ohne Schlüssel>, { retry: false }) und Ereignis fehler (Scout, Text „Suchlauf fehlgeschlagen“), kein Absturz; Aufträge von Rita und Finn bleiben liegen, die holt P3); kickScouts(deps) (startet drainScouts ohne await, mit Wächter gegen Überlappung nach dem Muster kickWerkstatt von P4, damit ein Lauf von bis zu 20 Minuten den 60-s-Tick nicht blockiert); top3Tick(deps) (now = nowOf(deps); erst scheduleFollowups, dann selectNightTop3); kuerzenTick(deps) → { gekuerzt, uebersprungen }; ohneWebsiteTickJobs = [{ kind: 'scouts', workerId: 'otto', pauseSafe: false, run: kickScouts }, { kind: 'top3', workerId: 'bea', pauseSafe: false, run: top3Tick }, { kind: 'kuerzen', workerId: 'arne', pauseSafe: true, run: kuerzenTick }] im Format von P1-T26; das Modul exportiert sie nach Übersicht §6.3 zusätzlich als tickJobs und BOTLEADS_ROUTES als routes.

  • Step 1: Tests schreiben.

// test/p6-nacht.test.js
import test from 'node:test'
import assert from 'node:assert/strict'
import { planNight } from '../src/otto/nachtplan.js'
import { enqueue } from '../src/jobs.js'
import { runSenden } from '../src/gespraeche/senden.js'
import { drainScouts, top3Tick, kuerzenTick, registerColdPieces } from '../src/ohneWebsite.js'
import { WEBKLAR_URL } from '../src/mail/links.js'
import { countWords, COLD_WORDS } from '../src/mail/rules.js'
import { makeDeps, fakeClaude, siteFetch, betriebSeiten } from './support/p6.js'

const NUMMERN = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
const host = (n) => `betrieb${n}-elektro.de`
const platz = (n) => ({ placeId: `ChIJ${n}`, name: `Original-Google-Name ${n}`, websiteUri: n >= 8 ? '' : `https://${host(n)}`, rating: 4.7, userRatingCount: 80, businessStatus: 'OPERATIONAL' })
const ENTWURF = { subject: 'Website-Vorschau für Ihren Betrieb', absaetze: ['Ich bin auf Ihren Betrieb in Kaiserslautern gestoßen und habe mir erlaubt, Ihnen etwas vorzuschlagen.', 'Wir erstellen Ihnen eine Vorschau einer neuen Website, ohne dass Sie dafür etwas tun müssen.'] }

function nacht(t0) {
  const deps = makeDeps({ now: '2026-10-19T20:00:00Z', settings: { connectedRooms: ['handwerk'] } }) // Mo 22:00 Berlin, Schalter an
  deps.places = { textSearch: async () => ({ places: NUMMERN.map(platz), nextPageToken: null }) }
  // Impressum je Betrieb mit Website (0–7); die Betriebe 6 und 7 zeigen keine Mail, 8 und 9 haben gar keine Website. Kein Sprachmodell zum Lesen (H2).
  deps.fetch = siteFetch(Object.assign({}, ...NUMMERN.filter((n) => n < 8).map((n) => betriebSeiten(host(n), { mail: n <= 5, firma: `Elektro Betrieb${n} GmbH` }))))
  t0.after(registerColdPieces(deps)) // Entwürfe für P4s Tick und Zusatzprüfung am Tor, wie in main.js
  deps.claude = fakeClaude({
    ask: async (_d, o) => o.workerId === 'bea'
      ? { data: { urteile: JSON.parse(o.input).kandidaten.map((k, i) => ({ id: k.id, auditScore: 3, sympathie: 9 - i, kette: false, belege: [] })) }, usage: {}, costUsd: 0.01 }
      : { data: ENTWURF, usage: {}, costUsd: 0.01 }, // Max (Erstmail; der Nachfass-Text kommt allein aus dem Code)
  })
  return deps
}

test('Eine Nacht bis zur Kürzung: Top 3 gemailt, Rest mit Grund gelistet, Nachfass nach 7 Tagen, danach gekürzt', async (t0) => {
  const deps = nacht(t0)
  const senden = async (iso) => { deps.clock.set(iso); return (await runSenden(deps)).sent ?? 0 }
  // 22:00: P1s planNight schickt Hanna los, die Scouts-Schleife sucht
  assert.deepEqual((await planNight(deps, { run: 1 })).planned.map((x) => [x.workerId, x.room]), [['hanna', 'handwerk']])
  assert.deepEqual(await drainScouts(deps), { fertig: 1, fehler: 0 })
  assert.equal(deps.db.docs('workersProspects').length, 10)
  // H2: Impressum und Kontakt per HTTP und Regex, nie per Sprachmodell; höchstens 3 Seiten je Betrieb
  assert.equal(deps.fetch.calls.map((c) => c.url).filter((u) => !u.endsWith('/robots.txt')).length, 16, '8 Betriebe mit Website × (Startseite + Impressum)')
  assert.deepEqual(deps.claude.calls.search, [], 'claude.search wird nie benutzt')
  assert.deepEqual([...new Set(deps.claude.calls.ask.map((c) => c.workerId))], ['bea'], 'vor dem Senden ruft nur Bea Claude auf')
  // 05:30: Top 3 über alle Räume, der Rest mit Grund in die Liste
  deps.clock.set('2026-10-20T03:30:00Z')
  await top3Tick(deps)
  const ps = deps.db.docs('workersProspects')
  assert.equal(deps.db.docs('workersJobs').filter((j) => j.kind === 'erstmail').length, 3)
  assert.deepEqual(ps.filter((p) => p.status === 'botlead').map((p) => p.botLeadReason).sort(), [
    'Platz 4 von 6; freie Mail-Plätze: 3', 'Platz 5 von 6; freie Mail-Plätze: 3', 'Platz 6 von 6; freie Mail-Plätze: 3',
    'keine veröffentlichte Mail', 'keine veröffentlichte Mail', 'keine veröffentlichte Mail', 'keine veröffentlichte Mail'])
  // Sendetag 09:05 (MESZ): P4s Tick sendet die drei Mails, danach ist das Tageslimit erreicht
  assert.equal(await senden('2026-10-20T07:05:00Z'), 3)
  assert.equal(await senden('2026-10-20T07:10:00Z'), 0)
  assert.equal(deps.mailer.sent.length, 3)
  const adressen = ps.filter((p) => p.threadId).map((p) => p.email)
  assert.equal(adressen.length, 3)
  for (const m of deps.mailer.sent) {
    assert.ok(adressen.some((a) => JSON.stringify(m.to).includes(a)), 'nur an gespeicherte veröffentlichte Adressen')
    const [koerper, fuss] = m.text.split('\n--\n')
    assert.ok(koerper.includes('KI-Assistent von WEBklar'))
    assert.deepEqual(koerper.match(/https?:\/\/\S+/g), [WEBKLAR_URL], 'Körper: genau ein Link (H3, A22)')
    const n = countWords(koerper)
    assert.ok(n >= COLD_WORDS.min && n <= COLD_WORDS.max, `Körper: ${n} Wörter`)
    assert.equal(fuss.match(/https?:\/\/\S+/g).length, 3, 'Fußblock: genau drei Adressen')
    assert.ok(m.headers['List-Unsubscribe'])
  }
  // R1 und Übersicht §2.13
  const alles = JSON.stringify(['workersProspects', 'workersEvents', 'workersJobs', 'workersSearchLog', 'workersThreads', 'workersMessages'].map((c) => deps.db.docs(c)))
  assert.ok(!alles.includes('Original-Google-Name'))
  assert.ok(deps.db.writes.every((w) => w.col.startsWith('workers')), 'nur Workers-Sammlungen beschrieben')
  assert.equal((await deps.db.get('workersDaily', '2026-10-20')).mailsCold, 3)
  // sieben Tage später (MEZ, ab 25.10.): genau eine Nachfass-Mail je Betrieb
  deps.clock.set('2026-10-27T03:30:00Z')
  await top3Tick(deps)
  assert.equal(deps.db.docs('workersJobs').filter((j) => j.kind === 'nachfass').length, 3)
  assert.equal(await senden('2026-10-27T08:05:00Z'), 3) // 09:05 MEZ
  assert.equal(await senden('2026-10-27T08:10:00Z'), 0)
  assert.equal(deps.mailer.sent.length, 6)
  assert.ok(deps.mailer.sent.slice(3).every((m) => m.subject.startsWith('Re: ')))
  deps.clock.set('2026-11-03T03:30:00Z')
  await top3Tick(deps)
  assert.equal(deps.db.docs('workersJobs').filter((j) => j.kind === 'nachfass').length, 3, 'keine zweite Nachfass-Runde')
  // 90 Tage nach dem Fund: alles gekürzt, Register bleibt
  const schluessel = deps.db.docs('workersKeys').length
  deps.clock.set('2027-01-20T01:30:00Z')
  assert.equal((await kuerzenTick(deps)).gekuerzt, 10)
  assert.ok(deps.db.docs('workersProspects').every((p) => p.email === '' && p.purgedAt))
  assert.equal(deps.db.docs('workersKeys').length, schluessel)
})
test('drainScouts: Fehler im Lauf ⇒ Auftrag "fehler" und Ereignis, kein Absturz; Aufträge von Rita bleiben liegen', async (t0) => {
  const deps = nacht(t0)
  deps.places = { textSearch: async () => { throw new Error('Places down') } }
  await planNight(deps, { run: 1 })
  const fremd = await enqueue(deps, { kind: 'suche', workerId: 'rita', payload: { room: 'netz' } })
  assert.deepEqual(await drainScouts(deps), { fertig: 0, fehler: 1 })
  assert.equal(deps.db.docs('workersJobs').find((j) => j.workerId === 'hanna').status, 'fehler')
  assert.ok(deps.db.docs('workersEvents').some((e) => e.kind === 'fehler'))
  assert.equal((await deps.db.get('workersJobs', fremd.id)).status, 'offen')
})
// test/p6-wiring.test.js
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import { buildTickJobs, buildRoutes } from '../src/main.js'
import { rankOf } from '../src/priority.js'
import * as ohneWebsite from '../src/ohneWebsite.js'

test('main.js hängt Tick-Einträge, Route und erweiterte deps ein', () => {
  const kinds = buildTickJobs().map((j) => j.kind)
  for (const k of ['scouts', 'top3', 'kuerzen']) assert.ok(kinds.includes(k), k)
  assert.ok(buildRoutes().some((r) => r.method === 'GET' && r.pattern.test('/botleads')))
  const src = fs.readFileSync(new URL('../src/main.js', import.meta.url), 'utf8')
  for (const s of ['erweitereDeps', 'registerColdPieces']) assert.ok(src.includes(s), s)
  assert.deepEqual(ohneWebsite.tickJobs.map((j) => j.kind), ['scouts', 'top3', 'kuerzen']) // Übersicht §6.3: Modul liefert tickJobs und routes
  assert.equal(ohneWebsite.routes.length, 1)
})
test('H1: Ränge der P6-Einträge und -Aufträge; die Artenliste der Scouts ist nicht absteigend', () => {
  const rang = Object.fromEntries(buildTickJobs().map((j) => [j.kind, rankOf(j.kind)])) // wirft für eine Art ohne Rang
  assert.deepEqual([rang.scouts, rang.top3, rang.kuerzen], [4, 4, 5])
  assert.deepEqual(['erstmail', 'nachfass', 'suche'].map(rankOf), [2, 2, 4])
  const raenge = ohneWebsite.SCOUT_KINDS.map(rankOf)
  assert.deepEqual(raenge, [...raenge].sort((a, b) => a - b))
})
test('E4/A1: erweitereDeps setzt places, osm und dns, nie llm, und lässt deps.fetch und deps.claude unangetastet', () => {
  const fetch = async () => {}, claude = { ask() {}, search() {} }
  const deps = ohneWebsite.erweitereDeps({ fetch, claude }, { places: { apiKey: 'k' } })
  assert.deepEqual([typeof deps.places.textSearch, typeof deps.osm.lookup, typeof deps.dns.resolveMx, typeof deps.dns.resolve4], ['function', 'function', 'function', 'function'])
  assert.deepEqual([deps.fetch === fetch, deps.claude === claude, 'llm' in deps], [true, true, false])
})
  • Step 2: Fehlschlag sehen. cd /home/webklar/apps/workers-dev && timeout 300 node --max-old-space-size=1024 --test test/p6-nacht.test.js test/p6-wiring.test.js ⇒ FAIL, Cannot find module '../src/ohneWebsite.js'.
  • Step 3: Implementieren.
    • src/ohneWebsite.js nach den Signaturen oben. erweitereDeps: places = createPlacesClient({ apiKey: config.places?.apiKey }) (config.places ist null, wenn die Gruppe ungesetzt ist; fehlt der Schlüssel, wirft textSearch mit PlacesError('Places-Schlüssel fehlt.', 0)), osm = createOsmClient({ sleep: (ms) => new Promise((r) => setTimeout(r, ms)) }), dns = { resolveMx, resolve4 } aus node:dns/promises; Rückgabe { ...base, places, osm, dns } (kein llm, fetch und claude unverändert). registerColdPieces(deps): registerColdDrafters(deps), const aus = registerColdGateCheck(), Rückgabe aus. drainScouts: Schleife bis maxJobs: job = await next(deps, { kinds: SCOUT_KINDS, workerIds: SCOUT_WORKER_IDS }) (SCOUT_KINDS = ['suche']; next fragt die Arten nach rankOf ab, H1), null ⇒ Ende; try { finish(deps, job.id, await runScoutJob(deps, job)) } catch: fail(...), emit fehler, Zähler fehler. kickScouts: Modulvariable laeuft; ist sie gesetzt, sofort zurück; sonst laeuft = drainScouts(deps).catch((e) => deps.log(…Meldung ohne Schlüssel…)).finally(() => { laeuft = null }) ohne await.
    • src/main.js (Stellen laut Vorprüfung, A4): buildDeps ruft am Ende erweitereDeps(deps, config) (nach P3s fetch, das nicht überschrieben wird); buildTickJobs() hängt ...ohneWebsiteTickJobs (= tickJobs) an; buildRoutes() hängt ...BOTLEADS_ROUTES (= routes) an; start ruft einmal registerColdPieces(deps) und ruft das zurückgegebene aus beim Beenden. Die Nachtplanung (planNight), den Versand (senden) und den Posteingang berührt P6 nicht.
  • Step 4: Bestehen sehen. Derselbe Befehl ⇒ # fail 0; danach die ganze Suite: free -m, dann timeout 300 node --max-old-space-size=1024 --test test/*.test.js ⇒ # fail 0.
  • Step 5: Commit. git add src/ohneWebsite.js src/main.js test/p6-nacht.test.js test/p6-wiring.test.js && git commit -m "p6: Fassade, Tick-Einträge und Nachtlauf-Integrationstest" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"

Betriebsanleitung (Kenso)

Reihenfolge ist wichtig: Erst das Fundament (1–4), dann Probe (5), dann der Datenschutztext (6–7), erst danach Räume verbinden (8). Solange privacyInteressentenLive aus ist, geht keine Kaltmail raus (Tor-Grund datenschutz, die Aufträge bleiben offen). Verbinde deshalb keinen Raum vor Schritt 7: Sonst wählt die erste Nacht drei Betriebe aus, deren Erstmail-Aufträge liegen bleiben und die Mail-Plätze blockieren (keine weitere Suche, bis der Schalter an ist). Der Schalter gilt auch für Claras Erstantworten (P3).

  1. Voraussetzungen. P1–P4 laufen: docker ps --filter name=webklar-workers --format '{{.Names}} {{.Status}}' ⇒ webklar-workers Up …. Absender-Domain und Postfach sind eingerichtet (SPF, DKIM, DMARC), die Aufwärmphase läuft seit mindestens zwei Wochen (Spec §11.4), eine von Hand verschickte Testmail aus dem Absender-Postfach erreicht bei mail-tester.com mindestens 9 von 10.
  2. Google-Places-Schlüssel. Google Cloud Console: „Places API (New)“ aktivieren, Schlüssel anlegen, beschränken auf diese API und die Server-IP, Tageskontingent für Text Search auf 300 Anfragen, Budgetwarnung bei 20 €. Dann cp /home/webklar/apps/.env /home/webklar/apps/.env.bak-$(date +%F)-workers-p6, Zeile WORKERS_PLACES_API_KEY=<Schlüssel> in /home/webklar/apps/.env eintragen (den Wert sieht Claude nie). Prüfen: command grep -c '^WORKERS_PLACES_API_KEY=.' /home/webklar/apps/.env ⇒ 1; command grep -c 'WORKERS_PLACES_API_KEY' /home/webklar/apps/docker-compose.yml ⇒ mindestens 1 (sonst nach Backup docker-compose.yml.bak-$(date +%F)-workers-p6 unter environment: des Dienstes workers die Listenzeile - WORKERS_PLACES_API_KEY=${WORKERS_PLACES_API_KEY:-} ergänzen, in derselben Listenschreibweise wie die übrigen Zeilen dort; P1s Compose-Block enthält sie schon, die Prüfung ist also meist grün); docker compose -f /home/webklar/apps/docker-compose.yml config -q ⇒ keine Ausgabe, Exit 0.
  3. Dienst ausrollen. cd /home/webklar/apps/workers-dev && git push origin p6-ohne-website:main (Gitea knso/workers-dienst), dann git -C /home/webklar/apps/workers pull --ff-only && cd /home/webklar/apps && docker compose build workers && docker compose up -d --no-deps workers. Prüfen: docker logs --tail 20 webklar-workers ohne Fehler; docker exec webklar-workers wget -qO- http://localhost:8092/api/workers/health ⇒ {"ok":true,…}. (Die Rollen-Prompts prompts/*.md, darunter bea.md und max.md, liegen im Checkout und werden ohne Neubau neu gelesen: Änderungen an ihnen brauchen nur git -C /home/webklar/apps/workers pull --ff-only, H5.)
  4. Schema prüfen. P6 braucht kein neues Schema (E1/E2). docker exec webklar-workers node scripts/setup-collections.mjs --check ⇒ Exit 0; sonst fehlt etwas aus P3 oder P4 (privacyInteressentenLive, Index auf workersMessages.threadId): ohne --check ausführen und erneut prüfen.
  5. Live-Probe (manuell, kostet 0,035 $: eine Places-Anfrage, kein Sprachmodell). docker exec webklar-workers node scripts/probe-scout.mjs --room handwerk --query "Elektriker in Kaiserslautern" --max 3. Erwartet: Places: 20 Treffer, davon mit Website: …; je Betrieb eine Zeile mit status=ok (oder robots, nicht_lesbar), Mail=gefunden oder keine, Quelle=<Host>, Telefon=ja|nein, MX=ok|nein|-; Kosten gesamt: 0,035 $. Bei status=robots oder nicht_lesbar für viele Betriebe: Probe mit einer anderen Anfrage wiederholen, sonst melden. (Ob Haiku die Websuche kann, betrifft nur Rita und Finn und wird in P1/P3 geprüft, nicht hier.)
  6. Datenschutztext live. (a) Kenso gibt den Text aus P6-T20 frei und nennt den Mail-Anbieter. (b) Der Ausführende committet in dev-workers-datenschutz; Kenso pusht: git -C /home/webklar/apps/kundenbereich.webklar.com/dev-workers-datenschutz push origin feature/workers-datenschutz:main; nach 1–2 Minuten curl -s https://project.webklar.com/datenschutz.html | command grep -c 'id="interessenten"' ⇒ 1. (c) Website: git -C /home/webklar/apps/webklar.com/dev-workers-ds push origin fix/datenschutz-anker:main; deployt der Webhook nicht binnen 3 Minuten: docker exec webklar-webclaw-build sh -c 'DEPLOY_TRIGGER=manual sh "${WEBCLAW_DEPLOY_SCRIPT:-/deploy.sh}"'. Prüfen im Browser: https://www.webklar.com/datenschutz#interessenten landet auf https://project.webklar.com/datenschutz.html#interessenten und zeigt „11. Interessenten“. (d) Rechtliches: Kenso erzeugt in der Appwrite-Konsole den kurzlebigen Schlüssel „Rechtliches-Upload“ (files.read, files.write, documents.read, documents.write, Ablauf 1 Tag) und legt ihn nach /root/.appwrite-rechtliches.key (chmod 600); der Ausführende (Claude) führt im Portal-Worktree timeout 300 node --max-old-space-size=1024 scripts/datenschutz-hochladen.mjs --probe und dann timeout 300 node --max-old-space-size=1024 scripts/datenschutz-hochladen.mjs --freigabe=<Stand-Datum> aus; Prüfen in WOMS „Rechtliches“: Karte „Datenschutzerklärung“ zeigt die neue Version als „aktuell“, die alte als „abgelöst“; danach löscht Kenso den Schlüssel in der Konsole.
  7. Schalter umlegen (erst jetzt dürfen Kaltmails raus). docker exec webklar-workers node scripts/p6-freischalten.mjs datenschutz --check ⇒ „Seite ok“; dann … datenschutz --set ⇒ „Schalter privacyInteressentenLive: an“ (derselbe Schalter wie P3s datenschutz-schalter.mjs --an, aber erst nach der Seitenprüfung; P3s Schritt „Clara einschalten“ ist damit mit erledigt).
  8. Räume verbinden, einen nach dem anderen. docker exec webklar-workers node scripts/p6-freischalten.mjs raeume --set handwerk ⇒ „Verbundene Räume: …handwerk“. Ab der nächsten Nacht (22:00 Suche, 05:30 Auswahl, ab 09:00 Versand) arbeitet Hanna; weitere Räume erst, wenn Zustellung und Antworten gut aussehen (aerzte, selbst, laden mit demselben Befehl).
  9. WOMS. git -C /home/webklar/apps/ticket.webklar.com/dev-workers push origin feature/workers:test (wenn P2/P3 mit dran sind, laut Übersicht §1.1). Prüfen: curl -s -o /dev/null -w '%{http_code}' https://ticket.webklar.com/api/workers/botleads ⇒ 401; in WOMS /hustler ⇒ Reiter „Bot-Leads“.
  10. Erste Nacht beobachten. Morgens: Morgenbericht-Mail „Hustler-Nachtbericht TT.MM.JJJJ“ (06:30); „Bot-Leads“ zeigt rund 7 Einträge mit Grund; im Absender-Postfach „Gesendet“ höchstens 3 Mails, alle zwischen 09:00 und 16:00, jede mit genau einem Link im Text (https://www.webklar.com), Fußblock und Abmelde-Link (Klick testen: erscheint der Eintrag in der Sperrliste?); workersDaily des Tages zeigt mailsCold ≤ 3. Beschwerden und Antworten landen wie in P4 beschrieben im Pult. Zeigt das Board „Versand gestoppt“ (Bounce-Notbremse, H4), sind harte Bounces aufgetreten: Ursache prüfen (falsche Adressen? Absender-Reputation?), dann gibt ein Admin im Einstellungs-Dialog „Versand wieder freigeben“; die Erstmail-Aufträge blieben solange offen. Verweigert das Tor eine Kaltmail aus P6 (Pult „Mail nicht gesendet: …“, z. B. text_zu_lang bei einem sehr langen Angebotssatz), gehört der Entwurf geprüft, nicht das Tor gelockert.
  11. Not-Aus und Rückbau. Sofort: Pausenschalter in WOMS. Nur Kaltmail aus (stoppt auch Claras Erstantworten, P3): docker exec webklar-workers node scripts/p6-freischalten.mjs datenschutz --off. Suche aus: … raeume --clear. Beides ist ohne Neustart wirksam. Die Bounce-Notbremse (H4) stoppt den Versand außerdem von selbst, siehe Schritt 10.