Workers P6: Prüfstand für den Reiter Bot-Leads (Desktop und Handy)

Skript-API: botleads({ room, q, page, id }) antwortet wie GET /botleads des Dienstes
(items, total, pageSize 50; Telefon nie bei Heilberufen, gekürzte Stubs nur mit id).
60 sichtbare Einträge in vier Räumen (zwei Seiten), 3 gekürzte Stubs, Heilberufe mit
Altbestand-Telefon, eine http-Quelle, zwei feindliche Quellen, lange Namen und Gründe.
Schalter: faults.botleads, botleadsDelayMs, botleadsEmpty (?botleads=leer), botleadCalls.
Fetch-Weiche und entry.jsx (?bot=<Id>) um die Route und den Deep-Link ergänzt.

Szenarien: botleads (Reiter, Liste, Raumfilter, Blättern mit Fokus, Suche, Leerzustände,
Fehlertexte, Deep-Link, Quellen-Links, Telefon) und handy-botleads (390 x 844: kein
seitliches Scrollen, Knopfhöhe, Kontrast der gekürzten Zeile, Wortumbruch).
Der Pin in workersHarness.test.mjs prüft nun die neue Antwortform statt der leeren Liste.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-20 14:43:33 +00:00
1 parent cd532e0cf0
commit 78c8e3fbc2
5 files changed
+709 -11

No files matched your search

+191 -6
View File
@@ -11,6 +11,8 @@
// paste.js (Prüfreihenfolge, 400/404/409). Eigener Satz Daten mit `pult: 'p4'` (p4Fixtures); die P3-Daten bleiben unberührt. Die Schalter
// `faults.reply`, `.approve-preview`, `.start-build` und `.paste` lassen eine Aktion mit { status, message } scheitern (409 mit deutschem
// Satz, 413 „Anfrage zu groß.“, 400), `pultDelayMs` hält jede Pult-Aktion so lange auf (Knöpfe sind währenddessen gesperrt).
// Bot-Leads (P6): botleads({ room, q, page, id }) antwortet mit 60 festen Einträgen in der Form von GET /botleads (workers-dev-p6/src/api/botleads.js:
// items, total, pageSize 50, Telefon nie bei Heilberufen, gekürzte Stubs nur mit id); Schalter und Fixtures siehe „Bot-Leads (P6)“ weiter unten.
import { MODEL_IDS, ROLES, SEARCH_ROOMS, roleOf } from '../../src/lib/workersRoster.js'
import { indexWorkers, reduceEvents } from '../../src/lib/workersEvents.js'
@@ -468,14 +470,186 @@ function defaultSettings(halted) {
}
}
// ---------- Bot-Leads (P6) ----------
// GET /botleads in der Form des Dienstes (workers-dev-p6/src/api/botleads.js, listBotLeads): { items, total, pageSize }, neueste zuerst, 50 je Seite.
// Der Speicher unten hält die Dokumente (Interessenten der Etage „Ohne Website“, status 'botlead') mit allem, was der Dienst kennt; nach außen geht wie
// im Dienst nur botleadEntry(): feste Auswahl der Felder, Telefon nur mit Quelle und nie bei einem Heilberuf (auch nicht aus einem Altbestand, in dem die
// Nummer noch steht; leere Zeichenkette statt null, wie im Dienst), Zeit mit „Z“. Gekürzte Stubs ('[gekürzt]', purgedAt) fehlen in der Liste und in
// total und kommen nur mit id (Ereignislink). 60 sichtbare Einträge (je 15 in den vier Räumen: zwei Seiten) und 3 gekürzte Stubs mit festen Kennungen.
// Besonderheiten: Raum aerzte und eine Physiotherapie im Raum selbst sind Heilberufe mit Altbestand-Telefon im Speicher (darf nie erscheinen); ein
// Eintrag hat eine http-Quelle, zwei haben eine feindliche Quelle (javascript:, Zugangsdaten in der Adresse), die der Dienst nie liefern würde und
// die Oberfläche trotzdem nicht als Link zeigen darf; ein langer Name und ein langer Grund ohne Umbruchstellen prüfen den Umbruch am Handy.
export const BOTLEAD_PAGE_SIZE = 50
export const BOTLEAD_ROOM_KEYS = Object.freeze(['aerzte', 'handwerk', 'selbst', 'laden'])
export const BOTLEAD_IDS = Object.freeze({
health: 'bl-aerzte-01', // Heilberuf mit Altbestand-Telefon im Speicher, Seite 1
physio: 'bl-selbst-01', // Heilberuf außerhalb des Raums aerzte
http: 'bl-handwerk-02', // Quelle mit http
javascript: 'bl-laden-04', // Quelle javascript:
credentials: 'bl-selbst-03', // Quelle mit Zugangsdaten in der Adresse
long: 'bl-handwerk-01', // langer Name und langer Grund ohne Umbruchstellen
cut: 'bl-gekuerzt-01', // gekürzter Stub (nur mit id sichtbar)
cuts: Object.freeze(['bl-gekuerzt-01', 'bl-gekuerzt-02', 'bl-gekuerzt-03']),
})
const BOTLEAD_ROOM_LABEL = Object.freeze({ aerzte: 'Ärzte & Praxen', handwerk: 'Handwerk', selbst: 'Selbstständige', laden: 'Läden & Gastro' })
const BOTLEAD_ANCHOR_MS = Date.parse('2026-10-17T20:00:00.000Z')
const BOTLEAD_STEP_MS = 14.5 * 60 * 60 * 1000
const BOTLEAD_REASONS = Object.freeze([
'Keine veröffentlichte E-Mail-Adresse gefunden (nur ein Kontaktformular).',
'Gehört zu einer Kette oder einem Filialverbund, wird nicht angeschrieben.',
'Website ist in gutem Zustand (modern, mobil nutzbar), kein Bedarf erkennbar.',
'Kein Mail-Platz mehr in dieser Nacht (Tageslimit erreicht).',
'Mail nur über eine Sammeladresse des Verbunds, nicht eindeutig dem Betrieb zuzuordnen.',
'Lesen der Seite fehlgeschlagen (Zeitüberschreitung).',
'Name fehlt in Impressum und Karte.',
])
const BOTLEAD_LONG_REASON = 'Impressum nennt nur eine Postfachadresse und ein Kontaktformular; die einzige Mail-Adresse (verwaltung@sehr-langer-domainname-der-nicht-umbricht-handwerksbetrieb-westpfalz-gruppe.example) gehört einem Dritten.'
const BOTLEAD_LONG_NAME = 'Elektroinstallation-und-Haustechnik-Hoffmann-Sonnenschein-Wittgensteiner GmbH'
// [Name, PLZ, Ort] je Raum, 15 Einträge; Fund i gehört zum Raum i % 4 und dort zum Eintrag i / 4 (neueste zuerst, Raum für Raum abwechselnd)
const BOTLEAD_TABLE = Object.freeze({
aerzte: [
['Praxis Dr. Lindner', '67655', 'Kaiserslautern'], ['Zahnarztpraxis Brandt & Kollegen', '66849', 'Landstuhl'], ['Kieferorthopädie Dr. Yilmaz', '67433', 'Neustadt an der Weinstraße'],
['Augenarztpraxis am Markt', '76829', 'Landau in der Pfalz'], ['Frauenarztpraxis Dr. Schaal', '67098', 'Bad Dürkheim'], ['Kinderarzt Dr. Weigand', '66953', 'Pirmasens'],
['HNO-Praxis Dr. Reuter', '67227', 'Frankenthal'], ['Orthopädie Zentrum Westpfalz', '67697', 'Otterberg'], ['Hautarztpraxis Dr. Kessler', '66482', 'Zweibrücken'],
['Heilpraktikerin Sabine Roth', '67547', 'Worms'], ['Tierarztpraxis Dr. Keller', '67346', 'Speyer'], ['Psychotherapie Fuchs', '67269', 'Grünstadt'],
['Hausarztpraxis Dr. Baumann', '66869', 'Kusel'], ['Zahnärzte am Dom', '67346', 'Speyer'], ['Praxis für Allgemeinmedizin Dr. Heinz', '55411', 'Bingen'],
],
handwerk: [
[BOTLEAD_LONG_NAME, '67655', 'Kaiserslautern'], ['Elektro Hartmann GmbH', '67677', 'Enkenbach-Alsenborn'], ['Sanitär Weber', '66877', 'Ramstein-Miesenbach'],
['Malerbetrieb Schneider', '67806', 'Rockenhausen'], ['Schreinerei Ott', '67722', 'Winnweiler'], ['Dachdeckerei Lang & Söhne', '66849', 'Landstuhl'],
['Fliesen Becker', '67433', 'Neustadt an der Weinstraße'], ['Zimmerei Jung', '76829', 'Landau in der Pfalz'], ['Gartenbau Grünfinger', '66953', 'Pirmasens'],
['Metallbau Schmitt', '66482', 'Zweibrücken'], ['Kfz-Werkstatt Mayer', '67227', 'Frankenthal'], ['Autolackiererei Roth', '67547', 'Worms'],
['Bauunternehmen Kraus GmbH & Co. KG', '67547', 'Worms'], ['Heizungsbau Albrecht', '67098', 'Bad Dürkheim'], ['Glaserei Peters', '55543', 'Bad Kreuznach'],
],
selbst: [
['Physiotherapie Moser', '67655', 'Kaiserslautern'], ['Steuerberatung Klein', '67433', 'Neustadt an der Weinstraße'], ['Fotografie Lichtblick', '66849', 'Landstuhl'],
['Coaching Anja Berger', '67098', 'Bad Dürkheim'], ['Grafikdesign Pixelwerk', '66953', 'Pirmasens'], ['Architekturbüro Vogel', '67227', 'Frankenthal'],
['Hundeschule Pfotenglück', '66869', 'Kusel'], ['Nachhilfe Lernwerk', '76829', 'Landau in der Pfalz'], ['Fahrschule Schreiber', '67547', 'Worms'],
['Versicherungsmakler Böhm', '67346', 'Speyer'], ['Immobilien Kaufmann', '67269', 'Grünstadt'], ['Übersetzungsbüro Lingua', '55116', 'Mainz'],
['Steuerberater Dr. Franke', '67677', 'Enkenbach-Alsenborn'], ['Hundepension Waldblick', '66482', 'Zweibrücken'], ['Yogastudio Atemraum', '67697', 'Otterberg'],
],
laden: [
['Bäckerei Sommer', '67655', 'Kaiserslautern'], ['Metzgerei Kunz', '66849', 'Landstuhl'], ['Café Eselsburg', '67697', 'Otterberg'],
['Restaurant Zum Hirschen', '67433', 'Neustadt an der Weinstraße'], ['Blumenladen Rosengarten', '76829', 'Landau in der Pfalz'], ['Friseursalon Haarscharf', '67098', 'Bad Dürkheim'],
['Kosmetikstudio Belle', '66953', 'Pirmasens'], ['Boutique Mode & Mehr', '67227', 'Frankenthal'], ['Buchhandlung Seitenweise', '66482', 'Zweibrücken'],
['Eisdiele Dolce Vita', '67547', 'Worms'], ['Geschenkeladen Wunderbar', '67346', 'Speyer'], ['Fahrradladen Kettenreaktion', '67269', 'Grünstadt'],
['Weinstube Pfälzer Eck', '67433', 'Neustadt an der Weinstraße'], ['Pizzeria Da Marco', '67655', 'Kaiserslautern'], ['Kiosk am Bahnhof', '67806', 'Rockenhausen'],
],
})
function botleadDocs() {
const docs = []
const pad = (n) => String(n).padStart(2, '0')
for (let i = 0; i < 60; i += 1) {
const room = BOTLEAD_ROOM_KEYS[i % 4]
const k = Math.floor(i / 4)
const [name, postalCode, city] = BOTLEAD_TABLE[room][k]
const id = `bl-${room}-${pad(k + 1)}`
const health = room === 'aerzte' || id === BOTLEAD_IDS.physio
const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 24)
const source = room === 'selbst' ? 'impressum' : room === 'laden' ? 'osm' : 'places'
let sourceUrl = room === 'selbst' ? `https://www.${slug}.example/impressum` : room === 'laden' ? `https://www.openstreetmap.org/node/${4710000 + i * 37}` : `https://www.11880.com/branchenbuch/${slug}`
if (id === BOTLEAD_IDS.http) sourceUrl = 'http://www.elektro-hartmann-alt.example/kontakt'
if (id === BOTLEAD_IDS.javascript) sourceUrl = 'javascript:alert(document.domain)'
if (id === BOTLEAD_IDS.credentials) sourceUrl = 'https://fotografie-lichtblick.example@evil.example/impressum'
let reason = BOTLEAD_REASONS[(i * 3 + k) % BOTLEAD_REASONS.length]
if (id === BOTLEAD_IDS.long) reason = BOTLEAD_LONG_REASON
// Telefon: die meisten mit Quelle, einer nur mit Nummer (ohne Quelle gilt sie als nicht belegt), einer ganz ohne; Heilberufe tragen im Speicher eine Altbestand-Nummer samt Quelle
const phoneStyle = health ? 'alt' : k % 5 === 4 ? 'ohneQuelle' : k % 6 === 5 ? 'keine' : 'mitQuelle'
docs.push({
$id: id,
entrance: 'ohne_website',
status: 'botlead',
room,
name,
street: '',
postalCode,
city,
website: reason === BOTLEAD_REASONS[2] ? `https://www.${slug}.example/` : '',
score: 35 + ((i * 13) % 60),
botLeadReason: reason,
source,
sourceUrl,
foundAt: isoOf(BOTLEAD_ANCHOR_MS - i * BOTLEAD_STEP_MS - (i % 4) * 11 * 60 * 1000),
isHealth: health,
purgedAt: '',
phone: phoneStyle === 'keine' ? '' : `0${631 + (i % 5)} 555 ${String(100 + i * 7).padStart(4, '0')}`,
phoneSource: phoneStyle === 'mitQuelle' || phoneStyle === 'alt' ? (source === 'osm' ? 'osm' : 'impressum') : '',
})
}
// Gekürzte Stubs wie nach archive/kuerzen.js: Name '[gekürzt]', Ort, Raum, Punkte und Grund bleiben, alles Persönliche ist leer
const cuts = [['handwerk', 'Kaiserslautern', 51], ['laden', 'Landau in der Pfalz', 44], ['aerzte', 'Worms', 62]]
cuts.forEach(([room, city, score], n) => {
docs.push({
$id: BOTLEAD_IDS.cuts[n], entrance: 'ohne_website', status: 'botlead', room, name: '[gekürzt]', street: '', postalCode: '', city, website: '', score,
botLeadReason: BOTLEAD_REASONS[n], source: '', sourceUrl: '', foundAt: isoOf(BOTLEAD_ANCHOR_MS - (120 + n * 9) * 24 * 60 * 60 * 1000), isHealth: room === 'aerzte',
purgedAt: isoOf(BOTLEAD_ANCHOR_MS - (2 + n) * 24 * 60 * 60 * 1000), phone: '', phoneSource: '',
})
})
return docs
}
// Ein Eintrag der Antwort: feste Auswahl der Felder (wie eintrag() im Dienst; nie E-Mail, Quelle der Adresse, Ansprechpartner, Kennung des Orts, Anfragetext)
function botleadEntry(doc) {
const health = Boolean(doc.isHealth) || doc.room === 'aerzte'
const withNumber = !health && doc.phoneSource !== ''
const found = Date.parse(doc.foundAt)
return {
id: doc.$id,
room: doc.room,
roomLabel: BOTLEAD_ROOM_LABEL[doc.room] ?? doc.room,
name: doc.name,
street: doc.street,
postalCode: doc.postalCode,
city: doc.city,
website: doc.website,
score: Number.isFinite(doc.score) ? doc.score : 0,
reason: doc.botLeadReason,
source: doc.source,
sourceUrl: doc.sourceUrl,
foundAt: Number.isNaN(found) ? doc.foundAt : new Date(found).toISOString(),
isHealth: health,
gekuerzt: Boolean(doc.purgedAt),
phone: withNumber ? doc.phone : '',
phoneSource: withNumber ? doc.phoneSource : '',
}
}
// Suchtext und Bestand auf dieselbe Form bringen: klein, Umlaute als ae/oe/ue, Satzzeichen weg (wie normalizeName des Dienstes, ohne Rechtsformen)
const foldSearch = (value) => String(value ?? '').toLowerCase().replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss').replace(/[^a-z0-9]+/g, ' ').trim()
const BOTLEAD_ID_FORM = /^[A-Za-z0-9][A-Za-z0-9._-]{0,35}$/
/** listBotLeads des Dienstes auf dem Speicher docs: { items, total, pageSize }. Ein unbekannter Raum wirft 400 „Unbekannter Raum.“. */
export function listBotleads(docs, { room = '', q = '', page = 1, id = '' } = {}) {
const raum = room ?? ''
if (raum !== '' && !BOTLEAD_ROOM_KEYS.includes(raum)) throw Object.assign(new Error('Unbekannter Raum.'), { status: 400 })
const kennung = typeof id === 'string' ? id : ''
if (kennung !== '') {
const doc = BOTLEAD_ID_FORM.test(kennung) ? docs.find((d) => d.$id === kennung) : undefined
const items = doc ? [botleadEntry(doc)] : []
return { items, total: items.length, pageSize: BOTLEAD_PAGE_SIZE }
}
const words = foldSearch(String(q ?? '').slice(0, 100)).split(' ').filter(Boolean)
const hit = docs
.filter((d) => !d.purgedAt && (raum === '' || d.room === raum))
.filter((d) => { const hay = foldSearch([d.name, d.city, d.botLeadReason, BOTLEAD_ROOM_LABEL[d.room]].join(' ')); return words.every((w) => hay.includes(w)) })
.sort((x, y) => Date.parse(y.foundAt) - Date.parse(x.foundAt) || y.score - x.score || (x.$id < y.$id ? -1 : 1))
const seite = Number.isInteger(page) && page >= 1 ? page : 1
const from = (seite - 1) * BOTLEAD_PAGE_SIZE
return { items: hit.slice(from, from + BOTLEAD_PAGE_SIZE).map(botleadEntry), total: hit.length, pageSize: BOTLEAD_PAGE_SIZE }
}
/**
* Skript-API für den Prüfstand. now() = Uhr in Millisekunden (nur die Differenz zum Aufruf von createScriptedApi zählt),
* base = Zeitpunkt (ms) des Starts für die Zeitstempel der Ereignisse, halted = Bounce-Notbremse ist von Anfang an gesetzt (H4),
* pult = das Freigabe-Pult und die Interessenten (P3) mit den festen Daten füllen (sonst ist das Pult leer); 'p4' nimmt statt dessen den
* Satz für die Gespräche und die Werkstatt (p4Fixtures).
* Satz für die Gespräche und die Werkstatt (p4Fixtures). botleadsEmpty = die Bot-Lead-Liste ist von Anfang an leer (Leerzustand ohne Filter).
* Bot-Leads (P6): botleads({ room, q, page, id }) antwortet wie GET /botleads des Dienstes (listBotleads oben). Schalter: `faults.botleads` ({ status, message }),
* `botleadsDelayMs` (Zahl oder Funktion (Abfrage) => ms), `botleadsEmpty`; `botleadCalls` hält die Abfragen fest, `botleadsHealthPhones` die Nummern, die im
* Speicher bei Heilberufen stehen und nie ausgeliefert werden dürfen.
* Rückgabe wie createWorkersApi; saved ist die Liste der Patches, die saveSettings bekam.
*/
export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false, pult = false }) {
export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false, pult = false, botleadsEmpty = false }) {
const start = now()
const elapsed = () => Math.max(0, now() - start)
let paused = false
@@ -485,11 +659,13 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false, pu
const fx = pult === 'p4' ? p4Fixtures(base) : pult ? pultFixtures(base) : { items: [], prospects: {}, threads: {}, messages: {}, previews: {} }
const pultItems = fx.items
// je { status, message } oder null; die vier Schlüssel mit Bindestrich oder Namen der P4-Aktionen stehen ebenfalls hier
const faults = { pult: null, claim: null, sent: null, discard: null, done: null, prospect: null, reply: null, 'approve-preview': null, 'start-build': null, paste: null }
const faults = { pult: null, claim: null, sent: null, discard: null, done: null, prospect: null, reply: null, 'approve-preview': null, 'start-build': null, paste: null, botleads: null }
const pastedBefore = new Set() // Interessent + Text: dieselbe eingefügte Antwort ist beim zweiten Mal „doppelt“
const pultCalls = []
const prospectCalls = []
const pauseCalls = []
const botleadStore = botleadDocs()
const botleadCalls = []
// Alle Ereignisse des Drehbuchs mit festen Kennungen und Zeitstempeln (aufsteigend); due() = bisher fällige
const all = SCRIPT.map((s, i) => ({
@@ -621,6 +797,10 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false, pu
pultCalls,
prospectCalls,
pauseCalls,
botleadCalls,
botleadsHealthPhones: botleadStore.filter((d) => d.isHealth && d.phone !== '').map((d) => d.phone),
botleadsEmpty,
botleadsDelayMs: 0, // > 0 oder Funktion (Abfrage) => ms: die Antwort der Bot-Lead-Liste kommt erst nach so vielen Millisekunden
pultEmpty: false, // true: das Pult ist leer (Leerzustand)
pultDelayMs: 0, // > 0: jede Pult-Aktion antwortet erst nach so vielen Millisekunden (Knöpfe sind in der Zwischenzeit gesperrt)
@@ -728,9 +908,14 @@ export function createScriptedApi({ now, base = DEFAULT_BASE, halted = false, pu
return clone({ item })
},
// Bot-Leads (P6): leere Liste; der Reiter wird im Prüfstand gebaut und gezeigt, aber nicht mit Daten gefüllt (workersHarness.test.mjs pinnt die leere Antwort)
async botleads() {
return { items: [], total: 0 }
// GET /botleads?room=&q=&page=&id=: Antwort { items, total, pageSize } wie der Dienst (listBotleads); leer bei botleadsEmpty. Abfragen landen in botleadCalls.
async botleads(query = {}) {
const { room = '', q = '', page = 1, id = '' } = query ?? {}
botleadCalls.push(clone({ room, q, page, id }))
const wait = typeof api.botleadsDelayMs === 'function' ? api.botleadsDelayMs({ room, q, page, id }) : api.botleadsDelayMs
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait))
if (faults.botleads) throw failure(faults.botleads)
return clone(listBotleads(api.botleadsEmpty ? [] : botleadStore, { room, q, page, id }))
},
// GET /prospect/:id: { prospect (nur Felder der Whitelist), thread, messages, preview, reply }; Faden und Vorschau fehlen als null