Neu gefundene Projekte mit Treffer werden einmalig per gpt-5.6-luna mit den Akquise-Eintraegen und Opportunities der letzten N Tage (Vorgabe 14) verglichen. Passt ein Eintrag, zeigt der Scanner "bereits beworben!" samt Begruendung. Schalter und Zeitraum (Tage/Wochen) in Einstellungen -> Scanner, Neupruefung von Hand im Detail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
316 lines
15 KiB
Markdown
316 lines
15 KiB
Markdown
# 03 Projekt Scanner
|
|
|
|
Der Scanner durchsucht Projektbörsen nach Aufträgen und bewertet jeden Fund mit
|
|
OpenAI gegen die Lebensläufe der Berater. Er ist eine vollständige Portierung
|
|
der eigenständigen App, die er seit dem 09.09.2026 ersetzt
|
|
([Rückbau](#rückbau-der-alten-app---09092026)).
|
|
|
|
## Ein Suchlauf
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant C as node-cron<br/>jede Minute
|
|
participant P as Pipeline
|
|
participant PW as Playwright
|
|
participant B as Projektboersen
|
|
participant DB as perinco_scanner
|
|
participant AI as OpenAI gpt-4o-mini
|
|
participant M as SMTP
|
|
|
|
C->>P: fällig? (Intervall, Mo-Fr, 07-22 Uhr, Sperrfrist)
|
|
P->>P: läuft schon einer? dann abbrechen
|
|
P->>DB: aktive Agenten laden
|
|
P->>P: Keywords aller Agenten zusammenfuehren
|
|
P->>DB: Portal-Sessions laden
|
|
|
|
loop je Portal
|
|
P->>PW: Chromium starten, Cookies setzen
|
|
PW->>B: Suchseite je Keyword
|
|
B-->>PW: Projektkarten
|
|
PW-->>P: Projekte (Titel, Beschreibung, Ort, Rate, URL)
|
|
end
|
|
|
|
loop je Projekt x Agent
|
|
P->>DB: Treffer schon vorhanden? dann weiter
|
|
P->>P: Ausschlussbegriff im Text? dann weiter
|
|
P->>AI: Lebenslauf vs. Projektbeschreibung
|
|
AI-->>P: { matchPercentage, pros[], cons[] }
|
|
P->>DB: Treffer speichern
|
|
alt Prozent >= Benachrichtigungs-Schwelle
|
|
P->>M: E-Mail an Agent oder Standardempfaenger
|
|
end
|
|
end
|
|
|
|
opt Abgleich aktiv, Projekt hat Treffer, noch nicht geprüft
|
|
P->>P: Akquise und Opportunities der letzten N Tage laden
|
|
P->>AI: gpt-5.6-luna: Projekt vs. CRM-Einträge
|
|
AI-->>P: { matches: [{ type, id, reason }] }
|
|
P->>DB: already_applied, applied_refs, applied_check_at
|
|
end
|
|
```
|
|
|
|
Zwei Sparmassnahmen stecken bewusst **vor** dem OpenAI-Aufruf: der vorhandene
|
|
Treffer (Unique-Key `agent_id, project_id`) und die Ausschlussbegriffe. Ein
|
|
Projekt, das „Arbeitnehmerüberlassung" enthält, kostet damit keinen Cent.
|
|
|
|
## Abgleich „bereits beworben!"
|
|
|
|
Dieselbe Ausschreibung kommt oft mehrfach: über zwei Börsen, über mehrere
|
|
Vermittler, mit anderem Titel und anderem Link. Damit niemand ein Projekt ein
|
|
zweites Mal anbietet, gleicht `services/appliedCheck.js` jeden Fund mit der
|
|
**Akquise-Liste** (`crm_acquisitions`) und den **Opportunities**
|
|
(`crm_opportunities`) ab. Steht er dort schon, trägt der Treffer im Dashboard,
|
|
unter *Unpassend* und im Detail die Markierung **bereits beworben!**; das Detail
|
|
nennt die passenden Einträge samt Begründung.
|
|
|
|
```mermaid
|
|
flowchart TD
|
|
A["Projekt im Lauf verarbeitet"] --> B{"applied_check_active?"}
|
|
B -->|nein| X["nichts tun"]
|
|
B -->|ja| C{"schon geprüft?<br/>applied_check_at"}
|
|
C -->|ja| X
|
|
C -->|nein| D{"mindestens ein Treffer?"}
|
|
D -->|nein| X
|
|
D -->|ja| E["Kandidaten laden:<br/>Akquise found_at, Opportunities created_at<br/>der letzten N Tage, ohne Papierkorb"]
|
|
E --> F{"Kandidaten?"}
|
|
F -->|nein| N["nicht beworben"]
|
|
F -->|ja| G{"gleicher Link?"}
|
|
G -->|ja| Y["bereits beworben"]
|
|
G -->|nein| H["gpt-5.6-luna, reasoning_effort low"]
|
|
H -->|Treffer mit bekannter Id| Y
|
|
H -->|keine| N
|
|
N --> S["am Projekt speichern"]
|
|
Y --> S
|
|
```
|
|
|
|
| Einstellung | Vorgabe | Bedeutung |
|
|
| --- | --- | --- |
|
|
| `applied_check_active` | `true` | Schalter in *Einstellungen → Scanner* |
|
|
| `applied_check_days` | `14` | Zeitraum zurück, in Tagen (Oberfläche: Tage oder Wochen), höchstens 365 |
|
|
|
|
Entscheidungen dahinter:
|
|
|
|
- **Eigenes Modell.** Die Bewertung CV gegen Projekt bleibt bei `gpt-4o-mini`;
|
|
der Abgleich nutzt `gpt-5.6-luna` (Konstante `APPLIED_CHECK_MODEL`). Er muss
|
|
umformulierte Ausschreibungen als dieselbe erkennen und ähnliche Rollen bei
|
|
anderem Kunden auseinanderhalten - das ist ein Urteil, kein Textvergleich.
|
|
Der Schlüssel ist derselbe `openai_api_key`.
|
|
- **Einmal je Projekt, nur mit Treffer.** Die Börsen liefern bei jedem Lauf
|
|
dieselben Projekte erneut; `applied_check_at` verhindert, dass sie jedes Mal
|
|
bezahlt werden. Projekte ohne Treffer zeigt die Oberfläche nie an.
|
|
- **Gleicher Link braucht keine KI.** Verglichen wird ohne Protokoll, `www`,
|
|
Query und Schrägstrich am Ende.
|
|
- **Nur Ids, die wir geschickt haben.** Nennt das Modell eine unbekannte Id,
|
|
zählt sie nicht.
|
|
- **Fehler setzen aus, nicht ab.** Scheitert der Aufruf (Schlüssel, Quote,
|
|
Netz), bleibt der Abgleich für den Rest des Laufs aus und das Projekt
|
|
ungeprüft - der nächste Lauf versucht es wieder. Die Bewertung läuft davon
|
|
unberührt weiter.
|
|
- **Momentaufnahme.** Geprüft wird beim Fund. Wird die Bewerbung erst danach
|
|
in der Akquise erfasst, holt *Auf Bewerbung prüfen* im Detail den Abgleich
|
|
von Hand nach (`POST /projects/:id/applied-check`, auch bei abgeschaltetem
|
|
Automatismus).
|
|
|
|
Die drei Spalten an `projects` legt `initScannerDatabase()` per
|
|
`ALTER TABLE ... ADD COLUMN IF NOT EXISTS` an - `sync()` ohne `alter` tut das in
|
|
bestehenden Tabellen nicht.
|
|
|
|
## Wann läuft er
|
|
|
|
```mermaid
|
|
flowchart TD
|
|
T["Cron-Tick, jede Minute"] --> A{"scanning_active?"}
|
|
A -->|nein| STOP1["nichts tun"]
|
|
A -->|ja| B{"Intervall geändert?"}
|
|
B -->|ja| RE["neu planen"]
|
|
B -->|nein| C{"Sperrfrist<br/>nur beim allerersten Start"}
|
|
C -->|noch aktiv| STOP2["nichts tun"]
|
|
C -->|abgelaufen| D{"Mo-Fr?"}
|
|
D -->|nein| STOP3["nichts tun"]
|
|
D -->|ja| E{"07:00-21:59?"}
|
|
E -->|nein| STOP4["nichts tun"]
|
|
E -->|ja| S["last_scan_at aus der DB lesen<br/>(fängt manuelle Läufe mit ein)"]
|
|
S --> F{"Intervall seit<br/>letztem Lauf um?"}
|
|
F -->|nein| STOP5["nichts tun"]
|
|
F -->|ja| G["Pipeline starten"]
|
|
G --> W["last_scan_at = Beginn des Laufs"]
|
|
```
|
|
|
|
Geschrieben wird der **Beginn** des Laufs, nicht sein Ende. Das Intervall ist ein
|
|
Takt: mit dem Endzeitpunkt kam die Laufzeit von drei bis vier Minuten jedes Mal
|
|
obendrauf, aus 15 Minuten wurden 19 - rund ein Fuenftel weniger Durchgaenge pro
|
|
Tag, und die Trefferliste der Boersen zeigt nur die neuesten Projekte.
|
|
|
|
Die **Sperrfrist** war die Antwort auf ein konkretes Problem des
|
|
Parallelbetriebs: beide Instanzen benutzten dieselben Portal-Cookies. Beim
|
|
ersten Start zogen sonst beide gleichzeitig los, was Rate-Limits und CAPTCHAs
|
|
provozierte - und zwei Chromium gleichzeitig belasten den Speicher doppelt. Seit
|
|
dem Rückbau der alten App steht `SCANNER_SCHEDULER_OFFSET` auf 0; die Mechanik
|
|
bleibt im Code, weil sie bei jedem Erststart einer weiteren Instanz wieder
|
|
greift.
|
|
|
|
Der Zeitpunkt des letzten Laufs steht in `settings.last_scan_at`. Das ist kein
|
|
Luxus, sondern nach zwei beobachteten Fehlern nötig:
|
|
|
|
> **Erster Fehler:** Der Versatz war über einen vorgealterten `lastRunTime`
|
|
> gebaut. Ein Zeitstempel kann einen Lauf aber nur **vorziehen**, nie verzögern -
|
|
> bei Intervall 15 und Versatz 30 lief er sofort. Seither eine eigene Frist
|
|
> (`notBefore`).
|
|
>
|
|
> **Zweiter Fehler:** Diese Frist begann bei **jedem** Neustart von vorn. Zwei
|
|
> Deployments im Abstand von zehn Minuten haben den fälligen Lauf zweimal um eine
|
|
> halbe Stunde verschoben - er fand nie statt. Wer öfter als alle 30 Minuten
|
|
> deployt, hätte einen Scanner, der nie automatisch sucht. Deshalb liegt der
|
|
> Zeitpunkt jetzt in der Datenbank, und der Versatz greift nur, solange dort noch
|
|
> nichts steht.
|
|
|
|
Der gespeicherte Zeitpunkt wird auch **im Tick** gelesen: ein manueller Scan aus
|
|
der Oberfläche schreibt ihn, weiß aber nichts von der Variablen im Scheduler.
|
|
Ohne den Abgleich könnte der Scheduler direkt hinter einem manuellen Lauf noch
|
|
einmal losziehen.
|
|
|
|
## Ein Lauf zur Zeit
|
|
|
|
Zwei Aufrufer können die Pipeline starten: der Cron-Tick und der Knopf "Jetzt
|
|
scannen" (`POST /api/scanner/scan`). Keiner von beiden weiß vom anderen, und der
|
|
Tick prüft nur die verstrichene Zeit - nicht, ob der vorige Lauf noch arbeitet.
|
|
Ein voller Lauf dauert länger als das Intervall (43 Suchbegriffe über mehrere
|
|
Börsen), also überholen sich die Läufe und stapeln sich auf.
|
|
|
|
Deshalb hält `pipeline.js` ein Modul-Flag. Beide Aufrufer teilen es, weil beide
|
|
dasselbe Modul importieren:
|
|
|
|
```js
|
|
let pipelineRunning = false;
|
|
let pipelineStartedAtMs = null;
|
|
const PIPELINE_MAX_RUNTIME_MS = 60 * 60 * 1000;
|
|
|
|
export const runMatchingPipeline = async () => {
|
|
if (pipelineRunning) { /* ... überspringen ... */ }
|
|
pipelineRunning = true;
|
|
pipelineStartedAtMs = Date.now();
|
|
try {
|
|
return await executePipeline();
|
|
} finally {
|
|
pipelineRunning = false; // zwingend im finally
|
|
pipelineStartedAtMs = null;
|
|
}
|
|
};
|
|
```
|
|
|
|
Das `finally` ist der wichtigste Teil. Würde das Flag am Ende des `try`-Blocks
|
|
zurückgesetzt, bliebe es nach einer Ausnahme für immer `true` - der Scanner
|
|
würde bis zum nächsten Neustart des Dienstes nie wieder suchen. Das wäre
|
|
schlimmer als die Überlappung, gegen die die Sperre da ist.
|
|
|
|
Die Route fragt zusätzlich `isPipelineRunning()` ab und antwortet mit **409**,
|
|
statt einen Start zu melden, den die Sperre gleich darauf verwirft.
|
|
|
|
> **Warum das nötig ist - der Vorfall vom 26.08.2026.** Die alte App
|
|
> (`scanner.perinco.de`) hatte diese Sperre nicht. Gemessen an diesem Tag: fünf
|
|
> Pipeline-Starts in 16 Minuten, zwei davon zehn Sekunden auseinander. Jeder Lauf
|
|
> startet eigene Chromium-Instanzen, jede mit rund fünf Unterprozessen - in
|
|
> Summe **31 `chrome-headless-shell`-Prozesse** und **1,62 GB RSS** im Dienst,
|
|
> nach 2 Monaten 23 Tagen ohne Neustart. Auf einer Box mit 4 Kernen und 7,7 GiB
|
|
> RAM ergab das **Load Average 126**, 168 MB freien Speicher und einen zu 100 %
|
|
> gefüllten Swap. Die Maschine war im Thrashing und nahe am OOM-Killer; selbst
|
|
> `systemctl` und SSH brauchten Minuten. Ein Neustart brachte Load 0,87 und
|
|
> 277 MB RSS zurück.
|
|
>
|
|
> Wichtig für die Fehlersuche: die Chromium-Prozesse waren **kein Leck**. Alle
|
|
> Scraper schließen ihren Browser in einem `finally`. Die 31 Prozesse waren
|
|
> schlicht fünf gleichzeitige Läufe mal mehrere Börsen - die Sperre allein
|
|
> behebt das.
|
|
|
|
Die Sperre schützt gegen Überholen, nicht gegen einen Lauf, der gar nicht
|
|
zurückkommt. Hängt ein Browser oder steht eine Verbindung, läuft das `finally`
|
|
nie. Dagegen das **Notventil** `PIPELINE_MAX_RUNTIME_MS`: läuft die Sperre
|
|
länger als eine Stunde, gibt der nächste Aufruf sie frei und startet neu. So
|
|
erholt sich der Scanner selbst, statt auf ein Deployment zu warten.
|
|
|
|
## Rückbau der alten App - 09.09.2026
|
|
|
|
Der Scanner lief seit dem Rollout am 06.08.2026 **parallel** zur eigenständigen
|
|
Vorgänger-App unter `scanner.perinco.de`. Diese ist am 09.09.2026 vollständig
|
|
zurückgebaut. Verschwunden sind:
|
|
|
|
| Weg | War |
|
|
| --- | --- |
|
|
| Unit `project-scanner-backend` | Express auf `0.0.0.0:5005` |
|
|
| Vhosts `scanner.perinco.de(.conf/-le-ssl.conf)` | eigener Apache-Vhost, Basic Auth |
|
|
| Zertifikat `scanner.perinco.de` | eigenes Let's-Encrypt-Zert samt Renewal-Config |
|
|
| `/var/www/Projekt Scanner` | 486 MB Code, `node_modules`, 19 CV-PDFs |
|
|
| Datenbank `project_scanner` | 16,2 MB; Dump liegt unter `/root/backups/project_scanner_20260909-173447.sql.gz` |
|
|
| `LEGACY_*` in `server/.env` | Quelle des Importskripts |
|
|
| `server/src/scanner/importLegacy.js` | `npm run scanner:import` |
|
|
|
|
**Was bewusst stehen geblieben ist:** `/root/.cache/ms-playwright` (641 MB, Build
|
|
`chromium-1223`). Der Cache stammt aus der Installation der alten App, aber
|
|
`PLAYWRIGHT_BROWSERS_PATH` ist nirgends gesetzt - **beide** Apps haben ihn
|
|
benutzt, und `playwright` ist im CRM auf exakt `1.60.0` gepinnt, damit es genau
|
|
dieser Build bleibt. Wer den Cache mit dem alten Verzeichnis löscht, nimmt dem
|
|
CRM-Scanner den Browser. Der Ordner `/var/www/Projekt Scanner/backend/.playwright`
|
|
war dagegen toter Ballast einer Installation mit gesetztem
|
|
`PLAYWRIGHT_BROWSERS_PATH` und ist mit dem Verzeichnis mitgegangen.
|
|
|
|
Was der Rückbau ändert:
|
|
|
|
- **Kosten:** Beide Instanzen haben dieselben Projekte unabhängig voneinander
|
|
bewertet, die OpenAI-Kosten fielen doppelt an. Faktisch war das schon am
|
|
09.09.2026 um 08:49 vorbei - da wurde `scanning_active` in der alten
|
|
Oberfläche auf `false` gesetzt, letzter Lauf dort 09:02.
|
|
- **Portal-Sessions:** Beide Instanzen benutzten dieselben Cookies der
|
|
Projektbörsen. Der Grund für `SCANNER_SCHEDULER_OFFSET` fällt damit weg, der
|
|
Wert steht jetzt auf 0. Rate-Limits und CAPTCHAs durch zwei gleichzeitig
|
|
scrapende Chromium-Sätze sind Geschichte.
|
|
- **Speicher:** rund 200 MB RSS und ein zweiter Playwright-Satz weniger auf
|
|
einer Box mit 7,7 GiB.
|
|
- **Daten:** Nicht übernommen wurden 13380 Treffer, die es nur in der alten
|
|
Datenbank gab, darunter 92 als *reviewed* und 5 als *applied* markierte. Ein
|
|
nachträglicher Import war nicht sinnvoll: das Importskript vergibt die
|
|
Original-Ids und wäre mit den vorhandenen Ids in `perinco_scanner`
|
|
kollidiert. Der Bestand war ohnehin flüchtig - der Aufräumjob hält nur die
|
|
jüngsten Tage. Wer doch etwas sucht, greift auf den Dump zurück.
|
|
|
|
Die Domain `scanner.perinco.de` zeigt weiter auf den Server, hat aber keinen
|
|
eigenen Vhost mehr: HTTP landet beim ersten aktivierten Vhost, HTTPS gibt eine
|
|
Zertifikatswarnung. Der DNS-Eintrag muss beim Anbieter gelöscht werden.
|
|
|
|
## Oberfläche
|
|
|
|
| Reiter | Datei | Inhalt |
|
|
| --- | --- | --- |
|
|
| Dashboard | `ScannerDashboard.tsx` | Kennzahlen, Filter, Trefferliste, *Jetzt scannen*, Ton bei neuen Treffern |
|
|
| Unpassend | `ScannerRejected.tsx` | Treffer mit Status `rejected` |
|
|
| Suchagenten | `ScannerAgents.tsx` | Beraterprofile, CV-Upload |
|
|
| Börsen-Konten | `ScannerPortals.tsx` | Verbindungsstatus, Zugangsdaten, Cookie-Import |
|
|
| Einstellungen | `settings/ScannerSettings.tsx` | Intervall, Schwellenwerte, Abgleich mit Akquise/Opportunities, OpenAI, Ton, E-Mail |
|
|
|
|
**Wichtig zum Ton:** Er hängt an der Dashboard-Komponente. Wer auf einen anderen
|
|
Menüpunkt wechselt, hört nichts - die Komponente wird ausgebaut und die
|
|
30-Sekunden-Abfrage endet. In einem anderen Browsertab bleibt der Ton dagegen
|
|
aktiv, nur langsamer (Chrome bremst Timer versteckter Tabs auf etwa einmal pro
|
|
Minute) und nur, wenn zuvor irgendwo auf der Seite geklickt wurde
|
|
(Autoplay-Regel). Browserunabhängig meldet nur die E-Mail.
|
|
|
|
## Endpunkte
|
|
|
|
Alle unter `/api/scanner`, alle hinter `requireAuth`:
|
|
|
|
| Methode | Pfad | Zweck |
|
|
| --- | --- | --- |
|
|
| GET | `/agents` | Liste ohne `cvText` (Nutzlast) |
|
|
| POST/PUT | `/agents[/:id]` | anlegen/ändern, `multipart` mit CV |
|
|
| DELETE | `/agents/:id` | Agent samt CV-Datei |
|
|
| GET | `/agents/:id/cv` | PDF als Stream |
|
|
| GET | `/portals` | Status ohne Geheimnisse |
|
|
| POST | `/portals/credentials` | Zugangsdaten, AES-verschlüsselt |
|
|
| POST | `/portals/sync-session` | Cookie-Import, normalisiert für Playwright |
|
|
| POST | `/portals/test-connection/:id` | Mini-Scrape bzw. Session-Prüfung |
|
|
| GET | `/matches` | Filter: `status`, `agentId`, `minPercentage`, `limit` |
|
|
| PUT/DELETE | `/matches/:id` | Status setzen, löschen |
|
|
| POST | `/projects/:id/applied-check` | Abgleich „bereits beworben!" von Hand wiederholen |
|
|
| GET/POST | `/settings` | Geheimnisse maskiert |
|
|
| POST | `/scan` | Lauf starten, `409` wenn schon einer läuft |
|
|
| GET | `/status` | läuft ein Lauf, Scheduler an, Datenbankname |
|