Der Dateibaum im README und in 05/08, die Kommentare hinter # in den Beispielbefehlen und die Beschriftungen in Mermaid-Sequenzdiagrammen sind Lesetext, stehen aber in Codeblöcken - die erste Runde hat sie deshalb übersprungen. Die Befehle selbst und alle Bezeichner bleiben unverändert: ersetzt wurde nur hinter "# ", hinter den zwei Leerzeichen des Dateibaums und hinter ": " in Mermaid-Nachrichten. ER-Labels wie bewertet_fuer sind Bezeichner und bleiben, "systemctl daemon-reload" ebenfalls. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
202 lines
7.9 KiB
Markdown
202 lines
7.9 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 und läuft parallel zu ihr.
|
|
|
|
## 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
|
|
```
|
|
|
|
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.
|
|
|
|
## 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 schreiben"]
|
|
```
|
|
|
|
Die **Sperrfrist** ist die Antwort auf ein konkretes Problem: beide Instanzen
|
|
benutzen dieselben Portal-Cookies. Beim ersten Start ziehen sonst beide
|
|
gleichzeitig los, was Rate-Limits und CAPTCHAs provoziert - und zwei Chromium
|
|
gleichzeitig belasten den Speicher doppelt.
|
|
|
|
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.
|
|
|
|
## Parallelbetrieb - alles verdoppelt
|
|
|
|
```mermaid
|
|
flowchart LR
|
|
subgraph alt["alte App (scanner.perinco.de)"]
|
|
A1["Express :5005"] --> A2[("project_scanner")]
|
|
A1 --> A3["uploads/ in<br/>/var/www/Projekt Scanner/backend"]
|
|
A4["ENCRYPTION_KEY"] --- A1
|
|
end
|
|
|
|
subgraph neu["CRM (pcrm.perinco.de)"]
|
|
N1["Express :5006<br/>/api/scanner"] --> N2[("perinco_scanner")]
|
|
N1 --> N3["uploads/ in<br/>/var/www/perinco_CRM/server"]
|
|
N4["SCANNER_ENCRYPTION_KEY"] --- N1
|
|
end
|
|
|
|
B["Projektbörsen"]
|
|
O["OpenAI"]
|
|
A1 --> B
|
|
N1 --> B
|
|
A1 --> O
|
|
N1 --> O
|
|
|
|
IMP["npm run scanner:import"] -.->|"liest"| A2
|
|
IMP -.->|"schreibt"| N2
|
|
IMP -.->|"kopiert"| N3
|
|
```
|
|
|
|
| | alte App | CRM |
|
|
| --- | --- | --- |
|
|
| Datenbank | `project_scanner` | `perinco_scanner` |
|
|
| Schlüssel | `ENCRYPTION_KEY` | `SCANNER_ENCRYPTION_KEY` |
|
|
| CV-Dateien | eigener Ordner | eigener Ordner |
|
|
| Aufräumen | 03:00 | 03:30 |
|
|
| Anmeldung | HTTP Basic | CRM-Sitzung (JWT) |
|
|
|
|
**Der Preis:** Beide bewerten dieselben Projekte unabhängig voneinander, die
|
|
OpenAI-Kosten fallen also doppelt an. Wer das senken will, stellt im CRM ein
|
|
größeres Suchintervall ein - die Einstellung gilt nur für diese Instanz.
|
|
|
|
## Übernahme aus der alten Datenbank
|
|
|
|
`server/src/scanner/importLegacy.js`, aufrufbar als `npm run scanner:import`:
|
|
|
|
```mermaid
|
|
flowchart TD
|
|
S["Start"] --> C{"Zieltabelle agents leer?"}
|
|
C -->|nein, ohne --force| AB["Abbruch"]
|
|
C -->|ja oder --force| E["Einstellungen kopieren<br/>inkl. OpenAI-Key"]
|
|
E --> P["Portale über den Namen zuordnen:<br/>Benutzername, Session-Cookies, Status"]
|
|
P --> PW{"LEGACY_ENCRYPTION_KEY gesetzt?"}
|
|
PW -->|ja| PW2["Passwort entschlüsseln<br/>und neu verschlüsseln"]
|
|
PW -->|nein| PW3["Passwort auslassen"]
|
|
PW2 --> A["Agenten anlegen, Ids behalten<br/>CV-PDF kopieren"]
|
|
PW3 --> A
|
|
A --> M{"--with-matches?"}
|
|
M -->|ja| M2["Projekte und Treffer<br/>mit Original-Ids"]
|
|
M -->|nein| F["fertig"]
|
|
M2 --> F
|
|
```
|
|
|
|
Die Ids bleiben erhalten, damit übernommene Treffer weiter auf die richtigen
|
|
Agenten und Projekte zeigen. Deshalb bricht das Skript bei bereits vorhandenen
|
|
Agenten ab.
|
|
|
|
> **Beim Rollout am 06.08.2026 festgestellt:** Die Portal-Passwörter der alten
|
|
> Datenbank lassen sich mit dem dortigen `ENCRYPTION_KEY` nicht mehr
|
|
> entschlüsseln - der Schlüssel wurde nach dem Speichern getauscht. Die alte App
|
|
> kann sie damit ebenso wenig lesen, es ging also nichts verloren. Die
|
|
> Session-Cookies liegen im Klartext und wurden übernommen; damit scrapt die
|
|
> neue Instanz sofort.
|
|
|
|
## 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, 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 |
|
|
| GET/POST | `/settings` | Geheimnisse maskiert |
|
|
| POST | `/scan` | Lauf starten, `409` wenn schon einer läuft |
|
|
| GET | `/status` | läuft ein Lauf, Scheduler an, Datenbankname |
|