280 KiB
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 bekommenberlinNow(clock), Funktionen mit ParameternowerhaltennowOf(deps)(einDate, aus P4ssrc/gespraeche/util.js, wiegetDocundparsePayload);db.getliefertnull; Claude wird nur alsdeps.claude.ask(deps, opts)aufgerufen (A1: es gibt wederdeps.llmnochllmOf, Tests ersetzendeps.claude;searchnutzt P6 in dieser Runde nicht, H2); Impressum und Kontaktseiten liestreadOwnSitemitdeps.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 mitQuery.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 kenntequal,orderAsc,orderDesc,limit,greaterThan,offset,cursorAfterundselect, jede andere Query-Methode wirftQuery-Methode nicht unterstützt);workersDailyschreibt alleinbook/updateDaily(P1-E4); die Suchaufträge der Scouts plant P1splanNight(Payload{ night, run, room, region, candidates, entrance }); Mails an Interessenten versendet allein P4s Tor im Ticksenden(Aufträgeerstmail/nachfass, Text ausdeps.drafters), Zusatzprüfungen hängen überregisterGateCheckein; den Schaltersettings.privacyInteressentenLivelegt P3 an (P3-E8, A5) und er ist der einzige Datenschutz-Schalter (kein Env, keine zweite Konfiguration); die Registries heißenbuildTickJobs()undbuildRoutes()(A4). - Arbeitsorte: Dienst
/home/webklar/apps/workers-devBranchp6-ohne-website(auf dem Stand, der P1–P4 enthält); WOMS/home/webklar/apps/ticket.webklar.com/dev-workersBranchfeature/workers; Portal-Worktree/home/webklar/apps/kundenbereich.webklar.com/dev-workers-datenschutzBranchfeature/workers-datenschutzvonorigin/main; Website-Worktree/home/webklar/apps/webklar.com/dev-workers-dsBranchfix/datenschutz-ankervonorigin/main. - Kein Agent pusht, deployt, startet Container oder ruft in Tests Google, Anthropic, Overpass, DNS, SMTP, Appwrite oder fremde Websites auf (nur Fakes;
deps.fetchwirft inmakeDepsNetzzugriff im Test); Live-Prüfungen stehen ausschließlich in der Betriebsanleitung. - Places:
POST https://places.googleapis.com/v1/places:searchText, Feldmaske exaktplaces.id,places.displayName,places.websiteUri,places.rating,places.userRatingCount,places.businessStatus,nextPageToken,pageSize20, höchstens 3 Seiten je Lauf, 0,035 $ je Anfrage; dauerhaft nurplace_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-Agentaussrc/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; vorherrobotsAllows(verboten ⇒ Seite auslassen, Startseite verboten ⇒ Statusrobots); kein Abruf von Hosts ausBOT_VERBOT_DOMAINS(src/claude.js), von sozialen Netzen und Verzeichnissen (normalizeDomainaus 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üftdeps.dns.resolveMxdie Mail-Domain (kein MX ⇒ Adresse verworfen).claude.searchnutzt 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
aerzteoder Gewerk inGESUNDHEITS_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 Ticksenden): Anrede „Sie“, Fließtext mit genau einem Link (WEBKLAR_URLaussrc/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 aussettings.offerText(höchstens 400 Zeichen, A11), Pflichtblock ausbuildColdFooter(für Erst- und Nachfass-Mail) mit genau drei Adressen (Quelle,DATENSCHUTZ_URL=https://www.webklar.com/datenschutz#interessentenaussrc/mail/links.js, Abmelde-Link), nur beiprivacyInteressentenLive=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 insrc/mail/rules.js;qaColdMailund die Tests benutzen sie, P6 definiert keine eigene Kopie. - Nachfass: höchstens 1 je Betrieb,
followupDays(7) nach der Erstmail (Termin und FlagfollowupSentsetzt P4sonOutbound), höchstens ein Auftragnachfassje 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;workersKeysundworkersSuppressionwerden 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
rankOfaussrc/priority.js(P1):scoutsundtop3Rang 4,kuerzenRang 5, die AufträgeerstmailundnachfassRang 2,sucheRang 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 perbuildSystem(workerId, aufgabe)(src/prompts.js, P1) an. Die vier Scout-Dateienprompts/{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
placeIdund 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.txtverbietet den Abruf (robotsAllowsist 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:falsegetarnt,toweicht 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
mailColdPerDayje 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
aerzteoder „Physiotherapie“ im Raumselbst) 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 (Pultunsicher, 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
privacyInteressentenLiveinsrc/schema.js/src/settings.jsan, P1 erzwingtretentionDays7–90,offerText1–400 undofferProductKeynurhosting, 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); TesthilfenmakeDeps,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.jsmitmakeDeps(over)(Step 4), den Re-ExportenfakeClock,fakeMailer,fakeClaude(script)(setztdeps.claude, A1),at(iso),page(url, text),siteFetch(map)(Fake fürdeps.fetch),betriebSeiten(host, over)(Start- und Impressum-Seite eines Testbetriebs fürsiteFetch),seedProspect(db, over),seedThread(db, over),seedJob(db, over)(rohes Dokument),takeJob(deps, { kind, workerId, prospectId?, threadId?, payload? })(P1-Auftrag{ id, …, payload }überenqueue+next, Statuslaeuft),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 alsdeps.claude.ask(deps, opts)(keindeps.llm, keinllmOf); A2deps.clock.now()liefert Millisekunden,berlinNow(clock)für den Zeitplan,nowOf(deps)einDate;db.getliefertnull,db.createdas Dokument,listAllein Array (P1-E3); A3next(deps, { kinds, workerIds? })liefert{ id, kind, workerId, prospectId, threadId, attempts, payload }odernull(Arten inrankOf-Reihenfolge, H1),fail(deps, id, error, { retry })(P1-E8, P3-E3); A4buildTickJobs(),buildRoutes()und Tick-Einträge{ kind, workerId, pauseSafe, run(deps, { now, settings }) }insrc/main.js(P3-E5/E6); A5registerGateCheck(fn)mitctx = { prospect, thread, to, text, cold, author, settings, first }unddeps.drafters.erstmail/nachfass(P4-E1/E2); der einzige Datenschutz-Schalter istsettings.privacyInteressentenLive. Weicht der Code ab, wird das gemeldet und nurtest/support/p6.jsangepasst; 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-schemalä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-supportschlägt fehl:Cannot find module './support/p6.js'. - Step 4: Implementieren. Keine Änderung an
src/settings.jsodersrc/schema.js: P3 legtprivacyInteressentenLivean (Schema,DEFAULTS, Boolean-Prüfung), P1 erzwingtretentionDays7–90,offerText1–400 undofferProductKeynurhosting(A11); die Testsp6-schemableiben als Pins.test/support/p6.jsnach 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), danachdeps.clock.set(now);deps.db.seed('workersSettings', { ...DEFAULTS, privacyInteressentenLive: true, ...settings }, 'main')(Standard der Testhilfe: Schalter an; Tests des ausgeschalteten Schalters übergeben ausdrücklichfalse);deps.claude = fakeClaude({})(A1; ersetzt das Fake von P4, Tests setzendeps.claudeneu; es gibt keindeps.llm),deps.fetch = async () => { throw new Error('Netzzugriff im Test') }(Tests setzensiteFetch(...)),deps.sleep = async (ms) => { deps.sleeps.push(ms) }mitdeps.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.1ist öffentlich im Sinne vonisPrivateIp);deps.db.docs = (col) => deps.db.all(col);deps.db.writes= Liste{ op, col, id }(create,update,removeder Fake-DB werden umhüllt).fakeClaude({ search, ask }):{ calls: { search: [], ask: [] }, ask(deps, opts), search(deps, opts) }; jede Funktion zeichnetoptsauf und ruft die übergebene Skriptfunktion (Standard: wirftError('nicht vorgesehen'), damit ein unerwarteter Modellaufruf, etwasearch, sofort auffällt).siteFetch(map)→ fetch-kompatible Funktionf(url, init)mitf.calls = [{ url, init }](jeder Aufruf wird aufgezeichnet, auch eintimeout-Fall, mit der normalisierten Adressenew URL(url).href; ebenso werden die Schlüssel vonmapnormalisiert). Werte vonmap:string(Status 200,content-type: text/html; charset=utf-8, für*/robots.txttext/plain) oder{ status = 200, headers = {}, body = '', timeout = false }(headersergänzen bzw. überschreiben den Standard-content-type: text/html; charset=utf-8);timeout: truelässt den Aufruf sofort mit einem Fehlername: 'TimeoutError'scheitern (kein echtes Warten). Eine Adresse, die nicht inmapsteht, liefert Status 404 mit leerem Körper (für/robots.txtheißt das: alles erlaubt, RFC 9309). Die Antwort ist{ ok, status, url, headers: { get(name) } (Name ohne Beachtung der Schreibung), text: async () => body };init.redirectwird nur aufgezeichnet, Weiterleitungen folgt die Funktion nie selbst.betriebSeiten(host, { mail = true, phone, chef = '', firma = 'Elektro Beispiel GmbH', jahr = 2016, ort = 'Kaiserslautern' } = {})→ Map fürsiteFetchmit genau zwei Seiten. Startseitehttps://<host>/:<html><head><title><firma> | Elektriker in <ort></title></head><body><p>© <jahr> <firma></p><a href="/impressum">Impressum</a></body></html>. Impressumhttps://<host>/impressum:<h1>Impressum</h1><p>Angaben gemäß § 5 TMG</p><p><firma><br>Hauptstraße 5<br>67655 <ort></p>, bei gesetztemchefdanach<p>Geschäftsführer: <chef></p>, dann<p>Telefon: <phone><br>E-Mail: info@<host></p>(die Telefonzeile entfällt beiphone: false, die Mailzeile beimail: false).phoneist 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)überdb.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(rohesworkersJobs-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'):workersSearchLogmitworkerId = workerForRoom(room),found 10,fresh 8.takeJob(deps, { kind, workerId, prospectId, threadId, payload = {} }):await enqueue(deps, …), danachreturn next(deps, { kinds: [kind] }).at(iso) = new Date(iso),page(url, text) = { url, text }. Alleseed*sind mitoverüberschreibbar und geben das angelegte Dokument zurück; Felder ohne Wert werden weggelassen, nienull.
- Re-Exporte:
- Step 5: Bestehen sehen. Derselbe Befehl ⇒
# fail 0. Danachtimeout 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)aussrc/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 Dateienprompts/{ali,hanna,sven,lea}.mdlegt 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); SammlungenworkersThreads(Indexidx_status),workersJobs(Indexstatus). -
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.
berlinDayruft P1sberlinNow({ now: () => date.getTime() })(nicht neu rechnen) und liefert{ day, minutes: hour * 60 + minute, weekday }.sendDayFor: Start = Berliner Tag vonnow; istminutes ≥Fensterende (aussettings.sendWindowEnd, als Minuten) oder (beisendWeekdaysOnly) Wochenende, auf den nächsten zulässigen Tag vorrücken.followupThreads:listAll('workersThreads', [Query.equal('status', ['wartet_auf_kunde'])]), im Codechannel === 'mail',followupSentfalsch,followupDueAtgesetzt und dessen Berliner Tag (berlinNow({ now: () => Date.parse(followupDueAt) }).day) ≤sendDay.mailSlots:followups=followupThreads(...).length;openFirst= Aufträge mitQuery.equal('status', ['offen','laeuft']), im Codekind === 'erstmail';sentToday=(await getDaily(deps, sendDay))?.mailsCold ?? 0, aber nur wennsendDayder 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), SammlungworkersSearchLog(Indexroom+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(nullfür Räume ohne Scout oder wenn alle Abfragen der letztensperrTagegesucht sind); P6 ergänztisDue/runKeyumtop3(täglich ab 05:30 Berlin) undscouts(Intervall 2 Minuten, wieanfragenin P3);kuerzen(täglich ab 03:00) kenntisDueseit 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 Reihenfolgefor ort of orte { for gewerk of gewerke }, Abfrage"<gewerk> in <ort>"; jequeryzählt der jüngste Logeintrag; erster Kandidat ohne Eintrag gewinnt; sonst der Kandidat mit dem ältesten Eintrag, der älter alssperrTageist; sonstnull.pickQuery:SCOUT_ROOMS[room]fehlt ⇒null; Orte =orteFuer(region); Log =list('workersSearchLog', [Query.equal('room', [room]), Query.orderDesc('at'), Query.limit(100)]); Ergebnis vonnaechsteAbfragemitisHealth(room, gewerk)ergänzt. Insrc/schedule.jsdie Tabelle von P1-T08 erweitern:top3als tägliche Art wiebericht(ab 05:30, Marker = Berliner Tag) undscoutsals Intervallart wieposteingang(120 000 ms).kuerzenundpultAltstehen 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 hatkuerzennicht 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 zudeps.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.
POSTmit HeadernContent-Type: application/json,X-Goog-Api-Key,X-Goog-FieldMask; Körper wie im Test,pageTokennur wenn gesetzt;signal: AbortSignal.timeout(20000); Antwort-MappingplaceId = id,name = displayName?.text ?? '',websiteUri ?? '',rating ?? null,userRatingCount ?? 0,businessStatus ?? '', Einträge ohneidverwerfen,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:
normalizeNameaussrc/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 zudeps.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 ausnormalizeNameund enthält nur[a-z0-9]).lookup:POST https://overpass-api.de/api/interpreter, Körperdata=<encodeURIComponent(query)>, HeaderContent-Type: application/x-www-form-urlencoded,User-Agent; vor jeder weiteren Anfrageawait sleep(minGapMs)(erste Anfrage ohne Wartezeit); ZählermaxCalls; nach Status ≠ 200 oder Netzfehler Schaltergestoerrt⇒ alle weiteren Aufrufenullohne Anfrage. Trefferwahl: erstes Element mittags.name, dessennormalizeNamegleich ist oder das kürzere (≥ 8 Zeichen) als ganze Wortfolge im längeren steht, und dessenaddr:cityfehlt oder gleichort(normalizeName) ist. Felder:phoneausphoneodercontact:phone,websiteauswebsiteodercontact: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)undUSER_AGENTaussrc/net/robots.js(P3, A16),extractAnchors(html),htmlToLines(html),decodeEntities(s)aussrc/anfragen/alerts/html.js(P3),BOT_VERBOT_DOMAINSaussrc/claude.js(P1, A10),normalizeDomain(liefert die registrierbare Domain,''für Social- und Verzeichnis-Hosts, P1-T17),normalizeNameaussrc/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>mitOwnSite = { 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'',latestYearnull). Es gibt kein Schema, keinen Prompt und keinen Modellaufruf:OWNSITE_SCHEMA,SCOUT_SYSTEMundEXTRACT_SYSTEMdes früheren Entwurfs entfallen. -
Step 1: Test schreiben. Alle Tests laufen mit
deps.fetch = siteFetch(...)und injiziertemdeps.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 derenmailtos(Adressen ausmailto:-Hrefs), klein geschrieben, Duplikate entfernt; verworfen werden Endungenpng jpg jpeg gif svg webp css jsund Lokalteilenoreply no-reply donotreply datenschutz privacy dpo abuse webmaster postmaster. Keine Entschleierung.pickEmail: Punkte 2 für Domain gleichhostoder Unterdomain, 1 für Lokalteil ininfo 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, sonstnull.mailDomainOk(dns, domain): nurdns.resolveMx(domain)(H2: MX-only, kein Rückgriff aufresolve4): nicht leer ⇒true; leere Antwort oder FehlercodeENOTFOUND/ENODATA⇒false; jeder andere Fehlercode ⇒true(im Zweifel zustellbar).isPrivateIp(ip): IPv4 in0.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:assertCap(workerId, 'web_fetch')(wiesearchprüft auch das Lesen der Website die Fähigkeit; Rollen ohne sie bekommenCapError).host = normalizeDomain(website); ist er leer, das Schema wederhttpnochhttps(fehlt es, wirdhttps://vorangestellt), der Host inBOT_VERBOT_DOMAINS(Punktgrenze) oder eine IP-Adresse ⇒{ status: 'keine_website' }ohne Abruf (das deckt Facebook, Instagram und Verzeichnisse ab).- Vor dem ersten Abruf je Hostname:
deps.dns.resolve4(hostname); Fehler, leere Antwort oderisPrivateIpfür irgendeine Adresse ⇒ Startseite:nicht_lesbar, spätere Seite: auslassen; es folgt dann kein Abruf, auch nicht vonrobots.txt. - Jede Seite wird so geholt:
robotsAllows(deps.fetch, url)(P3; Startseite verboten ⇒ Statusrobots, andere Seite ⇒ auslassen), danndeps.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 Zielhttp/httpsist undnormalizeDomain(ziel) === host(andere Domain ⇒ Seite fehlgeschlagen); jedes Ziel durchläuft wieder Punkt 3 undrobotsAllows. Eine Antwort gilt nur mit Status 200,content-typefehlend odertext/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). - Höchstens drei Seitenabrufe (
robots.txtzählt nicht): Startseite, dann der erste Impressum-Link, dann der erste Kontakt-Link. Die Links stammen ausextractAnchors(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, nurhttp/https, ohne#-Fragment, nicht die Startseite selbst, keine Adresse doppelt. Es werden keine Pfade geraten: fehlt der Link, fehlt die Seite. Vor jeder weiteren Seiteawait (deps.sleep ?? warte)(1000)(warte= Timer; nicht vor der Startseite). - Text:
htmlToLines(html)je Seite;pages=[{ url (Endadresse), text, rolle: 'start'|'impressum'|'kontakt', mailtos }]mitmailtos= Adressen aus denmailto:-Hrefs der Seite (vor?, dekodiert). Die Mail kommt nur aus Seiten mit Rolleimpressumoderkontakt: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). - Regex-Auslesen aus den Zeilen der Impressum-, dann der Kontaktseite (kein Modell, nichts erfinden, nichts raten):
- Telefon: Zeile mit
Tel(efon)?|Fon|Phone|Mobil(nichtFax), 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})(Flagu); bei mehreren Treffern gewinnt der mitnormalizeName(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); sonstog:site_nameder Startseite; sonst der erste Abschnitt des<title>(vor|,–oder-); höchstens 120 Zeichen. - Ansprechpartner: nach
Geschäftsführer(in)?,Inhaber(in)?oderVertreten durch(mit oder ohne:) zwei bis vier Wörter mit großem Anfangsbuchstaben, bis Zeilenende, Komma oder „ und “.
- Telefon: Zeile mit
- MX: hat
pickEmaileine Adresse geliefert, prüftmailDomainOk(deps.dns, <Domain der Adresse>);false⇒emailundemailSourcebleiben'',mailProblem 'mx'. impressumUrl= URL der Seite mit der Mail, sonst die der Impressum-Seite, sonst'';websiteOrigin= Ursprung der Endadresse der Startseite;latestYearaus dem Text der Startseite mit dem Jahr ausdeps.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 Feldownvorosmvor Standard (name⇒'nicht verfügbar', Rest'',city⇒run.ort);website=own.websiteOriginwennown.status === 'ok', sonstosm.websiteoder'';sourceUrl=own.impressumUrloderosm.osmUrlodermapsUrl(placeId);phoneundphoneSource(own.phoneSourcebzw.osm.osmUrl) nur wenn!run.isHealth; E-Mail und Quelle nur ausown; kein Feld liestplace.name,place.rating,place.userRatingCountoderplace.websiteUri;updatedAtsetzen. - 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, aufgabenneutralenprompts/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 KonstanteBEA_SYSTEM, damit Änderungen anprompts/bea.mdohne Neustart wirken),ersatzUrteil({ latestYear, nowYear }) → { auditScore, sympathie, kette, belege },rateCandidates(deps, { items, nowYear }) → Promise<{ urteile: Map<id, { auditScore, sympathie, kette, belege }>, ersatz: boolean }>mititems = [{ 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, sonstMath.round(35 - 3.5 * auditScore);scoreBusiness:noChain10 wenn!kette,mail15 wennhasMail,sympathy=sympathie;reasongenau 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'.ausschlussGrundprüft in der ReihenfolgemailProblem⇒ MX,!hasMail⇒ KEINE_MAIL,kette⇒ KETTE, (websiteState === 'vorhanden'undauditScore > MAX_AUDIT_FOR_MAIL) ⇒ WEBSITE_GUT, sonstnull.rankCandidates: Kopie, sortiert nachscoreabsteigend,foundAtaufsteigend,idaufsteigend.ersatzUrteil:latestYear≤nowYear − 5⇒auditScore3; ≤nowYear − 3⇒ 5;null⇒ 5; sonst 7;sympathie5,kettefalse,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 miturteile(Liste aus Objekten mitidstring,auditScoreinteger,sympathieinteger,ketteboolean,belegeListe von Strings), überalladditionalProperties:false.AUFGABE_BETRIEB(Aufgabentext im Code; die Persona steht inprompts/bea.md,beaSystem()hängt ihn perbuildSysteman) 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),kettetrue bei Filial- oder Franchise-Auftritt, höchstens 3 kurze Belege, nichts erfinden. Auswertung: Zahlen auf 0…10 begrenzen und runden,websiteState 'keine'⇒auditScore null,belegehöchstens 3 Strings zu je 80 Zeichen, fehlende Ids ⇒ersatzUrteil; wirft der Aufruf (auchBudgetStop), für alle Ersatzurteile undersatz: true.
- Stufen:
- 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>. Setztstatus 'botlead',botLeadReason(höchstens 500 Zeichen), leert beidropContactemail,emailSource,contactName(Datensparsamkeit: Bot-Leads werden nie angeschrieben), behält Telefon samt Quelle, Punkte und Register-Schlüssel; Ereignisbotlead(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.getliefertnull⇒Error('Interessent fehlt')); iststatusschonbotlead⇒ zurück ohne Schreiben; iststatusnichtgefundenoderbewertetoderthreadIdgesetzt ⇒Error('Bot-Lead nur für Interessenten ohne Kontakt');updatemitstatus,botLeadReason, beidropContactdie drei Felder auf'',updatedAt; Ereignisemit(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(P4src/gespraeche/util.js),mailSlots(T04),pickQuery(T05),PLACES_REQUEST_USD,buildStub,buildPatch,readOwnSite(liest überdeps.fetchunddeps.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' }>;jobist ein P1-Auftrag (id,workerId,payload={ night, run, room, region, candidates, entrance }von P1splanNight;regionist das Bundesland aussettings.regions).runScoutJobbeendet den Auftrag nicht selbst:drainScouts(T24) ruft danachfinishbzw.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.deusw.
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).
assertCap(job.workerId, 'places')(wirftCapErrorfür Rollen ohne diese Fähigkeit);payload = job.payload;SCOUT_ROOMS[payload.room]?.workerIdmussjob.workerIdsein, sonstError('Raum passt nicht zum Scout');now = nowOf(deps),start = deps.clock.now()(Millisekunden). Dann der Reihe nach:mailSlots(deps, { now }).free <= 0⇒ Ereignisleerlauf(Scout, Text „: Alle Mail-Plätze sind vergeben, kein Suchlauf.“, = Rollenname) undgestoppt 'plaetze_voll';checkBudget(deps, 0.2)nicht ok ⇒gestoppt 'budget';pickQuery(deps, { room: payload.room, region: payload.region, now })istnull⇒ Ereignisleerlauf(„… hat alle Abfragen der letzten 90 Tage gesucht.“) undgestoppt 'keine_abfrage'; sonst liefert esgewerk,ort,query,isHealthfür den ganzen Lauf.- Seiten holen (höchstens 3, solange weniger als
payload.candidatesfrische): vor jeder SeitecheckBudget, danndeps.places.textSearch({ query, pageToken })undbook(deps, { workerId, usd: PLACES_REQUEST_USD, counters: { places: 1 } }). - Je Treffer der Reihe nach, erst jetzt: Zeitgrenze (
deps.clock.now() − start > 20 · 60 000⇒gestoppt 'zeit', Schleife verlassen),checkBudget; dannid = '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) undclaimKeys(deps, keysFor({ placeId, website: websiteUri }), { prospectId: id, origin: 'workers' }); bei einem Betrieb, dessenbusinessStatusgesetzt und ≠OPERATIONAList, mitprospectId: ''(es entsteht kein Dokument, ein späterer Fund gilt als „schon bekannt“).fresh === false⇒doppelt++, Ereignisdoppelt(Arne, Text „Schon bekannt: Kandidat in “), weiter. NichtOPERATIONAL⇒ weiter (Schlüssel bleiben, kein Dokument). Sonstdb.create('workersProspects', buildStub(...), id); wirft das einenConflictError(das Dokument besteht, Wiederholung), wird es gelesen: Statusgefunden⇒ die Verarbeitung wird fortgesetzt (abgestürzter Lauf), jeder andere Status ⇒ wiedoppeltüberspringen (Zähler und Ereignis).freshzählt die Kandidaten, die dieser Lauf verarbeitet (Dokument neu angelegt oder fortgesetzt), jeweils mit Ereignisfund(Scout, Text „: neuer Kandidat in ()“). try:readOwnSite(deps, { workerId, website: websiteUri, ort })(T08: liest Startseite, Impressum und Kontaktseite perdeps.fetchohne Modell, liefert bei unbenutzbarer Websitekeine_websiteohne Abruf). OSM-Aufruf (nachassertCap(workerId, 'osm'))deps.osm.lookup({ name: place.name, ort })(Name nur im Speicher), wennown.status !== 'ok'oder Telefon oder Straße fehlen. Hatownkeine Website undosm.websiteist benutzbar ⇒readOwnSite(deps, { workerId, website: osm.website, ort })stattown.- Mail vorhanden (
own.email; eine Adresse ohne MX hat T08 schon verworfen undown.mailProblem 'mx'gesetzt, der Grund „Adresse nicht zustellbar“ entsteht in Schritt 8 überausschlussGrund):isSuppressed⇒fileBotLead(GRUND.GESPERRT);claimKeys(deps, keysFor({ email, phone (nur wenn nicht isHealth), name, postalCode }), …)mitfresh === false⇒fileBotLead(GRUND.BEKANNT)und Ereignisdoppelt. db.updatemitbuildPatch(...); 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).catch:BudgetStop⇒fileBotLead(GRUND.ABGEBROCHEN),gestoppt 'budget', Schleife verlassen; sonstfileBotLead(GRUND.LESEN), Ereignisfehler(Text ohne Namen), weiter.- 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,atjetzt); Ereignisuebergabe(Scout →to 'bea');rateCandidates(ohne Kandidaten kein Aufruf); je KandidatscoreBusiness,updatemitscoreundscoreReason, dannausschlussGrund⇒fileBotLead(grund)sonststatus 'bewertet'; ein Ereignisbewertet(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 (keinfinish).
- 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? })(P4src/gespraeche/threads.js: legt den Faden an und setztprospect.threadId). -
Produces:
selectNightTop3(deps, { now: Date }) → Promise<{ slots, selected: string[], botleads: string[] }>. Auswahl: Interessenten mitentrance 'ohne_website',status 'bewertet', ohnethreadId,foundAtder letzten 72 Stunden; je gewähltem Betrieb ein Faden (überensureThread,toEmail= gespeicherte Adresse), Auftragerstmail(Max,threadId), Ereignistop3(Bea → Max). Zusätzlich werden Nachzügler abgelegt: Interessentenstatus 'gefunden'mitfoundAtälter als 12 Stunden (der Lauf ist abgestürzt, sie wurden nie bewertet) ⇒ Bot-Lead mit GrundGRUND.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'])undQuery.equal('status', ['gefunden'])(Indexentrance+status), im CodefoundAtälter als 12 Stunden ⇒fileBotLead(GRUND.ABGEBROCHEN). Laden der Kandidaten:Query.equal('entrance', ['ohne_website'])undQuery.equal('status', ['bewertet']), danach im Code ohnethreadId. Älter als 72 Stunden ⇒fileBotLead(GRUND.VERALTET). Rest: Punkte< MIN_SCORE_MAIL⇒fileBotLead(GRUND.SCHWELLE); die übrigen (mStück) mitrankCandidatesordnen: Rang1…slots.freebekommen Faden, Auftrag und Ereignis (TextPlatz <n> von <m>: Punktzahl <score>), alle weiterenfileBotLead('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: '' })(setztprospect.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, aufgabenneutralenprompts/max.md),assertCap('max','draft'),normalizeName,SCOUT_ROOMS(Branche); aussrc/mail/rules.js(P4, H3)BANNED_PHRASES,PLACEHOLDER_PATTERNS,COLD_WORDS,countWords(und in TestscheckOutgoing);WEBKLAR_URLaussrc/mail/links.js(P3). -
Produces:
KI_ZEILE,AUFGABE_KALT(Aufgabentext im Code),maxKaltSystem()(=buildSystem('max', AUFGABE_KALT), H5; als Funktion statt KonstanteMAX_KALT_SYSTEM, damit Änderungen anprompts/max.mdohne Neustart wirken),KALT_SCHEMA,qaColdMail({ subject, absaetze }, prospect, { settings }?) → string[](Befunde; mitsettingswird zusätzlich die Gesamtlänge der fertig zusammengesetzten Mail gegenCOLD_WORDSgeprüft),composeColdMail({ variante: 'erst'|'nachfass', prospect, absaetze?, settings, erstDatum? }) → string(Text ohne Pflichtblock, mit genau einer ZeileMehr ü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 P4scheckMailDraft), mit „!“ oder mit „Re:“; Absätze mithttps?:,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 ausBANNED_PHRASES(src/mail/rules.js, klein geschrieben und mit vereinheitlichtem Leerraum verglichen; darunter „Sehr geehrte“); jedes Muster ausPLACEHOLDER_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) ausnormalizeName(prospect.name)nochprospect.citykommt vor. Mit dem dritten Parameter{ settings }setztqaColdMaildie Mail mitcomposeColdMail({ variante: 'erst', … })zusammen und beanstandet eine Gesamtlänge außerhalb vonCOLD_WORDS(countWords; ein sehr langer Angebotssatz bis 400 Zeichen kann die 180 Wörter sonst sprengen und das Tor würde mittext_zu_langverweigern). composeColdMailbaut aus Absätzen getrennt durch Leerzeile: Anrede (Guten Tag <contactName>,oderGuten Tag,),KI_ZEILE, beierstdie Absätze, dannsettings.offerTextwörtlich, dannWenn Sie die Vorschau sehen möchten, antworten Sie einfach mit „Ja“.; beinachfassstatt 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 ZeileMehr über uns: <WEBKLAR_URL>(H3, A22: der eine erlaubte Link, vom Code gesetzt, nie vom Modell; das Modell liefert weiter keine Links); Schluss immerFreundliche 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: BetreffWebsite-Vorschau für <Name>(ist er länger als 55 Zeichen:Website-Vorschau für Ihren Betrieb), AbsätzeWir 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.undSie sehen sich die Vorschau in Ruhe an und entscheiden danach, ob sie Ihnen gefällt.draftColdMail:assertCap('max','draft'); bis zu zwei Versuche mitdeps.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 keinBudgetStopist, Ersatztext (usedFallback: true);BudgetStopwird nicht abgefangen.AUFGABE_KALT(Aufgabentext im Code; die Persona steht inprompts/max.md, P4, aufgabenneutral, und wird vonmaxKaltSystem()perbuildSystemdavor 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.
- Befunde von
- 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: Zweigcold:true), die davon betroffenen P4-Tests (src/mail/gate.jsbleibt unberührt: P4 liest nursettings.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_URLaussrc/mail/links.js(P3, A6);composeColdMail(T14, für den H3-gültigen Testtext);makeDeps,seedProspect,seedThread(T01). -
Produces:
buildColdFooter({ prospect, unsubscribeUrl }) → string(wirftError('Quelle der Adresse fehlt')ohneemailSource; nimmt die Datenschutz-Adresse aussrc/mail/links.js,coldFooter.jsexportiert keine eigene Konstante);appendCompliance(..., cold:true)=text.trimEnd()+ Leerzeile + Pflichtblock, für Erst- und Nachfass-Mail, unabhängig vonfirst;coldChecks({ prospect, thread, to, cold, settings }) → { ok: true } | { ok: false, reason }mit Gründendatenschutz(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
makeDepsaus 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 URLimpressenthält, sonst „Ihrer Kontaktseite“; die Datenschutz-Adresse in der Zeile „Wir nutzen sie …“ istDATENSCHUTZ_URLaussrc/mail/links.jsund 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 ein Angebot zu machen und, wenn keine Antwort kommt, einmal daran zu erinnern (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): Zweigcold⇒${text.trimEnd()}\n\n${buildColdFooter({ prospect, unsubscribeUrl })}(statt P4s kurzem Block);cold:falseunverä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 aufbuildColdFooterbzw. dessen letzte Zeile umgestellt.coldChecks.js(rein): beicold:truein der Reihenfolge Datenschutz-Schalter (settings.privacyInteressentenLivefalsch ⇒datenschutz),entrance !== 'ohne_website'⇒kein_kaltkontakt,statusnichtbewertet|kontaktiert⇒status_unpassend,emailSourceleer oderto(klein geschrieben, getrimmt) ≠prospect.email⇒adresse_nicht_belegt; beicold:false:entrance === 'ohne_website'undthread.lastInboundAtleer ⇒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 alleinsettings.privacyInteressentenLive(A5); ein Env-Schalter existiert nicht.- Step 4: Bestehen sehen. Derselbe Befehl ⇒
# fail 0; danachtimeout 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(setztdeps.drafters.erstmailund.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) undcomposeColdMail({ variante: 'erst', prospect, absaetze, settings }); Ereignisentwurf(Max,prospectId, Text „Erstmail entworfen ()“); Rückgabe{ subject, text }.BudgetStopwird nicht abgefangen.nachfassDrafter(deps, job): Faden (job.threadId) und letzte ausgehende Nachricht (lastOutbound, P4) laden; fehlt eine ⇒Error('Erstmail fehlt');erstDatum= Berliner Datum vonsentAt;composeColdMail({ variante: 'nachfass', prospect, settings, erstDatum }); Ereignisentwurf(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ückgabedeps.scheduleFollowups(deps):sendDayausmailSlots(deps, { now: nowOf(deps) }), Fäden ausfollowupThreads(deps, { sendDay })(T04, dieselbe Definition wie bei den reservierten Plätzen); je Faden mit vorhandenem Interessenten im Statuskontaktiertund ohne irgendeinen Auftragnachfassfür diesen Faden (listAll('workersJobs', [Query.equal('kind', ['nachfass'])]), Rest im Code) genau einenqueue(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)inReplyToundreferencesdes Drafters werden ansendMaildurchgereicht; (2)BudgetStopaus einem Drafter ergibt{ deferred: 'budget' }(Auftrag wird mitreleaseJobfreigegeben, kein Versuch verbraucht); (3) Drafter-Texte laufen nicht durchcheckMailDraft(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 aussrc/mail/rules.js(H3: 60–180 Wörter, genau ein LinkWEBKLAR_URL); eine nicht zurückstellbare H3-Verweigerung ergibt dort Jobskippedplus Pultunsicher. 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; danachtimeout 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(Indexstatus),workersThreads,workersMessages(Index aufthreadId, P3-E9),workersPult,workersOffers;book,nowOf(P4src/gespraeche/util.js). -
Produces:
FRIST_ABSAGE_TAGE = 30,FRIST_RUHE_TAGE = 183,runKuerzen(deps) → Promise<{ gekuerzt: number, uebersprungen: number }>. Regeln: ApurgeAftererreicht und Statusgefunden|bewertet|botlead|kontaktiertohne eingegangene Antwort (thread.lastInboundAtleer) ⇒ kürzen; mit Antwort oder weiterem Status ⇒ nicht kürzen,purgeAfterleeren (ab dann gilt B oder C). B Statusverloren|gesperrtundupdatedAtälter als 30 Tage ⇒ kürzen. C Statuskontaktiert(mit Antwort),gespraech|ja|vorschau_bau|vorschau_gesendet|angebotohnecustomerIdund letzte Aktivität älter als 183 Tage ⇒ kürzen.dealnie. Kürzen = persönliche Felder leeren,name '[gekürzt]',purgedAtsetzen,purgeAfterentfernen, FadentoEmail/subjectleeren, Nachrichtensubject '',body '[gekürzt]',contactSource '', Pult-Einträge des Interessenten (title '[gekürzt]',originalText '',draft '',sourceUrl '') und Kauf-Links (businessName,email,previewUrlje'[gekürzt]');workersKeysundworkersSuppressionbleiben 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 überQuery.equal('status', [...])(Indexstatus), Fristen im Code: A über die Statusgefunden|bewertet|botlead|kontaktiertmitpurgeAfter ≤ jetzt, B überverloren|gesperrt, C überkontaktiert|gespraech|ja|vorschau_bau|vorschau_gesendet|angebot; C zählt nicht als „übersprungen“, wenn die Frist noch läuft. Interessenten mitpurgedAtwerden übersprungen, ohne zu zählen. Letzte Aktivität (C) = größtes ausupdatedAtund den ZeitpunktenlastInboundAt,lastOutboundAtdes Fadens (Faden überprospect.threadId,db.getkannnullliefern). Pult-Einträge und Kauf-Links: je Lauf einmallistAll('workersPult')undlistAll('workersOffers')(kleine Sammlungen, kein Index aufprospectIdnötig) und im Code nachprospectIdder zu kürzenden Interessenten zuordnen. Beim Kürzen leeren:contactName email emailSource phone phoneSource website street postalCode placeId requestText sourceUrl scoreReason;citybleibt (Ortsangabe ohne Personenbezug, für die Auswertung der Suchläufe). Am Endebook(deps, { workerId: 'arne', usd: 0, counters: { gekuerzt: n } })(nur wennn > 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 dieroutes-Registry von P3-E6 ein;src/api.jsbleibt unberührt) - Test:
test/p6-botleads-api.test.js
Interfaces:
-
Consumes:
SCOUT_ROOMS,normalizeName,getDoc(P4src/gespraeche/util.js), SammlungworkersProspects(Indexentrance+status). -
Produces:
class BadRequest extends Error;listBotLeads(deps, { room = '', q = '', page = 1, id = '' }, { pageSize = 50 } = {}) → Promise<{ items, total }>mititem = { id, room, roomLabel, name, street, postalCode, city, website, score, reason, source, sourceUrl, foundAt, isHealth, gekuerzt, phone, phoneSource }(keinemail, keinemailSource;phoneundphoneSourcenur, wennphoneSourcegesetzt undisHealthfalsch, sonst'');BOTLEADS_ROUTES = [{ method: 'GET', pattern: /^\/botleads$/, handler }]mithandler(deps, ctx) → { status, json }(ctx.queryistURLSearchParamsmitroom,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:roomnicht leer und nicht inSCOUT_ROOMS⇒BadRequest('Unbekannter Raum.');pageganzzahlig ≥ 1 sonst 1;qauf 100 Zeichen gekürzt. Mitid:getDoc(deps, 'workersProspects', id), nur wenn der Interessent existiert (null⇒ leer),entrance 'ohne_website'undstatus 'botlead', sonst{ items: [], total: 0 }. Sonstdb.listAll('workersProspects', [Query.equal('entrance', ['ohne_website']), Query.equal('status', ['botlead'])]), im Speicher nachroomfiltern,q: alle Wörter vonnormalizeName(q)müssen innormalizeName(name + ' ' + city + ' ' + botLeadReason + ' ' + roomLabel)vorkommen; SortierungfoundAtabsteigend, dannscoreabsteigend;totalvor dem Seitenschnitt; Seite 1-basiert.gekuerzt = Boolean(purgedAt).BOTLEADS_ROUTESwie unter „Produces“: der Handler liestroom,q,page,idausctx.query(pageals Zahl, leer ⇒ 1), fängtBadRequestund gibt{ status: 400, json: { error: e.message } }zurück; die Team-Prüfung (Labelmitarbeiteroderadmin) übernimmt die Registry (P3-E6). Die Eintragung inbuildRoutes()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_TABSum den Reiterbotleadsergänzen),src/styles/workers.css(Klassenwk-bl-*);src/lib/workersApi.jsbleibt unberührt (P2-T04 reichtidbeibotleadsschon durch) - Test:
tests/unit/botleadsView.test.mjs
Interfaces:
-
Consumes:
workersApi.botleads(params) → { items, total }(P2-T04:createWorkersApi, Instanz insrc/lib/workersClient.js;params = { room, q, page, id },idreicht 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.jsxhältWORKERS_TABS(Idshochhaus,freigabevon P3,botleadsvon P6) und die Instanzconst api = workersApi; der Reiter wird über?tab=botleadsgewählt. Zeiger: Das Durchreichen vonid(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 modulebzw. DateiBotLeadList.jsxfehlt. - 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)intry, nur Protokollhttp:oderhttps:⇒href, sonst'';phoneLabel:isHealth⇒ „bewusst nicht gespeichert“, sonst Nummer wennphoneundphoneSourcegesetzt, sonst „nicht verfügbar“;botleadRow:title = name || 'nicht verfügbar',place = [postalCode, city].filter(Boolean).join(' '),scoreLabel = '<n> Punkte',found= DatumTT.MM.JJJJin Europe/Berlin;pageInfo:pages = max(1, ceil(total / pageSize)), LabelSeite <p> von <n> · <total> Einträge, beitotal 0„Keine Einträge“.BotLeadList.jsx(WOMS-Weißdesign, Klassen ausglobal.css:.toolbar,.select,.input,.table,.empty,.msg.err,.btn): Kopfzeile mit Hinweis „Reine Liste: keine Anrufe, kein Mailversand.“, Raumfilter (<select>ausROOM_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 wennsourceHrefnicht leer,target="_blank" rel="noopener noreferrer"), Gefunden; gekürzte Einträge grau mit „gekürzt“; Leerzustand „Noch keine Bot-Leads.“; Fehler als.msg.errmit dem Satz aus der API; Ladeanfragen mit Laufnummer (nur die neueste zählt); Parameterbotin der Adresszeile hebt die Zeile hervor und ruft die API mitidab; Seitenwechsel „Zurück“/„Weiter“ mitpageInfo. KeindangerouslySetInnerHTML, kein Polling.workersApi.js: nichts zu tun,botleads({ room, q, page, id })kenntidseit P2-T04 (Query-Reihenfolgeroom,q,page,id).WorkersPage.jsx:WORKERS_TABSum{ id: 'botleads', label: 'Bot-Leads' }ergänzen (nachfreigabe), im Inhalt dieses Reiters<BotLeadList api={api} />rendern;workers.css: Klassen mit Präfixwk-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, dassiddurchgereicht 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"⇒ Zeilebuilt 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
#interessentenaufhttps://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 undsettings(Ü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 Hilfenread,strip,betweenund die KonstanteDSschon 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 inpublic/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; danncd /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. Danntimeout 900 node --max-old-space-size=1024 --test test/legalTexts.test.js⇒# fail 0(inklusivepdf_pruefsummeunddatenschutz_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(nurexportvor 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>mitdeps = { fetch, schluessel, log, git, dateien }wie das Vorbild. Kartedatenschutz; Dateiendocs/WEBklar-Datenschutzerklaerung.pdfundpublic/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 AufrufdokumentFuer('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.mjsnurexportergänzen vorBUCKET,DB,KEY_DATEI,ROOT,client,alleDokumente,versionen,neueId,ddmmjjjj(kein Verhalten ändern; möglicher Merge-Konflikt mitfeature/kundenansicht-neubleibt auf diese Zeilen begrenzt).datenschutz-hochladen.mjsnach dem Muster der Funktionhochladendes Vorbilds: Schlüssel ausdeps.schluessel(Standard DateiKEY_DATEI), Kartedatenschutzmuss existieren (sonst Meldung und Exit 1 vor jedem Schreiben), Dateien müssen existieren,git status --porcelain -- <beide Dateien>muss leer sein,commitpergit rev-parse --short HEAD,planVersion⇒schon⇒ Exit 0 ohne Schreiben; Reihenfolge: erst beide Dateien in den Bucketlegal-files(PDF, dann HTML), dann Versionsdokument (versionausplanVersion,validFrom= Freigabe,validTo null,status 'current',fileIds,source,changeNote), zuletzt die bisherigecurrent-Version aufsupersededmitvalidTo = planVersion.validToAltes.--probeliest 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(Basishttps://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 nutztziel; 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üfungsystemd-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 ProbecreatePlacesClient,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>mitdeps = { db, clock, log, config, fetchImpl? }und den Aufrufendatenschutz --check | --set | --off(A5: derselbe SchalterprivacyInteressentenLivewie P3sdatenschutz-schalter.mjs,--seterst nach der Seitenprüfung) sowieraeume --set <a,b> | --clear(die Branchen-Räume setzt Kenso hiermit, P3-E7; P3ssyncConnectedRoomslässt sie unangetastet). Produces (probe-scout.mjs):parseArgs(argv) → { room, query, max },main(argv, deps)mitdeps = { 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 ausPFLICHTim Klartext der Seite (Tags entfernt, Leerraum vereinheitlicht);fehltenthält die Meldung bzw. den Satz.main: Aufrufform prüfen (sonst Hinweis und 1).datenschutz: Seitehttps://project.webklar.com/datenschutz.htmlmitdeps.fetchImpl ?? fetchholen,--checkund--setprüfen und die Funde ausgeben;--setsetzt nur beioküberupdateSettings(deps, { privacyInteressentenLive: true }, 'script:p6-freischalten');--offsetztfalseohne Prüfung (derselbe Schalter wie P3sscripts/datenschutz-schalter.mjs --an|--aus, aber--setsetzt ihn erst nach der Seitenprüfung; er gilt auch für Claras Erstantworten).raeume: Namen gegenSCOUT_ROOMSprüfen (unbekannt ⇒ 1, nichts schreiben),deps.config.places?.apiKeymuss gesetzt sein (sonst 1),--setergänztconnectedRoomsohne Duplikate,--clearentfernt 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(--roomPflicht und Scout-Raum,--queryStandard<erstes Gewerk> in Kaiserslautern,--max1…5, Standard 3, sonstErrormit „Aufruf: …“; die Argumente bleiben unverändert).main: (1) eine Seite Places, ZeilePlaces: <n> Treffer, davon mit Website: <k>; (2) für die erstenmaxTreffer mit WebsitereadOwnSite(deps, { workerId: <Scout des Raums>, website, ort })(H2: Impressum und Kontaktseite perdeps.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=neinbeimailProblem '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 bautdepsausloadConfig(process.env):places = createPlacesClient(...),fetch,dnsausnode: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 nachrankOf, H1),finish,fail(deps, id, error, { retry }),planNight,rankOf(src/priority.js); P4:runSenden,registerGateCheck; P3:buildTickJobs(),buildRoutes()insrc/main.js,deps.fetch. -
Produces:
SCOUT_WORKER_IDS(Wiederausfuhr aus T03);SCOUT_KINDS = ['suche'](die Auftragsarten, diedrainScoutsholt; nachrankOfgeordnet, H1);erweitereDeps(base, config) → deps(E4: setztplaces,osm,dns; keinllm;deps.fetchunddeps.claudebleiben unangetastet);registerColdPieces(deps) → unregister(setztdeps.draftersüberregisterColdDraftersund hängtregisterColdGateCheck()ein; einmal instart);drainScouts(deps, { maxJobs = 2 } = {}) → Promise<{ fertig, fehler }>(holtsuche-Aufträge nur der Scouts, ruftrunScoutJob, danachfinish; wirft der Lauf,fail(deps, job.id, <Meldung ohne Schlüssel>, { retry: false })und Ereignisfehler(Scout, Text „Suchlauf fehlgeschlagen“), kein Absturz; Aufträge von Rita und Finn bleiben liegen, die holt P3);kickScouts(deps)(startetdrainScoutsohneawait, mit Wächter gegen Überlappung nach dem MusterkickWerkstattvon P4, damit ein Lauf von bis zu 20 Minuten den 60-s-Tick nicht blockiert);top3Tick(deps)(now = nowOf(deps); erstscheduleFollowups, dannselectNightTop3);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 alstickJobsundBOTLEADS_ROUTESalsroutes. -
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.jsnach den Signaturen oben.erweitereDeps:places = createPlacesClient({ apiKey: config.places?.apiKey })(config.placesistnull, wenn die Gruppe ungesetzt ist; fehlt der Schlüssel, wirfttextSearchmitPlacesError('Places-Schlüssel fehlt.', 0)),osm = createOsmClient({ sleep: (ms) => new Promise((r) => setTimeout(r, ms)) }),dns = { resolveMx, resolve4 }ausnode:dns/promises; Rückgabe{ ...base, places, osm, dns }(keinllm,fetchundclaudeunverändert).registerColdPieces(deps):registerColdDrafters(deps),const aus = registerColdGateCheck(), Rückgabeaus.drainScouts: Schleife bismaxJobs:job = await next(deps, { kinds: SCOUT_KINDS, workerIds: SCOUT_WORKER_IDS })(SCOUT_KINDS = ['suche'];nextfragt die Arten nachrankOfab, H1),null⇒ Ende;try { finish(deps, job.id, await runScoutJob(deps, job)) } catch:fail(...),emitfehler, Zählerfehler.kickScouts: Modulvariablelaeuft; ist sie gesetzt, sofort zurück; sonstlaeuft = drainScouts(deps).catch((e) => deps.log(…Meldung ohne Schlüssel…)).finally(() => { laeuft = null })ohneawait.src/main.js(Stellen laut Vorprüfung, A4):buildDepsruft am EndeerweitereDeps(deps, config)(nach P3sfetch, das nicht überschrieben wird);buildTickJobs()hängt...ohneWebsiteTickJobs(=tickJobs) an;buildRoutes()hängt...BOTLEADS_ROUTES(=routes) an;startruft einmalregisterColdPieces(deps)und ruft das zurückgegebeneausbeim 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, danntimeout 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)
Zwei Teile, in dieser Reihenfolge. Teil A bringt den Datenschutztext live und hängt nicht am Dienst. Er muss fertig sein, bevor du in P3 Block G (Clara einschalten) oder P4 Block H (Schalter, Dauerbetrieb) den Datenschutz-Schalter setzt, denn beide verlangen den veröffentlichten Abschnitt „Interessenten“. Teil B nimmt die Scouts und die automatische Kaltmail in Betrieb, erst nach P1 bis P4 (jeweils bis zu ihrem „Fertig, wenn“) und wenn P5 live ist oder du den Kaufweg bewusst von Hand machst. [Claude] macht Claude für dich (merged, testet, committet), alles andere tippst du selbst auf dem Server, jeder Befehl in seinem Kasten. Zugangsdaten tippst du nur selbst ein (read -rs), nie in einen Chat und nie in einen Befehl, der sie ausgibt; die .env zeigst du nie an, die Befehle unten zählen nur Zeilen. Nach jeder Änderung an .env oder docker-compose.yml startest du mit cd /home/webklar/apps && docker compose up -d --no-deps workers neu (nie docker restart: das liest .env nicht neu ein; nie ein nacktes docker compose up -d). Wird ein git push abgelehnt: nie --force, Claude Bescheid. Agenten führen von diesen Befehlen nichts aus.
Was P6 tut: Jede Nacht um 22:00 schickt Otto einen der vier Scouts los (Ali: Ärzte & Praxen, Hanna: Handwerk, Sven: Selbstständige, Lea: Läden & Gastro). Der Scout holt bei Google Places bis zu 20 Treffer je Anfrage (höchstens drei Anfragen pro Lauf), liest die eigene Website jedes neuen Betriebs (Impressum und Kontaktseite, ohne KI), ergänzt aus OpenStreetMap, und Bea bewertet die Kandidaten. Um 05:30 wählt Bea die Top 3 (so viele, wie am Sendetag Mail-Plätze frei sind), Max schreibt jedem eine Kaltmail, sie geht Montag bis Freitag zwischen 09:00 und 16:00 hinaus; nach etwa sieben Tagen folgt einmal eine Erinnerung. Alle anderen Kandidaten stehen mit Grund im Reiter „Bot-Leads“. Um 03:00 kürzt Arne abgelaufene Daten, um 03:40 löscht der Dienst alte Antworten im Antwort-Postfach. Achtung: Das sind echte Werbemails an Fremde und echte Kosten bei Google, Anthropic und Resend. Eine Mail geht nur an eine Adresse, die im Impressum oder auf der Kontaktseite des Betriebs steht; ein Betrieb ganz ohne Website kann also nie eine Mail bekommen, die Mails gehen fast nur an veraltete Websites. Erwartete Kosten: unter 0,3 $ pro Nacht (F6).
TEIL A: Datenschutztext live (unabhängig vom Dienst)
Ziel: Der Abschnitt 11 „Interessenten“ steht auf https://project.webklar.com/datenschutz.html (Anker #interessenten), webklar.com/datenschutz#interessenten führt dorthin (jede Mail verlinkt https://www.webklar.com/datenschutz#interessenten), und die Fassung liegt in WOMS → Rechtliches. Der Text liegt im Portal-Worktree /home/webklar/apps/kundenbereich.webklar.com/dev-workers-datenschutz (Zweig feature/workers-datenschutz), die Weiterleitung im Website-Worktree /home/webklar/apps/webklar.com/dev-workers-ds (Zweig fix/datenschutz-anker). Mach Stand, Portal-Push und Upload am selben Tag.
1. Vorbedingungen (du, vor Schritt 5). Kein Befehl. (a) Lies die Nutzungsbedingungen und die Acceptable Use Policy von Resend: Ist die erste geschäftliche E-Mail an ein Unternehmen (B2B, höchstens drei pro Tag) erlaubt? Schreib Claude eine Zeile, „erlaubt“ oder „Risiko einer Kontosperre bewusst getragen“; Claude hält sie in der Notiz zu P6 fest. (b) Nimm im Resend-Konto und im Cloudflare-Konto den Auftragsverarbeitungsvertrag (mit Standardvertragsklauseln) an und leg ihn ab; der Text verspricht beides. (c) Frag die Kanzlei: Bot-Leads (Betriebe, die wir nicht anschreiben, aber bis zu 90 Tage mit Namen speichern) erfahren davon nur über diesen öffentlichen Text (Art. 14 Abs. 5 lit. b DSGVO, unverhältnismäßiger Aufwand), und ob die Nutzungsbedingungen der Google Maps Platform erlauben, was die Scouts tun (gespeichert wird nur die Place-ID, dazu die selbst gelesene Website-Adresse). (d) In WOMS → Hustler → Einstellungen bleiben „Nachfassen nach (Tage)“ bei 7, „Aufbewahrung (Tage)“ bei höchstens 90 und „Unverlangte Mails pro Tag“ bei höchstens 3; der Text verspricht genau das. Erwartet: (a), (b) und (d) erledigt. Wenn nicht: (a) oder (b) offen = Schritt 5 nicht ausführen; (c) offen = Teil A trotzdem fertigstellen, aber Block E in Teil B erst nach der Antwort.
2. Text lesen und freigeben (du). Zeigt dir die Abschnitte 11, 12 und 14 im Klartext:
cd /home/webklar/apps/kundenbereich.webklar.com/dev-workers-datenschutz && sed -n '/<a id="interessenten">/,/<h2>13\./p;/<h2>14\./,/<h2>15\./p' public/datenschutz.html | sed 's/<[^>]*>//g' | grep -v '^[[:space:]]*$' | fold -s -w 110
Prüfe diese Aussagen:
- Quellen: Website des Betriebs (die E-Mail-Adresse nur aus Impressum oder Kontaktseite, nie geraten), OpenStreetMap, Google Maps (nur die Kennung des Eintrags, die Place-ID); Telefonnummern nur, wenn öffentlich genannt, bei Heilberufen nie.
- Umfang: höchstens zwei E-Mails je Betrieb (Angebot, Erinnerung nach etwa sieben Tagen), höchstens drei neue Betriebe pro Tag, KI-Hinweis in jeder Nachricht, Antworten sortiert ein KI-Assistent, Beschwerden bearbeitet ein Mitarbeiter.
- Empfänger (Abschnitt 11 und 12): Hetzner (Daten und Antwort-Postfach), Anthropic Ireland, Limited (auch öffentliche Website-Texte des Betriebs für die Bewertung; gegen die aktuellen Anthropic-Bedingungen prüfen), Resend, Inc. (Versand), Cloudflare, Inc. (Weiterleitung der Antworten), Overpass-API (Suche nach Name und Ort); Standardvertragsklauseln nennt der Text für Anthropic, Resend und Cloudflare; dazu „Abweichend von Abschnitt 8“ für diese Mails.
- Fristen: 90 Tage nach dem Fund, 30 Tage nach Absage oder Widerspruch, sechs Monate nach der letzten Nachricht (der Dienst kürzt etwas früher: nach 89, 29 und 180 Tagen); Antworten im Postfach spätestens 30 Tage nach Eingang, bei Widerspruch sofort (das machst du von Hand, G3); das Versandprotokoll löscht Resend nach eigener Frist.
- Was nach dem Kürzen bleibt: nur Hash-Werte (E-Mail, Domain, Telefon, Place-ID, Anzeigenkennung, Name mit Postleitzahl) sowie Ort, Branche, Punktzahl und Status ohne Namen und Kontaktdaten.
- Rechte: Information nach Art. 14 hier und in der ersten Nachricht, Widerspruch nach Art. 21 (Kasten mit Antwort, Abmelde-Link und support@webklar.com), Auskunft und weitere Rechte in Abschnitt 13, keine automatisierte Einzelentscheidung nach Art. 22 (Abschnitt 14: die Bewertung entscheidet nur, ob und wann wir schreiben).
- Fragen an dich: die Kanzleifrage aus Schritt 1 (c); der Fuß jeder Kaltmail sagt jetzt „Wir nutzen sie, um Ihnen ein Angebot zu machen und, wenn keine Antwort kommt, einmal daran zu erinnern“ (passend zum Text; steht in
src/mail/coldFooter.js): lies ihn in der Rechtsrunde mit.
Das Prüf-Skript verlangt später im Abschnitt 11 wörtlich diese Wendungen: „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)“, „höchstens zwei E-Mails“, „höchstens drei Betriebe pro Tag“, „KI-Assistent im Auftrag von WEBklar“, „Resend, Inc.“, „Cloudflare, Inc.“, „Hetzner Online GmbH“, „spätestens 30 Tage nach Eingang“, „Place-ID“, „bei Heilberufen nie“, „Hash-Werte“ und „Abweichend von Abschnitt 8“. Ändert dein Wortlaut eine davon, sag es Claude: er zieht die Liste in scripts/p6-freischalten.mjs und den Test legal_datenschutz_interessenten mit, sonst bleibt der Schalter in E3 zu. Erwartet: du gibst frei oder nennst Änderungen (Claude ändert nur public/datenschutz.html und test/legalTexts.test.js). Wenn nicht: nicht pushen, bevor du freigegeben hast.
3. [Claude] Stand setzen. Der Stand ist der Tag des Portal-Pushs und des Uploads. Ist das der 20.10.2026 (der Stand im Text), tut Claude nichts. Sonst ändert er genau eine Zeile (Beispiel 22.10.2026) und druckt in Schritt 4 das PDF neu:
cd /home/webklar/apps/kundenbereich.webklar.com/dev-workers-datenschutz && sed -i 's|<p class="meta">Stand [0-9.]*</p>|<p class="meta">Stand 22.10.2026</p>|' public/datenschutz.html && command grep -c '<p class="meta">Stand 22.10.2026</p>' public/datenschutz.html
Erwartet: 1. Wenn nicht: 0 = die Zeile hat ein anderes Format, Claude liest die Datei.
4. [Claude] Portal vorbereiten. Zuerst den Stand von origin/main:
cd /home/webklar/apps/kundenbereich.webklar.com/dev-workers-datenschutz && git status -sb | head -n3 && git fetch origin && git log --oneline HEAD..origin/main
Erwartet: die Zeile ## feature/workers-datenschutz...origin/main [ahead N] (nach Schritt 3 darunter M public/datenschutz.html) und eine leere Liste. Ist die Liste nicht leer: git merge origin/main (nie --force, nie rebase); bei einem Konflikt in public/datenschutz.html beide Änderungen behalten und das spätere Stand-Datum nehmen; PDF und .sha256 nie zusammenführen, eine Seite wählen und neu drucken (FORCE=1 vor den Befehl). Dann das PDF (es druckt nur, wenn der Text sich seit dem letzten Druck geändert hat):
free -m | sed -n 2p && flock -w 3600 /run/lock/hustler-schwer.lock systemd-run --scope -q -p MemoryMax=700M -p MemorySwapMax=0 nice scripts/build-legal-pdfs.sh datenschutz
Erwartet: „available“ mindestens 2500, danach -> docs/WEBklar-Datenschutzerklaerung.pdf (neu gedruckt) oder = docs/WEBklar-Datenschutzerklaerung.pdf (unveraendert) (das PDF passt schon zum Text). Dann die Rechtstext-Tests:
flock -w 3600 /run/lock/hustler-schwer.lock timeout 900 node --max-old-space-size=1024 --test --test-concurrency=2 test/legalTexts.test.js test/legalDocs.test.js test/buildLegalHtml.test.js test/legalV6Build.test.js test/analyseLegal.test.js test/shopTabRechtliches.test.js test/datenschutzHochladen.test.js test/rechtlichesHochladen.test.js
Erwartet: # fail 0. Zuletzt, falls sich etwas geändert hat, committen (danach git status --short leer):
git add public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf docs/WEBklar-Datenschutzerklaerung.pdf.sha256 && git commit -m "Datenschutz: Stand und Zusammenführung" -m "Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>"
Wenn nicht: ein roter Test oder available unter 2500 = nicht pushen; bei Speichermangel 60 Sekunden warten und wiederholen.
5. Portal pushen (du). Das Portal ist beim Deploy 1 bis 2 Minuten nicht erreichbar (der Start installiert Pakete): push außerhalb der Geschäftszeit und stimm mit P5 A8 ab, wer zuerst pusht (P5 führt in seinem C1 ohnehin den dann aktuellen origin/main ein).
git -C /home/webklar/apps/kundenbereich.webklar.com/dev-workers-datenschutz push origin feature/workers-datenschutz:main
Erwartet: Push angenommen. Wenn nicht: „non-fast-forward“ = main hat neue Commits: nie erzwingen, Claude wiederholt Schritt 4.
6. Portal-Seite und PDF prüfen (du). Nach 1 bis 2 Minuten:
curl -s https://project.webklar.com/datenschutz.html | command grep -o 'id="interessenten"\|<p class="meta">Stand [0-9.]*'
Erwartet: zwei Zeilen, erst <p class="meta">Stand TT.MM.JJJJ (dein Stand), dann id="interessenten". Dazu legt dir Claude das PDF docs/WEBklar-Datenschutzerklaerung.pdf als Datei vor: oben derselbe Stand, und „11. Interessenten“, „Resend“ und „Cloudflare“ kommen vor (auf dem Host gibt es kein pdftotext). Wenn nicht: keine Zeile oder noch der alte Stand = 2 Minuten warten, dann Claude (Deploy-Hook).
7. [Claude] Website vorbereiten.
cd /home/webklar/apps/webklar.com/dev-workers-ds && git status -sb | head -n3 && git fetch origin && git log --oneline HEAD..origin/main
Erwartet: ## fix/datenschutz-anker...origin/main [ahead 1] und eine leere Liste (sonst git merge origin/main; hat der Merge Dateien unter src/ geändert, danach zusätzlich einmal alle Tests mit demselben Befehl ohne Dateinamen). Der Test (node_modules zeigt auf den Live-Baum: kein Cache schreiben):
M=$(mktemp) && flock -w 3600 /run/lock/hustler-schwer.lock timeout 600 node --max-old-space-size=1024 node_modules/vitest/vitest.mjs run --no-cache --maxWorkers=2 src/test/datenschutz-weiterleitung.test.tsx; find /home/webklar/apps/webklar.com/build/node_modules/.vite -newer "$M" 2>/dev/null | head -n1; rm -f "$M"
Erwartet: Tests 4 passed (4) und keine Zeile von find. Wenn nicht: ein roter Test oder eine find-Zeile = nicht pushen, Claude.
8. Website pushen und im Browser prüfen (du).
git -C /home/webklar/apps/webklar.com/dev-workers-ds push origin fix/datenschutz-anker:main
Erwartet: Push angenommen; der Deploy-Hook antwortet sofort mit 202, nach etwa 3 Minuten ist die Seite neu gebaut. Prüfe nur im Browser (curl sieht nur die Hülle der Seite, die Weiterleitung läuft in JavaScript): https://www.webklar.com/datenschutz#interessenten (leitet auf webklar.com um, der Anker bleibt) und https://webklar.com/datenschutz#interessenten müssen auf https://project.webklar.com/datenschutz.html#interessenten enden und bei „11. Interessenten“ stehen. Öffne dazu https://www.webklar.com/impressum: das Impressum muss erscheinen (jede Kaltmail verlinkt es im Fuß, und die Anbieterzeile der Mail muss ihm entsprechen). Wenn nicht: nach 3 Minuten noch die alte Weiterleitung (sie verliert den Anker) = Deploy von Hand anstoßen:
docker exec webklar-webclaw-build sh -c 'DEPLOY_TRIGGER=manual sh "${WEBCLAW_DEPLOY_SCRIPT:-/deploy.sh}"'
9. Weiterleitung auch ohne JavaScript (du, empfohlen). Ohne sie scheitert der Link in jeder Mail für Mail-Scanner und Browser ohne JavaScript, und die curl-Prüfungen in P3 G2 und P4 G3/H1 (curl -sL https://www.webklar.com/datenschutz | grep -Ec 'id=.interessenten') bleiben auf 0. Ein Eintrag in der nginx-Datei der Website (Sicherung zuerst; die Datei wird an Ort und Stelle überschrieben, damit der Container sie sieht):
cd /home/webklar/apps/webklar.com/nginx && B=webklar.com.conf.bak-$(date +%F)-workers-p6 && cp -p webklar.com.conf "$B" && sed '/^ # SPA Routing/i\ # Datenschutz liegt im Portal; der Anker (#interessenten aus der Kaltmail) bleibt bei der Weiterleitung erhalten\n location = /datenschutz { return 301 https://project.webklar.com/datenschutz.html; }\n' "$B" > webklar.com.conf && docker exec webklar-webclaw nginx -t && docker exec webklar-webclaw nginx -s reload
Erwartet: nginx -t meldet syntax is ok und test is successful. Dann:
curl -sI https://webklar.com/datenschutz | command grep -i '^HTTP\|^location'; curl -sL https://www.webklar.com/datenschutz | command grep -c 'id="interessenten"'
Erwartet: HTTP/2 301, location: https://project.webklar.com/datenschutz.html, dann 1; im Browser springt https://www.webklar.com/datenschutz#interessenten weiter zum Abschnitt (der Browser hängt den Anker an die 301 an). Wenn nicht: nginx -t rot = cp -p webklar.com.conf.bak-<Datum>-workers-p6 webklar.com.conf und Claude; ohne diesen Schritt ersetzt du die Prüfungen in P3 und P4 durch Schritt 6.
10. Upload nach WOMS → Rechtliches (du, oder Claude nach deinem „Go“). Der Schlüssel: Appwrite-Konsole (Projekt 6a1058610003c5a13a05 → Integrationen → API-Schlüssel) → Schlüssel Rechtliches-Upload, Ablauf 1 Tag, Bereiche files.read, files.write, documents.read, documents.write. Dann ablegen (verdeckt eintippen):
( read -rs -p 'Schlüssel: ' K; echo; umask 077; printf '%s' "$K" > /root/.appwrite-rechtliches.key )
Probe, nur lesend (der Stand im Ordner muss der gepushte sein):
cd /home/webklar/apps/kundenbereich.webklar.com/dev-workers-datenschutz && git status -sb | head -n1 && git status --short -- public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf && timeout 300 node --max-old-space-size=1024 scripts/datenschutz-hochladen.mjs --probe
Erwartet: die Statuszeile ohne [ahead …], sonst keine Ausgabe von git status, eine Zeile datenschutz: v1:current … (die Versionsliste) und zuletzt Schlüssel ok (files.read + documents.read), Karte datenschutz vorhanden. Wenn nicht: STOPP: Karte fehlt: datenschutz = nichts hochladen, erst die Karte in WOMS → Rechtliches anlegen; 401 oder „missing scope“ = falscher Schlüssel. Dann hochladen, mit dem Tag aus dem Stand (der Stand in der HTML muss genau dieses Datum tragen):
timeout 300 node --max-old-space-size=1024 scripts/datenschutz-hochladen.mjs --freigabe=JJJJ-MM-TT
Erwartet: + datenschutz vN (2 Dateien), bisherige Version abgelöst, Exit 0. Wenn nicht: Abbruch: Stand … in public/datenschutz.html passt nicht zur Freigabe … = Datum falsch gesetzt; Abbruch: nicht committeter Stand … = Claude. Ein zweiter Lauf meldet = datenschutz: schon hochgeladen und schreibt nichts; das beweist aber nicht, dass die alte Version abgelöst wurde, prüfe es in WOMS: Rechtliches → Unsere Rechtstexte → Karte zur Datenschutzerklärung zeigt vN · gilt seit TT.MM.JJJJ; im „Verlauf“ trägt genau eine Version „gültig“, die bisherige einen Zeitraum bis zum Vortag, der Vermerk lautet „Fassung vom TT.MM.JJJJ: Abschnitt 11 Interessenten (Ansprache per E-Mail)“, und der PDF-Link zeigt denselben Stand wie Schritt 6. Stehen zwei Versionen auf „gültig“, setzt Claude in der Appwrite-Konsole (Sammlung legalVersions) die alte auf superseded mit validTo am Vortag. Danach den Schlüssel in der Konsole löschen und die Datei:
rm -f /root/.appwrite-rechtliches.key
Fertig, wenn (Teil A): Schritt 6, 8 und 10 ihre „Erwartet“-Zeilen erfüllen. Jetzt darfst du P3 Block G und P4 Block H machen; ihre curl-Prüfung auf www.webklar.com/datenschutz wird erst mit Schritt 9 grün, ohne ihn nimm stattdessen die Prüfung aus Schritt 6.
TEIL B: Scouts und Kaltmail
Reihenfolge einhalten: Block A bis H. Die Kommentare in scripts/p6-freischalten.mjs und scripts/probe-scout.mjs verweisen noch auf die Nummern 5, 7, 8 und 11 einer früheren Fassung dieser Anleitung; gemeint sind die Probe (D2, D3), der Schalter (E1 bis E3), die Räume (E8) und der Not-Aus (H1).
A. Voraussetzungen prüfen
A1. Teil A ist durch, P1 bis P4 laufen.
curl -s https://project.webklar.com/datenschutz.html | command grep -c 'id="interessenten"' && curl -s https://ticket.webklar.com/api/workers/health && echo && docker ps --filter name=webklar-workers --format '{{.Names}} {{.Status}}'
Erwartet: 1, {"ok":true,"version":"0.1.0"} und webklar-workers Up …. Wenn nicht: 0 = Teil A, Schritt 5 bis 10; keine Gesundheitsantwort = erst die Anleitungen von P1 bis P4 abarbeiten.
A2. Absender-Domain D, Resend und Cloudflare. D ist eine eigene Domain nur für diese Mails, nie webklar.com und keine Subdomain davon (das Sende-Tor verweigert sie).
D=$(docker exec webklar-workers printenv WORKERS_SMTP_FROM | sed 's/.*@//; s/[> ].*//') && docker exec webklar-workers sh -c 'printenv WORKERS_SMTP_HOST WORKERS_SMTP_PORT WORKERS_SMTP_USER' && echo "Domain: $D" && dig +short MX "$D" && dig +short TXT "$D" && dig +short TXT "resend._domainkey.$D" && dig +short MX "send.$D" && dig +short TXT "_dmarc.$D"
Erwartet: smtp.resend.com, 465, resend; Domain: <D>; dreimal route…mx.cloudflare.net; eine TXT-Zeile v=spf1 include:_spf.mx.cloudflare.net …; eine TXT-Zeile mit p= (DKIM von Resend); eine MX-Zeile für send.<D>; v=DMARC1; p=none; … ohne Berichtsadresse, die auf das Antwort-Postfach zeigt. Im Resend-Konto: Domain „Verified“, Region eu-west-1 (Irland), Open tracking und Click tracking aus (kein Tracking-Pixel, ein Klick-Tracking schriebe den einen erlaubten Link um), API-Schlüssel nur mit „Sending access“ für diese Domain; notiere dir die Aufbewahrungsdauer der gesendeten Mails in deinem Resend-Plan (der Datenschutztext sagt „nach seiner eigenen Aufbewahrungsfrist“). Wenn nicht: richte es so ein. (1) Domain als Zone in Cloudflare (Nameserver beim Registrar umstellen; Email Routing braucht Cloudflare-DNS). (2) Cloudflare → Email Routing: Ziel = das Hetzner-Antwort-Postfach (Bestätigungsmail anklicken), Regel max@<D> an dieses Ziel, keine Catch-all-Regel; das Postfach darf nur Antworten des Hustlers bekommen (jede Mail ohne Faden wird ein Pult-Eintrag), Spam-Verschieben aus oder den Cloudflare-Absender freigeben. (3) Resend → Domains → <D> hinzufügen (eu-west-1), die angezeigten Einträge unverändert übernehmen (DKIM-TXT resend._domainkey, MX und SPF-TXT auf send.<D>), Tracking aus. (4) DNS _dmarc.<D> TXT v=DMARC1; p=none; rua=mailto:<Adresse, nicht das Antwort-Postfach> (nach zwei Wochen ohne Probleme p=quarantine). (5) .env: Führe zuerst B1 aus (die Sicherungen; B1 lässt sich vorziehen und schadet beim zweiten Mal nicht). Dann ersetzt du die fünf SMTP-Zeilen (der Resend-Schlüssel ist ein anderes Geheimnis als das IMAP-Passwort; die IMAP-Zeilen aus P4 B2/B3 mit Hetzner-Postfach und Port 993 bleiben unberührt; max@<D> durch deine Adresse ersetzen, den Schlüssel tippst du verdeckt):
cd /home/webklar/apps && sed -i '/^WORKERS_SMTP_\(HOST\|PORT\|USER\|PASS\|FROM\)=/d' .env && printf 'WORKERS_SMTP_HOST=smtp.resend.com\nWORKERS_SMTP_PORT=465\nWORKERS_SMTP_USER=resend\nWORKERS_SMTP_FROM=%s\n' 'max@<D>' >> .env && ( read -rs -p 'Resend-API-Schlüssel: ' K; echo; printf 'WORKERS_SMTP_PASS=%s\n' "$K" >> .env )
Danach Neustart wie oben (cd /home/webklar/apps && docker compose up -d --no-deps workers); die Zeilenzahlen prüft C3.
A3. Das Antwort-Postfach wird gelesen und darf löschen.
docker exec webklar-workers cat /data/imap-state.json
Erwartet: ein Eintrag "antworten":{"uidValidity":…,"lastUid":…}. Dann schick von Gmail eine Mail an max@<D>: sie liegt nach wenigen Minuten im Posteingang (nicht im Spam) des Hetzner-Postfachs, und nach 2 Minuten steht im Pult „Mail ohne Faden“ (erledige ihn). Lösch die Testmail danach im Webmail von Hand: so siehst du, dass dieses Konto in INBOX löschen darf (das braucht der tägliche Lauf um 03:40). Achtung: dieser Lauf löscht in der INBOX jede Mail, die der Dienst schon aufgenommen hat (alles bis zu seinem Lesestand, egal von wem und ob du sie gelesen hast) und die älter als 29 Tage ist, höchstens 500 am Tag; in der ersten Nacht trifft es auch den alten Bestand (Pilot-Mails, DMARC-Berichte, Testmails). Was du aufheben willst, verschiebst du vorher in einen anderen Ordner (andere Ordner fasst der Lauf nie an). Wenn nicht: kein antworten nach 5 Minuten oder keine Mail = P4 B10 und die Cloudflare-Regel aus A2; Löschen geht nicht = erst die Rechte des Kontos beim Anbieter klären.
A4. P5 ist live oder bewusst von Hand.
docker exec webklar-workers printenv WORKERS_KAUFLINK
Erwartet: ja (P5 Block G ist durch) oder nein (bewusst von Hand: Theo legt bei einem Kaufwunsch keinen Link an, sondern den Pult-Eintrag „Kaufwunsch – Kauf-Link noch nicht aktiv“, den du von Hand beantwortest). Wenn nicht: ja ohne fertiges P5 = sofort WORKERS_KAUFLINK=nein in der .env und neu starten (P4 H4).
A5. Platz, Speicher, Abmelde-Adresse.
df -h / | tail -1 && free -m | sed -n 2p && curl -s -o /dev/null -w '%{http_code}\n' https://entwurf.project.webklar.com/abmelden/x
Erwartet: mindestens 3 GB frei, „available“ mindestens 2000, dann 404. Wenn nicht: bei Platz oder Speicher nicht bauen (frag Claude); 502 oder ein Zertifikatsfehler = die Vorschau-Route aus P4 Block C ist kaputt, sie trägt den Abmelde-Link jeder Mail.
A6. Kein Befehl. (a) Anthropic-Console → Workspace „Hustler“ → Ausgabenlimit 150 $ pro Monat (P3 A5). (b) Der Startbestand des Registers ist eingespielt (P1 Schritt 9): Appwrite-Konsole → workersKeys hat Dokumente, damit niemand angeschrieben wird, den WEBklar schon kennt. (c) Du kannst in Google Cloud die Places API (New) aktivieren (C1). Erwartet: alles drei stimmt. Wenn nicht: nachholen, bevor du weitermachst.
B. Code: Sicherung, Zweige, Tests, Push, Image
B1. Sicherungen, Rückfall-Image, ALT-Stand (das Image vor dem Bauen sichern; vor-p6 wird nie überschrieben).
cd /home/webklar/apps && { [ -e .env.bak-$(date +%F)-workers-p6 ] || cp -p .env .env.bak-$(date +%F)-workers-p6; } && { [ -e docker-compose.yml.bak-$(date +%F)-workers-p6 ] || cp -p docker-compose.yml docker-compose.yml.bak-$(date +%F)-workers-p6; } && { docker image inspect webklar-workers:vor-p6 >/dev/null 2>&1 || docker tag webklar-workers:latest webklar-workers:vor-p6; } && git -C /home/webklar/apps/workers status -sb | head -n1 && { [ -s /home/webklar/apps/workers-alt-p6.txt ] || git -C /home/webklar/apps/workers rev-parse --short HEAD > /home/webklar/apps/workers-alt-p6.txt; } && cat /home/webklar/apps/workers-alt-p6.txt
Erwartet: ## main...origin/main, dann eine Nummer: das ist ALT-DIENST, der Stand vor P6 (sie steht in /home/webklar/apps/workers-alt-p6.txt, H2 liest sie von dort; ein zweiter Lauf am selben Tag überschreibt weder sie noch die Sicherungen von .env und Compose); docker images webklar-workers zeigt latest und vor-p6. Wenn nicht: ## HEAD (no branch) = git -C /home/webklar/apps/workers checkout main und den Befehl wiederholen; das Image vor-p6 löschst du nicht, solange P6 nicht bewährt ist.
B2. [Claude] Dienst-Zweig zusammenführen und testen (Worktree /home/webklar/apps/workers-dev-p6, Zweig p6-ohne-website; nie /home/webklar/apps/workers-dev).
cd /home/webklar/apps/workers-dev-p6 && git status -sb | head -n3 && git log --oneline p6-ohne-website..p4-gespraeche && git fetch origin && git merge-base --is-ancestor origin/main HEAD && echo FF-OK
Erwartet: ## p6-ohne-website (sauber), eine leere Liste, FF-OK. Ist die Liste nicht leer: git merge p4-gespraeche (auch spätere Korrekturen von P4 gehören hinein); fehlt FF-OK: git merge origin/main; nie rebase, nie --force. Dann der Gesamtlauf:
free -m | sed -n 2p && flock -w 3600 /run/lock/hustler-schwer.lock timeout 900 node --max-old-space-size=1024 --test --test-concurrency=2 test/*.test.js
Erwartet: „available“ mindestens 2500, # fail 0 und # skipped 0 (zuletzt 2855 Tests grün). Wenn nicht: nicht pushen; Claude behebt es.
B3. Pushen und Stand übernehmen (der pull ändert auch prompts/*.md, die der laufende Dienst sofort liest).
git -C /home/webklar/apps/workers-dev-p6 push origin p6-ohne-website:main && git -C /home/webklar/apps/workers pull --ff-only
Erwartet: kein Fehler; git -C /home/webklar/apps/workers log --oneline -1 und git -C /home/webklar/apps/workers-dev-p6 log --oneline -1 zeigen dieselbe Nummer. [Claude] bestätigt danach, dass grep -rn '\.sendMail(' /home/webklar/apps/workers/src nur src/mail/gate.js und src/notify.js nennt (jede Mail an Interessenten geht durchs Sende-Tor). Wenn nicht: „non-fast-forward“ oder „Not possible to fast-forward“ = nie --force, nie rebase, Claude Bescheid.
B4. Image bauen (der laufende Container bleibt bis C5 auf dem alten Image).
cd /home/webklar/apps && free -m | sed -n 2p && docker compose build workers
Erwartet: „available“ mindestens 1500, der Bau endet ohne Fehler, docker images webklar-workers zeigt latest mit neuer Kennung und vor-p6 mit der alten. Wenn nicht: „Killed“ oder Speicherfehler = nichts erzwingen, A5 beachten.
B5. [Claude] WOMS-Zweig vorbereiten (der Hustler braucht den Reiter „Bot-Leads“ und den Pausenschalter, bevor du schaltest; feature/workers ist gegenüber origin/test zurück, etwa um „Website-Statistik“).
cd /home/webklar/apps/ticket.webklar.com/dev-workers && git status -sb | head -n3 && git fetch origin && git log --oneline HEAD..origin/test
Erwartet: ## feature/workers...origin/test [ahead N] und eine leere Liste. Hat test neue Commits, steht dort [ahead N, behind M] und die Liste nennt sie (etwa „Website-Statistik“): dann git merge origin/test (nie --force). Danach Suite und Bau, jeweils unter flock:
flock -w 3600 /run/lock/hustler-schwer.lock timeout 900 node --max-old-space-size=1024 --test --test-concurrency=2 tests/*.test.mjs tests/unit/*.test.mjs
flock -w 3600 /run/lock/hustler-schwer.lock systemd-run --scope -p MemoryMax=1500M -p MemorySwapMax=0 --quiet timeout 600 npx vite build --outDir "$(mktemp -d "${TMPDIR:-/tmp}/p6w.XXXXXX")"
Erwartet: # fail 0, Bau Exit 0, und ls -d /home/webklar/apps/ticket.webklar.com/build/node_modules/.vite* 2>/dev/null gibt nichts aus. Wenn nicht: nicht pushen, Claude.
B6. WOMS pushen (du).
git -C /home/webklar/apps/ticket.webklar.com/dev-workers push origin feature/workers:test
Erwartet: Push angenommen; nach 1 bis 3 Minuten baut der Webhook (die Seite ansehen: hart neu laden, Strg/Cmd+Umschalt+R). Wenn nicht: „non-fast-forward“ = test hat neue Commits: nie erzwingen, B5 wiederholen.
C. Konfiguration, Schema, Neustart
C1. Google-Places-Schlüssel. Google Cloud Console: „Places API (New)“ aktivieren, Schlüssel anlegen, auf diese API und die Server-IP beschränken, das Tageskontingent für Text Search auf 300 Anfragen setzen, Budgetwarnung bei 20 € (ein Google-Budget deckelt nichts, die harte Grenze steht im Dienst). Dann (die Zeile heißt genau so, src/config.js liest sie):
cd /home/webklar/apps && sed -i '/^WORKERS_PLACES_API_KEY=/d' .env && ( read -rs -p 'Google-Places-Schlüssel: ' K; echo; printf 'WORKERS_PLACES_API_KEY=%s\n' "$K" >> .env )
Erwartet: keine Ausgabe nach dem Einfügen. Wenn nicht: Tippfehler = den Befehl wiederholen (er löscht die alte Zeile zuerst; Sicherung aus B1).
C2. Compose-Zeile für das Löschen alter Antworten (genau die Zeile aus deploy/compose-workers.yml; sie fehlt in einer Compose-Datei aus der Zeit vor P6, und ohne sie gälte im Dienst 30 statt 29 Tage und die Zusage „spätestens 30 Tage“ hielte nicht). Der Befehl tut nichts, wenn die Zeile schon steht.
cd /home/webklar/apps && [ "$(command grep -c WORKERS_ANTWORTEN_LOESCHEN_TAGE docker-compose.yml)" = 0 ] && sed -i '/^ workers:$/,/^ [a-z0-9_-]*:$/{
/^ - WORKERS_UNSUB_SECRET=/a\
- WORKERS_ANTWORTEN_LOESCHEN_TAGE=${WORKERS_ANTWORTEN_LOESCHEN_TAGE:-29}
}' docker-compose.yml; sed -n '/^ workers:$/,/^ [a-z0-9_-]*:$/p' docker-compose.yml | command grep -cF 'WORKERS_ANTWORTEN_LOESCHEN_TAGE=${WORKERS_ANTWORTEN_LOESCHEN_TAGE:-29}'
Erwartet: 1. In die .env gehört nur etwas, wenn du einen anderen Wert willst: WORKERS_ANTWORTEN_LOESCHEN_TAGE=<1 bis 29>; mehr als 29 bricht die Zusage (der Lauf kommt einmal täglich), 0 schaltet das Löschen aus und widerspricht dem Datenschutztext: nie. Wenn nicht: 0 = der Block heißt anders oder ist anders eingerückt: Zeile von Hand unter environment: von workers einfügen; 2 = doppelt, die doppelte im Editor löschen (Sicherung aus B1).
C3. Konfiguration prüfen (zählt nur Zeilen).
cd /home/webklar/apps && command grep -c '^WORKERS_PLACES_API_KEY=.' .env; command grep -c '^WORKERS_UNSUB_SECRET=.' .env; command grep -c '^WORKERS_SMTP_FROM=.*[@.]webklar\.com' .env; command grep -Ec '^WORKERS_SMTP_(HOST|PORT|USER|PASS|FROM)=.' .env; command grep -Ec '^WORKERS_IMAP_(HOST|PORT|USER|PASS)=.' .env; docker compose config -q && echo KONFIG-OK
Erwartet: 1, 1, 0, 5, 4 und KONFIG-OK. Wenn nicht: 0 bei der ersten Zahl = C1; 0 bei der zweiten = P4 B3 (das Abmelde-Geheimnis fehlt, jede Mail meldet nicht_konfiguriert); 1 bei der dritten = der Absender liegt auf webklar.com oder einer Subdomain davon, das Tor verweigert ihn (A2); unter 5 bei der vierten oder unter 4 bei der fünften = eine SMTP- oder IMAP-Zeile fehlt (der Dienst bricht sonst mit Konfiguration unvollständig: <Namen> ab), darüber = doppelte Zeilen aus früheren Versuchen, nur die doppelten im Editor löschen (Sicherung aus B1).
C4. Schema prüfen (auf dem Host, vor dem Neustart; der Laufzeit-Schlüssel des Dienstes darf das nicht, also nie per docker exec). P6 ändert das Schema nicht (src/schema.js ist seit dem Stand 558ce24 von P4 und P5 unverändert); die Prüfung zeigt, ob P4 und P5 es vollständig angelegt haben. Lege in der Appwrite-Konsole einen Schlüssel workers-setup an: Ablauf 1 Tag, Bereiche databases.read, collections.read/write, attributes.read/write, indexes.read/write, nie databases.write, nicht in .env.
cd /home/webklar/apps/workers-dev-p6 && ( read -rs -p 'Appwrite-Key workers-setup: ' APPWRITE_API_KEY; echo; export APPWRITE_API_KEY; timeout 300 node --max-old-space-size=1024 scripts/setup-collections.mjs --check; echo "Exit $?" )
Erwartet: 13 Zeilen = workers…: N Attribute, M Indizes vorhanden (zum Beispiel = workersProspects: 31 Attribute, 5 Indizes vorhanden), dann Alles vorhanden. und Exit 0. Danach den Schlüssel in der Appwrite-Konsole löschen. Wenn nicht: FEHLT-Zeilen mit Exit 1 = ein früherer Schritt (P4 B6, P5 A3) fehlt: derselbe Befehl ohne --check zieht alles nach (wiederholbar), danach wieder mit --check; 401 oder „missing scope“ = falscher Schlüssel; NICHT BEREIT = ein paar Minuten warten.
C5. Neustart (nur, wenn kein Bau läuft: im Hochhaus arbeiten weder Bob, Bianca, Ben noch Paul, sonst geht der bezahlte Bau verloren, siehe P4 I3).
cd /home/webklar/apps && free -m | sed -n 2p && docker compose up -d --no-deps workers
Erwartet: „available“ mindestens 1500, der Container wird neu erstellt. Wenn nicht: Speichermangel = A5, nichts erzwingen.
C6. Läuft der neue Dienst? Drei Prüfungen, jede einzeln.
docker logs --tail 30 webklar-workers
Erwartet: Zeilen mit workers listening on 8092 und vorschau listening on 8093, keine mit Abbruch: oder Konfiguration.
docker exec webklar-workers sh -c 'printenv WORKERS_ANTWORTEN_LOESCHEN_TAGE; printenv WORKERS_PLACES_API_KEY | wc -c; printenv WORKERS_SMTP_HOST'
Erwartet: 29 (oder dein Wert), eine Zahl über 1 (der Schlüssel selbst wird nicht ausgegeben), smtp.resend.com. Das ist der Stand im laufenden Container. Nach 2 bis 3 Minuten:
docker logs --since 5m webklar-workers 2>&1 | command grep -iE 'invalid query|index not found|attribute not found|unknown attribute|Tick abgebrochen|Unbehandelte|Fehler bei|Anmeldung abgelehnt'
Erwartet: keine Ausgabe; docker exec webklar-workers cat /data/imap-state.json zeigt wieder antworten (bis zum ersten erfolgreichen Lesen wartet jede Kaltmail mit dem Grund posteingang). Wenn nicht: eine Invalid query- oder Index-Zeile = C4 ist nicht vollständig durchgelaufen; Anmeldung abgelehnt = Postfach-Zugang (nicht in einer Schleife wiederholen, P4 B10); Abbruch: oder Konfiguration ungültig = eine Zeile in der .env oder in der Compose-Datei ist kaputt (der Dienst startet sonst in einer Schleife neu): Zeile an Claude, nichts raten. Das Rückfall-Image liegt bereit (H2).
C7. WOMS-Abnahme. Erst ohne Anmeldung:
curl -s -o /dev/null -w '%{http_code}\n' https://ticket.webklar.com/api/workers/botleads
Erwartet: 401. Wenn nicht: 404 = der alte Dienst läuft noch (C5 wiederholen); 502 = der Dienst läuft nicht (C6). Dann eingeloggt auf https://ticket.webklar.com in der Entwicklerkonsole des Browsers:
const j = await (await fetch('/v1/account/jwts', { method: 'POST', credentials: 'include', headers: { 'X-Appwrite-Project': '6a1058610003c5a13a05' } })).json()
for (const p of ['state', 'botleads']) { const r = await fetch('/api/workers/' + p, { headers: { 'X-Appwrite-JWT': j.jwt } }); console.log(p, r.status, await r.json()) }
Erwartet: state 200 {…} und botleads 200 {items: [], total: 0, pageSize: 50}. In WOMS → Hustler gibt es die Reiter „Hochhaus“, „Freigabe“ und „Bot-Leads“; schalte „Alles anhalten“ → „Anhalten“ einmal ein (Zeile „Angehalten: Es wird nichts gesendet und nichts gebaut.“) und mit „Fortsetzen“ wieder aus. Wenn nicht: state mit einem Fehler (500) = Appwrite lehnt die neue Zählabfrage auf purgeAfter ab, meist fehlt Attribut oder Index: C4 ohne --check wiederholen, dann Claude; botleads 404 = der alte Dienst läuft noch (C5); fehlt der Reiter, ist WOMS nicht ausgeliefert (B6, 3 Minuten warten, hart neu laden).
D. Live-Probe (kostet nichts außer einer Places-Anfrage je Aufruf, keine Mail an Fremde)
D1. DNS im Container (liefert der Docker-Resolver für fehlende AAAA-Einträge einen Fehler, gilt jede Website als unlesbar und jeder Kandidat endet als Bot-Lead).
docker exec webklar-workers node --input-type=module -e "const d = await import('node:dns/promises'); for (const h of ['example.org', 'webklar.com']) for (const t of ['resolve4', 'resolve6']) { try { console.log(h, t, (await d[t](h)).length, 'Adressen') } catch (e) { console.log(h, t, e.code) } }"
Erwartet: je Zeile eine Zahl von Adressen oder ENODATA bzw. ENOTFOUND (kein Eintrag). Wenn nicht: ESERVFAIL, ETIMEOUT oder ECONNREFUSED = der Resolver des Containers ist das Problem, nicht der Code: Claude, die Räume bleiben unverbunden.
D2. Probe Handwerk (eine Places-Anfrage, 0,035 $; liest bis zu 5 Websites; schreibt nichts, ruft kein Sprachmodell auf; gibt nur Rechnernamen aus, nie Adressen, Telefonnummern oder Namen).
timeout 300 docker exec webklar-workers node scripts/probe-scout.mjs --room handwerk --query 'Elektriker in Kaiserslautern' --max 5
Erwartet: Places: N Treffer, davon mit Website: K, bis zu fünf Zeilen <Rechner> status=ok|robots|nicht_lesbar|keine_website Mail=gefunden|keine Quelle=<Rechner oder -> Telefon=ja|nein MX=ok|nein|-, zuletzt Kosten gesamt: 0,035 $, Exit 0. Gut ist, wenn mindestens 60 % der Zeilen status=ok haben. Wenn nicht: Fehler: Places-Anfrage fehlgeschlagen (HTTP 403). = Schlüssel, API oder IP-Beschränkung (C1); Abbruch: WORKERS_PLACES_API_KEY fehlt = C1 und C5; fast nur nicht_lesbar = D1.
D3. Probe Läden und Gastro (anderes Abfragemuster; im Raum aerzte zeigt Telefon=ja, was die Seite nennt, der Dienst speichert dort nie eine Nummer).
timeout 300 docker exec webklar-workers node scripts/probe-scout.mjs --room laden --query 'Bäckerei in Mainz' --max 5
Erwartet: wie D2. Zusammen mit D2 gilt: mindestens 60 % status=ok, mindestens 2 der 10 Betriebe mit Mail=gefunden und MX=ok, weder Nummer noch Adresse noch Name in der Ausgabe. Wenn nicht: weniger = Räume nicht verbinden, die Zeilen (nur Rechnernamen) an Claude: er prüft, ob die Impressum-Erkennung zu viel abschneidet.
D4. Mail-Weg über Resend (genau ein Versuch an deine eigene Adresse, ohne Schutz des Sende-Tors).
docker exec webklar-workers node scripts/p4-livecheck.mjs smtp --to kenso.gri@gmail.com --ja-echt
Erwartet: Testmail gesendet (ein Versuch). Message-ID: <…>, Exit 0. In Gmail „Original anzeigen“: SPF, DKIM und DMARC PASS; List-Unsubscribe und List-Unsubscribe-Post: List-Unsubscribe=One-Click stehen da; die Message-ID bleibt <32 Hexzeichen.Id@D> (ersetzt Resend sie, ordnet der Dienst Antworten nur noch über den Absender zu: sag Claude Bescheid); kein Tracking-Link. Empfohlen: derselbe Befehl einmal mit der Adresse von mail-tester.com, Ergebnis mindestens 9 von 10. Antworte auf die Testmail aus Gmail: sie liegt im Posteingang des Hetzner-Postfachs (Cloudflare-Weg) und docker exec webklar-workers node scripts/p4-livecheck.mjs imap --ja-echt zeigt ein steigendes exists. Wenn nicht: SMTP-Login abgelehnt (…) = nicht wiederholen (der Anbieter sperrt sonst das Konto): Schlüssel in der .env prüfen (Sicherung B1), neu starten, dann einmal erneut; ein PASS fehlt oder die Mail liegt im Spam = DNS aus A2, Claude.
E. Freischalten
E1. Stand des Schalters und offene Mail-Aufträge.
docker exec webklar-workers node scripts/datenschutz-schalter.mjs --status
Erwartet: Datenschutz-Schalter: aus oder …: an; schreib das Wort auf (Stand vorher: ____). Appwrite-Konsole → woms-database → workersJobs, Filter Status = offen: kein Auftrag der Art antwort, vorschau_link, erinnerung, nachfass oder erstmail. Wenn nicht: gibt es solche Aufträge, nicht einschalten: sie gingen sofort hinaus; Claude fragen.
E2. Live-Seite prüfen (schreibt nie).
docker exec webklar-workers node scripts/p6-freischalten.mjs datenschutz --check
Erwartet: Seite ok, Exit 0 (Anker #interessenten da, die Wendungen aus Teil A Schritt 2 stehen zwischen dem Anker und „12. Empfänger“). Wenn nicht: Zeilen Fehlt: … und Seite unvollständig. (Exit 1) = der Text ist noch nicht live oder ein Wortlaut wurde geändert (Teil A, Schritt 2); Veraltet: Google Workspace (oder Migadu) = ein alter Stand ist live; Seite nicht abrufbar: HTTP 404 = Teil A, Schritt 6. Den Schalter nicht setzen.
E3. Schalter an. Das ist derselbe Schalter wie bei Clara (P3 Block G, P4 H2): ab jetzt dürfen Erstantworten und Kaltmails hinaus.
docker exec webklar-workers node scripts/p6-freischalten.mjs datenschutz --set
Erwartet: Seite ok und Schalter privacyInteressentenLive: an. Das Auswahl-Tick um 05:30 läuft einmal am Tag: schaltest du nach 05:30 ein, wählt Bea erst am nächsten Morgen. Wenn nicht: wie E2, dann bleibt der Schalter, wie er war (Schalter bleibt unverändert.).
E4. Zwei Probe-Betriebe an dich selbst (Mo bis Do am Nachmittag; sie laufen durch das echte Tor und kosten zwei Sonnet-Entwürfe, wenige Cent). Der Befehl legt zwei Betriebe „Testbetrieb Muster“ an, Status „bewertet“, mit zwei Plus-Adressen von dir:
docker exec -i webklar-workers node --input-type=module <<'JS'
import { loadConfig } from './src/config.js'
import { createDb } from './src/db.js'
const db = createDb(loadConfig().appwrite)
const jetzt = new Date()
for (const [nr, punkte] of [[1, 90], [2, 88]]) {
const id = `kalt${nr}${jetzt.getTime().toString(36)}`
await db.create('workersProspects', {
entrance: 'ohne_website', room: 'handwerk', foundBy: 'hanna', source: 'places',
sourceUrl: 'https://www.webklar.com/impressum', foundAt: jetzt.toISOString(),
name: 'Testbetrieb Muster', email: `kenso.gri+kalt${nr}@gmail.com`, emailSource: 'https://www.webklar.com/impressum',
city: 'Kaiserslautern', score: punkte, status: 'bewertet', isHealth: false,
purgeAfter: new Date(jetzt.getTime() + 3 * 86400000).toISOString(), updatedAt: jetzt.toISOString()
}, id)
console.log('Probe-Betrieb angelegt:', id)
}
JS
Erwartet: zwei Zeilen Probe-Betrieb angelegt: kalt1… und kalt2…. Am nächsten Morgen um 05:30 wählt Bea beide (im Hochhaus bei Bea: Platz 1 von 2: Punktzahl 90 und Platz 2 von 2: Punktzahl 88), Max schreibt Erstmail entworfen (Kaiserslautern), und ab 09:00 (Werktag) kommen beide Mails bei dir an (Erstmail gesendet). Wenn nicht: keine Mail bis 16:00 = Wochentag und Uhrzeit, Pause in WOMS, Schalter (E1 bis E3) und docker logs --since 24h webklar-workers 2>&1 | command grep -iE 'Sende-Tor|Top-3-Auswahl|Fehler bei' an Claude.
E5. Die erste echte Kaltmail lesen (an kalt1). Kein Befehl. Lies sie wie ein Fremder und prüfe: Absender Max, KI-Assistent von WEBklar von deiner Domain D (nie webklar.com), Anrede „Guten Tag,“, als erster Satz „Ich bin ein KI-Assistent von WEBklar und schreibe Ihnen im Auftrag von Kenso Grimm und Justin Klein.“, danach ein kurzer persönlicher Teil mit Name oder Ort, der Angebotssatz wörtlich wie in WOMS → Hustler → Einstellungen → Gruppe „Angebot“ → „Angebotstext“, die Zeile „Wenn Sie die Vorschau sehen möchten, antworten Sie einfach mit „Ja“.“, genau ein Link im Text (https://www.webklar.com), der Gruß „KI-Assistent von WEBklar, im Auftrag von Kenso Grimm und Justin Klein“ und der Fuß „Hinweise zu dieser Nachricht:“ mit genau vier Adressen (Impressum, Quelle der Adresse, Datenschutz mit #interessenten, Abmelden). Die Anbieterzeile im Fuß (GbR, Namen, Anschrift, Telefon, E-Mail) muss genau dem Impressum entsprechen; der Betreff hat kein „Re:“. Nur Text, kein Bild, kein Tracking-Link; Kopfzeilen wie in D4. Antworte mit Mensch: im Pult erscheint „Gespräch mit einem Menschen gewünscht“ (Etikett „Mensch gewünscht“). Klicke dann den Abmelden-Link (nur mit dieser Plus-Adresse: sie steht danach für immer auf der Sperrliste; zum Aufräumen die Id berechnen und das Dokument in workersSuppression löschen wie in P4 G13). Erwartet: alles wie beschrieben; der Link zeigt erst eine Seite mit dem Knopf „Abmelden“, dann „Sie wurden abgemeldet.“ Wenn nicht: jede Abweichung im Text (Reste wie Telefon oder Adresse in der Anrede, ein zweiter Link, ein falscher Angebotssatz) oder ein Eintrag „Mail nicht gesendet: …“ im Reiter „Freigabe“ mit Wortlaut an Claude, bevor du Räume verbindest.
E6. Kürzen-Probe (an kalt2). Echte Kürzungen kämen sonst erst nach 29 bis 89 Tagen. Wenn kalt2 die Mail bekommen hat (Status kontaktiert, nicht antworten), setze in der Appwrite-Konsole (workersProspects, Dokument kalt2…) purgeAfter auf gestern. Am nächsten Morgen nach 03:00 (der Lauf schreibt kein Ereignis): im Dokument name = [gekürzt], email leer, purgedAt gefüllt, purgeAfter leer; im Faden (threadId aus dem Dokument, Sammlung workersThreads) toEmail leer und status = abgeschlossen; die Nachrichten des Fadens (workersMessages, Feld threadId) haben body = [gekürzt]; die Anzahl der Dokumente in workersKeys ist gleich geblieben. Erwartet: alles wie beschrieben und docker logs --since 24h webklar-workers 2>&1 | command grep -c 'Kürzung:' gibt 0. Wenn nicht: eine Zeile Kürzung: Interessent … nicht gekürzt oder Fehler bei kuerzen an Claude; Räume nicht verbinden, denn die Datenschutz-Zusage (90, 30 Tage, sechs Monate) hängt an diesem Lauf. Danach darfst du die Probe-Dokumente in der Konsole löschen.
E7. Mails pro Tag auf 1 stellen (empfohlen für die ersten Tage: neue Domain, kleine Mengen). WOMS → Hustler → Hochhaus → „Einstellungen“ (Admin) → Gruppe „Mails“ → „Unverlangte Mails pro Tag“ = 1, speichern. Steigerung: Sendetage 1 bis 4 eine, ab dem 5. zwei, ab dem 8. drei (ab dem 8. Tag belegen fällige Erinnerungen Plätze). Erwartet: der Dialog schließt ohne Fehler. Wenn nicht: eine rote Zeile im Dialog nennt die Grenze (0 bis 3).
E8. Räume verbinden (am Werktag vor 22:00: danach hat Otto die Nacht schon geplant und die Suche beginnt erst am nächsten Abend; der Schalter muss an sein, sonst verweigert das Skript mit Exit 1). Erst ein Raum:
docker exec webklar-workers node scripts/p6-freischalten.mjs raeume --set handwerk
Erwartet: Verbundene Räume: handwerk (steht dort schon etwas von der Anfragen-Etage, etwa netz, foren, bleibt es erhalten). aerzte, selbst und laden kommen erst nach einer Woche ohne Bounce und mit lesbaren Mails dazu: derselbe Befehl mit dem Raumnamen, mehrere mit Komma (raeume --set aerzte,selbst). Wenn nicht: Der Datenschutz-Schalter ist aus: erst „datenschutz --check“, dann „datenschutz --set“, danach die Räume verbinden. = E2 und E3; Places-Schlüssel fehlt (WORKERS_PLACES_API_KEY): Räume bleiben unverbunden. = C1 und C5; Unbekannter Raum: … (erlaubt: aerzte, handwerk, selbst, laden) = Tippfehler.
F. Erste Nacht und erste Woche
F1. Was geschieht, kein Befehl. 22:00 Otto: „Otto schickt Hanna los: Handwerk in Rheinland-Pfalz (10 Kandidaten).“ (bei mehreren Räumen der, der am längsten nicht gesucht hat). Der Scout holt den Auftrag innerhalb von 2 Minuten (Lauf höchstens 20 Minuten): je neuem Betrieb „… neuer Kandidat in ()“, schon bekannte „Schon bekannt: Kandidat in “ (Arne), dann „… 10 Kandidaten an Bea übergeben“ und bei Bea „10 Kandidaten bewertet, bester Wert N“; wer nicht in Frage kommt, steht als „Bot-Lead (, ): “ in der Liste. 03:00 Arne kürzt (still), 03:40 räumt der Dienst das Postfach auf (ein Ereignis nur, wenn er etwas löscht). 05:30 Bea: „Platz 1 von 5: Punktzahl 84“ (an Max), alle anderen als Bot-Lead „Platz 2 von 5; freie Mail-Plätze: 1“ (die Zahl nennt die freien Plätze vor der Wahl, mit „Unverlangte Mails pro Tag“ = 1 also 1; „…: 0“ steht nur, wenn für den Tag schon alles belegt ist) oder „Punktzahl unter 55“. Ab 09:00, nur Mo bis Fr, schreibt Max („Erstmail entworfen ()“) und sendet („Erstmail gesendet“); Feiertage sind nicht ausgenommen, am Wochenende Gewählte gehen am Montag hinaus. Sind alle Mail-Plätze belegt, sucht der Scout nicht („… Alle Mail-Plätze sind vergeben, kein Suchlauf.“): kein Fehler. Erwartet: die Spur der ersten Nacht in dieser Reihenfolge. Wenn nicht: fehlt die Zeile von 22:00, steht der Raum nicht in „Verbundene Räume“ (E8) oder der Dienst ist angehalten; fehlt die von 05:30 an einem Tag, an dem du nach 05:30 eingeschaltet hast, ist das richtig (E3).
F2. Morgenkontrolle (2 Minuten). Erst das Log der Nacht:
docker logs --since 24h webklar-workers 2>&1 | command grep -E 'Fehler bei|fehlgeschlagen|Scouts:|Scout [a-z]+:|Postfach aufräumen: (fehl|kein|UID)|Kürzung:|Top-3-Auswahl|Sende-Tor|Tick abgebrochen|Unbehandelte' | command grep -v 'OSM-Abfrage\|Lesefehler'
Erwartet: keine Zeile (Zeilen Scout <name>: Lesefehler {…} mit Netzkennungen wie ENOTFOUND sind normal, tote Websites, und der Befehl blendet sie aus). Programmfehler unter den Lesefehlern zeigt dieser Befehl:
docker logs --since 24h webklar-workers 2>&1 | command grep 'Lesefehler' | command grep -E 'TypeError|ReferenceError|RangeError|SyntaxError'
Erwartet: keine Zeile; sonst Claude. Dann die Störungen von Overpass (OSM), das in der ersten Nacht zum ersten Mal echt gefragt wird (der Dienst loggt sie als „OSM gestört“, nicht als Fehler des Laufs):
docker logs --since 24h webklar-workers 2>&1 | command grep -cE 'OSM gestört|OSM-Abfrage fehlgeschlagen'
Erwartet: höchstens 1 je Suchlauf der Nacht (OSM gestört, keine weitere Anfrage in diesem Lauf (…) steht höchstens einmal je Lauf; die öffentliche Overpass-Instanz antwortet gelegentlich mit 429 oder 504). Dazu: Google Cloud zeigt mindestens 1 und höchstens 3 Places-Anfragen der Nacht, die Anthropic-Console (Workspace „Hustler“) unter 1 $ für die Nacht, und WOMS → Hustler → „Bot-Leads“ die Gründe: „Bewertung ausgefallen“ und „Lesen fehlgeschlagen“ sollten gegen null gehen, „keine veröffentlichte Mail“ darf überwiegen; die Zahl „Bot-Leads gesamt“ im Hochhaus gleicht der Summe im Reiter, und im Raum „Ärzte & Praxen“ steht bei jeder Zeile „bewusst nicht gespeichert“ statt einer Nummer. Optional: Ein Klick im Hochhaus bei Arne auf eine Zeile „Bot-Lead (…)“ öffnet /hustler?tab=botleads&bot=<Id> mit genau diesem hervorgehobenen Eintrag und dem Knopf „Alle anzeigen“; „Alle anzeigen“ zeigt die ganze Liste. Wenn nicht: jede Nacht eine OSM-Zeile (dann ist ein Abfragemuster kaputt) oder viele „Bewertung ausgefallen“ (Beas Antwort wird abgeschnitten) oder über 80 % „keine veröffentlichte Mail“ trotz guter Probe in D = Claude.
Willst du die Suche schon tagsüber sehen: scripts/jetzt.mjs löst nur bericht, nachtplan und nacht2 aus; top3 (05:30) und kuerzen (03:00) kannst du nicht von Hand starten. Das echte Planen der Suchläufe (etwa 0,1 bis 0,3 $; die gefundenen Betriebe kommen am nächsten Morgen in die Auswahl):
docker exec webklar-workers node scripts/jetzt.mjs nachtplan
Erwartet: eine JSON-Zeile {"planned":[{"room":"handwerk","workerId":"hanna","region":"Rheinland-Pfalz","jobId":"…"}],"skipped":null}; nach 2 Minuten holt der Scout den Auftrag. Der normale Lauf um 22:00 plant trotzdem noch einmal (jetzt.mjs setzt keinen Tagesmarker): in dieser Nacht suchen die Scouts zweimal, bis zu 0,2 $ mehr und bis zu 20 statt 10 Kandidaten. Sind die Räume der Anfragen-Etage verbunden (E8), stehen in planned auch rita und finn (Suche im Netz, wenige Cent). Wenn nicht: "skipped":"keine_raeume" = E8 fehlt, "skipped":"pause" = der Dienst ist angehalten.
F3. Die ersten Mails (ab 09:00). Im Postfach der Absenderadresse gibt es keinen Ordner „Gesendet“ (Resend legt keine Kopie ab). Prüfe stattdessen: Appwrite-Konsole → woms-database → workersDaily, Dokument mit der Id des Tages (JJJJ-MM-TT): mailsCold ist höchstens so groß wie „Unverlangte Mails pro Tag“ (kein Wochenende: dort 0); im Hochhaus bei Max „Erstmail gesendet“; in Resend → Emails jede der ersten 10 Mails öffnen (Anrede ohne Reste wie Telefon oder Anschrift, Betreff, Angebotssatz wörtlich, genau ein Link, Fuß mit Quelle, Impressum, Datenschutz, Abmelden). Erwartet: alles wie in E5. Wenn nicht: ein Eintrag „Mail nicht gesendet: text_zu_lang“, „text_zu_kurz“ oder „link_anzahl“ im Reiter „Freigabe“ heißt: der Angebotstext in den Einstellungen passt nicht (die ganze Mail muss 60 bis 180 Wörter haben, der Angebotstext höchstens 400 Zeichen): Text kürzen, das Tor nie lockern.
F4. Täglich in den ersten vier Wochen, danach wöchentlich: Resend. Bounces gehen an Resend, die Notbremse des Dienstes sieht sie kaum (Resend sperrt harte Bounces selbst). Resend → Emails, Filter „Bounced“ und „Complained“ der letzten 24 Stunden. Je Adresse in der Appwrite-Konsole workersProspects den Status auf verloren setzen (das stoppt die Erinnerung); bei einer Beschwerde zusätzlich „Unverlangte Mails pro Tag“ = 0. Erwartet: keine Treffer. Wenn nicht: ab 2 harten Bounces oder einer Beschwerde in einer Woche Schalter aus (H1) und Claude.
F5. Tag 8 und Tag 30. Ab dem 8. Tag kommen die ersten Erinnerungen: Betreff „Kurze Erinnerung: …“ ohne „Re:“, nach Anrede und KI-Satz beginnt der Text mit „Ich melde mich noch einmal kurz zu unserer Nachricht vom TT.MM.JJJJ“, Erst- und Erinnerungsmails zusammen höchstens 3 pro Tag und höchstens zwei Mails je Betrieb. Ab jetzt leerst du das Postfach nicht mehr monatlich von Hand: der Lauf um 03:40 löscht jede Mail, die der Dienst schon aufgenommen hat (alles bis zu seinem Lesestand, egal von wem und ob du sie gelesen hast) und die älter als 29 Tage ist, höchstens 500 am Tag. Liegt noch ein Rückstau aus der P4-Zeit im Postfach, meldet Arne das schon in der ersten Nacht („Antwort-Postfach aufgeräumt: N Mails gelöscht“), sonst erst, wenn die ersten Antworten 29 Tage alt sind. Steht stattdessen „Fehler bei postfachAufraeumen: …“ im Hochhaus, prüfe Login und Löschrechte des Kontos beim Anbieter. Steht im Hochhaus stattdessen „Antwort-Postfach: N Mails zum Löschen markiert (UIDPLUS fehlt)“, kann der Server einzelne Mails nicht entfernen (der Dienst markiert sie nur mit \Deleted, ein einfaches EXPUNGE nähme auch Mails mit, die ein Mensch markiert hat): dann ist nichts gelöscht, die Zusage „30 Tage“ hält nicht, Claude Bescheid; bis zur Klärung leerst du das Postfach von Hand.
F6. Kosten. Pro Nacht: Places höchstens 3 Anfragen zu je 0,035 (0,105; prüfe auf der ersten Google-Rechnungszeile, dass Text Search wirklich 0,035 je Anfrage kostet), Bea ein Sonnet-Aufruf (grob 0,03 bis 0,05), Max 1 bis 3 Entwürfe (grob 0,01 bis 0,02 je Mail), Antworten Cent-Beträge: erwartet unter 0,3 pro Tag und unter 10 im Monat; die Grenzen im Dienst sind 6 pro Tag und 150 $ im Monat. Resend Free (laut Resend 100 Mails am Tag, 3000 im Monat) reicht, Cloudflare Email Routing kostet nichts, die Domain wenige Euro im Jahr. Erwartet: „API heute“ im Hochhaus und die Anthropic-Console bleiben klar darunter. Wenn nicht: deutlich mehr = „Alles anhalten“ (H1) und Claude.
G. Handfälle
G1. Bot-Lead „Name fehlt“, „Impressum passt nicht zum Betrieb“, „schon bekannt (E-Mail oder Telefon)“ oder „(Name und PLZ)“. Kein Befehl. „Name fehlt“: die Seite nennt keinen echten Betriebsnamen (nur „Startseite“, „Willkommen …“ oder „Inh. …“). „Impressum passt nicht zum Betrieb“: Google verlinkt eine fremde Seite (Verein, Städteportal, Franchisegeber, Agentur). „Schon bekannt“: Register oder Sperrliste kennen den Betrieb. In allen Fällen geht keine Mail hinaus, und die Adresse ist beim Ablegen gelöscht. Willst du den Betrieb trotzdem erreichen, öffne „Quelle öffnen“ in der Bot-Lead-Zeile und schreib selbst, nicht über den Hustler. Erwartet: ein paar solche Zeilen pro Nacht. Wenn nicht: die Mehrzahl der Funde über mehrere Nächte = Claude (die Regeln kosten bewusst Ausbeute, so viele deuten auf einen Lesefehler).
G2. Ein Betrieb, den du schon kennst (Kunde, früherer Kontakt, ein Anruf), der noch nicht im Register steht: vor der Mail auf die Sperrliste. Eingetragen wird die Adresse, ihre Domain (außer bei Freemail) oder eine Telefonnummer:
docker exec -i webklar-workers node --input-type=module <<'JS'
import { loadConfig } from './src/config.js'
import { createDb } from './src/db.js'
import { suppress } from './src/register/keys.js'
const deps = { db: createDb(loadConfig().appwrite), clock: { now: () => Date.now() }, log: console.log }
console.log(await suppress(deps, { email: 'info@beispiel.de' }, 'manuell', 'mensch:kenso'))
JS
Erwartet: { created: [ 'mail', 'dom' ], ignored: false, unparsed: [] } (bei einer Freemail-Adresse nur mail); ab dann verweigert das Sende-Tor jede Mail an diese Adresse (gesperrt). Statt email geht auch domain: 'beispiel.de' oder phone: '+49 …'. Wenn nicht: ignored: true = die Angabe war nicht lesbar, nichts ist gesperrt: Tippfehler prüfen.
G3. Widerspruch, „abmelden“, Löschverlangen, Beschwerde, Abmahnung. Bei „abmelden“ (Antwort oder Link), Abmahnung und Beschwerde-Wörtern sperrt Nora den Absender selbst und sofort, auch in der Pause (Ereignis „Abmeldung: Interessent gesperrt“; bei Beschwerden dazu das Pult „Beschwerde oder rechtliche Drohung“ und die Mail „[Hustler] Beschwerde oder rechtliche Drohung“). Einen Widerspruch oder ein Löschverlangen in eigenen Worten beurteilt Nora mit dem Modell: sie sperrt oder legt die Antwort als „Unsichere Antwort“ ins Pult. Dazu musst du von Hand: (a) die Antwort-Mail und jede Mail, die sie zitiert, am selben Tag im Hetzner-Postfach löschen (der Text verspricht „sofort“, die automatische Frist von 30 Tagen genügt hier nicht); (b) bei einer Beschwerde oder Abmahnung nichts antworten ohne Rechtsrat und solange die Kanzlei prüft den Schalter aus (H1, Stufe 2); (c) verlangt die Person Auskunft oder Löschung vor der Frist, Claude Bescheid: er klärt, was in der Datenbank (sonst kürzt Arne nach 29 Tagen) und im Resend-Protokoll (nach Resends Frist, sonst beim Resend-Support) zu der Adresse steht. Eine Sperre ist endgültig: nie wieder anschreiben; nur deine eigenen Testadressen löschst du aus workersSuppression (P4 G13). Erwartet: die Adresse ist gesperrt, das Postfach ist leer von ihr. Wenn nicht: eine Antwort, die Nora nicht erkennt, liegt als „Unsichere Antwort“ im Pult: lies sie selbst und sperre sie mit G2.
G4. Resend sperrt das Konto oder der Login geht nicht mehr. Du erkennst es an einer Mail von Resend, an der Zeile „SMTP-Login abgelehnt (…), bis zum nächsten Berliner Tag kein weiterer Versuch“ im Hochhaus und an der Mail „[Hustler] Versand an Interessenten gestoppt (SMTP-Login)“. Dann: nicht in einer Schleife neu versuchen; im Resend-Dashboard den Grund lesen (Bounce-Quote, Beschwerde, Bedingungen); solange er unklar ist, Schalter aus (H1, Stufe 2), die Aufträge bleiben offen. Stimmen nur die Zugangsdaten nicht (Schlüssel erneuert), setzt du WORKERS_SMTP_PASS in der .env neu und startest neu. Ein anderer Anbieter oder eine neue Domain heißt neue DNS-Einträge und neue WORKERS_SMTP_*: Claude; nie von webklar.com senden. Erwartet: kein Versand, bis der Grund geklärt ist. Wenn nicht: Grund unklar = Claude, nichts raten.
H. Rückweg
H1. Sofort stoppen (wirkt ohne Neustart, verliert nichts). In vier Stufen:
- Stufe 1, alles: WOMS → Hustler → Hochhaus → „Alles anhalten“ → „Anhalten“ (Scouts, Top 3 und Versand stehen; ein laufender Suchlauf von bis zu 20 Minuten endet noch, sendet aber nichts; Kürzen und Postfach-Aufräumen laufen weiter).
- Stufe 2, keine Kaltmail und keine Erstantwort von Clara:
docker exec webklar-workers node scripts/p6-freischalten.mjs datenschutz --off
- Stufe 3, keine neue Suche (trennt nur die vier Scout-Räume, die der Anfragen-Etage bleiben; schon eingereihte Erstmail-Aufträge gehen bei eingeschaltetem Schalter noch hinaus, nimm dafür Stufe 2):
docker exec webklar-workers node scripts/p6-freischalten.mjs raeume --clear
- Weich: „Unverlangte Mails pro Tag“ =
0(die Scouts melden „Alle Mail-Plätze sind vergeben, kein Suchlauf.“).
Erwartet: Schalter privacyInteressentenLive: aus bzw. Verbundene Räume: … ohne die Scout-Räume. Wenn nicht: Fehlermeldung an Claude.
H2. P6-Code zurücknehmen (nur nach H1, dort mindestens Stufe 3, solange der neue Dienst noch läuft: der alte Dienst kennt die Scouts nicht, plant aber mit den verbundenen Räumen weiter jede Nacht Suchaufträge für sie, die niemand abholt; altes Image und alter Stand gehören zusammen, der Dienst liest die Rollen-Texte aus dem Checkout; .env und Compose müssen nicht zurück, der alte Dienst ignoriert die neuen Einträge; willst du es doch: cp -p .env.bak-<Datum>-workers-p6 .env und cp -p docker-compose.yml.bak-<Datum>-workers-p6 docker-compose.yml im Ordner /home/webklar/apps, die Sicherungen aus B1).
docker tag webklar-workers:vor-p6 webklar-workers:latest && git -C /home/webklar/apps/workers checkout --detach "$(cat /home/webklar/apps/workers-alt-p6.txt)" && cd /home/webklar/apps && docker compose up -d --no-deps workers
Erwartet: curl -s https://ticket.webklar.com/api/workers/health antwortet wie in A1; curl -s -o /dev/null -w '%{http_code}\n' https://ticket.webklar.com/api/workers/botleads gibt 404. Vor dem nächsten Update git -C /home/webklar/apps/workers checkout main, sonst scheitert git pull --ff-only. Wenn nicht: Bau- oder Startfehler an Claude.
H3. WOMS, Portal und Website zurücknehmen (nur falls nötig). Claude führt git revert --no-edit der betroffenen Commits aus (nie die Geschichte umschreiben), du pushst wie in B6, Teil A Schritt 5 und 8. Soll nur der Betrieb stoppen, genügt H1; die neue Fassung in WOMS → Rechtliches bleibt stehen (P3 und P4 brauchen sie auch). Erwartet: die Deploys laufen durch wie auf dem Hinweg. Wenn nicht: Claude.
Was beim Rückweg bleibt: Alle Daten (Interessenten, Fäden, Nachrichten, Aufträge, Sperrliste, Register, Suchprotokoll) und die Schema-Felder. Läuft der alte Dienst, laufen die Fristen des Datenschutztextes weiter, aber niemand kürzt: Kürzen (90 Tage, 30 Tage, sechs Monate) und das Aufräumen des Postfachs (30 Tage) gibt es nur im P6-Dienst. Bleibt P6 länger als ein paar Tage aus, leere das Postfach monatlich von Hand und sag Claude Bescheid.
Laufende Pflege
- Den „Angebotstext“ änderst du nur, wenn es keine offenen Fäden oder Angebote gibt (jedes Angebot speichert den Satz, der in der Mail stand).
- Nach jeder Änderung am Datenschutztext (Abschnitt 11):
datenschutz --check(E2) erneut, PDF neu drucken, neue Version in Rechtliches hochladen (Teil A Schritt 4 bis 10). - „Nachfassen nach (Tage)“, „Aufbewahrung (Tage)“ und „Unverlangte Mails pro Tag“ nur innerhalb der Zusagen im Text (sieben Tage, höchstens 90 Tage, höchstens 3 pro Tag).
- Änderungen an den Rollen-Prompts (
prompts/bea.md,prompts/max.md) wirken nachgit -C /home/webklar/apps/workers pull --ff-onlyohne Neubau; Änderungen an Aufgabentexten im Code brauchen ein neues Image (B3, B4, C5).
Fertig, wenn: Teil A (Schritt 6, 8, 10), A1 bis A6, C6, C7, D2 bis D4, E2, E3, E5, E6 und F2 ihre „Erwartet“-Zeilen erfüllen. Der Dauerbetrieb mit mehreren Räumen und drei Mails pro Tag ist eine eigene Entscheidung nach der ersten Woche (F4, F5).