chore(scanner): alte Scanner-App zurueckgebaut

Die eigenstaendige Vorgaenger-App unter scanner.perinco.de ist auf dem Server
entfernt: Unit project-scanner-backend (Port 5005), beide Apache-Vhosts, das
Let's-Encrypt-Zertifikat samt Renewal-Config, /var/www/Projekt Scanner (486 MB)
und die Datenbank project_scanner (Dump unter /root/backups). Damit entfallen
die LEGACY_*-Variablen und das Importskript, dessen Quelle es nicht mehr gibt.

/root/.cache/ms-playwright bleibt bewusst stehen: der Cache stammt aus der
Installation der alten App, liegt aber ausserhalb beider Verzeichnisse und wird
vom CRM-Scanner benutzt - playwright ist auf 1.60.0 gepinnt, damit es genau
dieser Build (chromium-1223) bleibt. Der Hinweis steht jetzt in README und
docs/06-betrieb.md, damit ihn beim naechsten Aufraeumen niemand mitnimmt.

SCANNER_SCHEDULER_OFFSET geht auf 0: der Versatz sollte verhindern, dass beide
Instanzen mit denselben Portal-Cookies gleichzeitig losziehen. Ohne zweite
Instanz hat er keinen Zweck mehr, die Mechanik bleibt fuer kuenftige Erststarts
im Code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-09 17:45:42 +02:00
co-authored by Claude Opus 5
parent ab6b9f2372
commit 3c9d83161e
11 changed files with 158 additions and 646 deletions
+61 -106
View File
@@ -211,22 +211,13 @@ starten absteigend, Text aufsteigend; leere Werte landen immer hinten.
## Projekt Scanner ## Projekt Scanner
Der eigenständige *Projekt Scanner* ist vollständig hier eingezogen und soll Der eigenständige *Projekt Scanner* ist vollständig hier eingezogen und hat die
die alte App ersetzen. Die läuft weiter unter `scanner.perinco.de`: alte App unter `scanner.perinco.de` am **09.09.2026** ersetzt. Seither läuft nur
noch diese Instanz; der Rückbau ist in
[docs/03-scanner.md](docs/03-scanner.md#rückbau-der-alten-app---09092026)
protokolliert.
``` Menüpunkt **Scanner** links; ein Klick klappt die Reiter darunter auf:
/var/www/Projekt Scanner/backend Express auf 0.0.0.0:5005
/var/www/Projekt Scanner/frontend/dist DocumentRoot des Vhosts
systemd-Unit: project-scanner-backend (nicht "project-scanner")
```
Bis zur Ablosung laufen **beide parallel und beide voll funktional**: eigene
Datenbank, eigener Verschlüsselungsschlüssel, eigenes Upload-Verzeichnis,
eigener Scheduler. Die alte App bleibt unverändert - weder ihre Dateien noch
ihre Datenbank werden angefasst.
Menüpunkt **Scanner** links; ein Klick klappt die Reiter der alten App darunter
auf:
| Reiter | Inhalt | | Reiter | Inhalt |
| --- | --- | | --- | --- |
@@ -248,7 +239,6 @@ beiden Stellen ändern; es ist derselbe Wert in der Datenbank.
| Scraper (Playwright), Matching (OpenAI `gpt-4o-mini`), Pipeline, Scheduler, Cleanup, PDF-Parser | `server/src/scanner/services/` | | Scraper (Playwright), Matching (OpenAI `gpt-4o-mini`), Pipeline, Scheduler, Cleanup, PDF-Parser | `server/src/scanner/services/` |
| REST-API | `server/src/routes/scanner.js`, gemountet als `/api/scanner` | | REST-API | `server/src/routes/scanner.js`, gemountet als `/api/scanner` |
| Oberfläche | `src/components/scanner/`, in Tailwind und CI-Farben neu gebaut | | Oberfläche | `src/components/scanner/`, in Tailwind und CI-Farben neu gebaut |
| Übernahme aus der alten DB | `server/src/scanner/importLegacy.js` (`npm run scanner:import`) |
Drei bewusste Unterschiede zum Original: Drei bewusste Unterschiede zum Original:
@@ -259,19 +249,23 @@ Drei bewusste Unterschiede zum Original:
mitschicken, deshalb holt das Frontend die Datei und öffnet sie als Blob. mitschicken, deshalb holt das Frontend die Datei und öffnet sie als Blob.
3. **Meldungen** laufen als Toast statt `alert()`, Löschabfragen als Dialog. 3. **Meldungen** laufen als Toast statt `alert()`, Löschabfragen als Dialog.
### Parallelbetrieb - alles doppelt ### Eigene Ressourcen, eigener Zeitplan
Beide Scanner laufen **gleichzeitig und funktional**, ohne sich zu berühren. Der Scanner benutzt durchweg Eigenes und nichts aus der Vorgänger-App - das war
Dafür ist hier jede geteilte Ressource verdoppelt: die Voraussetzung für den Parallelbetrieb und macht den Rückbau folgenlos:
| | alte App | diese Instanz | | | alte App | diese Instanz |
| --- | --- | --- | | --- | --- | --- |
| Datenbank | `project_scanner` | `perinco_scanner` (eigene Tabellen, gleiche Namen) | | Datenbank | `project_scanner` (gedroppt) | `perinco_scanner` |
| Verschlüsselung | `ENCRYPTION_KEY` | `SCANNER_ENCRYPTION_KEY` - eigener Wert | | Verschlüsselung | `ENCRYPTION_KEY` | `SCANNER_ENCRYPTION_KEY` - eigener Wert |
| CV-PDFs | `/var/www/Projekt Scanner/backend/uploads` | `SCANNER_UPLOAD_DIR` = `/var/www/perinco_CRM/server/uploads` | | CV-PDFs | `/var/www/Projekt Scanner/backend/uploads` (gelöscht) | `SCANNER_UPLOAD_DIR` = `/var/www/perinco_CRM/server/uploads` |
| Scheduler | eigener Cron im alten Dienst | `SCANNER_SCHEDULER`, Standard `true` | | Scheduler | eigener Cron im alten Dienst | `SCANNER_SCHEDULER`, Standard `true` |
| Aufräumen | 03:00 | 03:30 | | Aufräumen | 03:00 | 03:30 |
Geteilt war genau eine Sache, und die liegt ausserhalb beider Verzeichnisse:
`/root/.cache/ms-playwright` mit dem Build `chromium-1223`. Der Cache ist beim
Rückbau bewusst stehen geblieben, siehe Abschnitt *Playwright*.
Die Scanner-Datenbank legt das Backend beim Start **selbst an** Die Scanner-Datenbank legt das Backend beim Start **selbst an**
(`CREATE DATABASE IF NOT EXISTS`), die Tabellen kommen aus `sequelize.sync()`, (`CREATE DATABASE IF NOT EXISTS`), die Tabellen kommen aus `sequelize.sync()`,
Portale und Standardeinstellungen werden geseedet. Es genügt also, das Backend Portale und Standardeinstellungen werden geseedet. Es genügt also, das Backend
@@ -279,28 +273,23 @@ zu starten.
| Variable | Bedeutung | | Variable | Bedeutung |
| --- | --- | | --- | --- |
| `SCANNER_DB_NAME` | eigene Datenbank, Standard `perinco_scanner` - **nicht** auf `project_scanner` zeigen lassen | | `SCANNER_DB_NAME` | eigene Datenbank, Standard `perinco_scanner` |
| `SCANNER_DB_HOST/_PORT/_USER/_PASSWORD` | leer = CRM-Zugangsdaten | | `SCANNER_DB_HOST/_PORT/_USER/_PASSWORD` | leer = CRM-Zugangsdaten |
| `SCANNER_ENCRYPTION_KEY` | eigener Schlüssel für die Portal-Passwörter; ohne ihn wird ein bekannter Ersatzschlüssel benutzt und beim Start gewarnt | | `SCANNER_ENCRYPTION_KEY` | Schlüssel für die Portal-Passwörter; ohne ihn wird ein bekannter Ersatzschlüssel benutzt und beim Start gewarnt |
| `SCANNER_UPLOAD_DIR` | eigenes CV-Verzeichnis; leer = `<server>/uploads` | | `SCANNER_UPLOAD_DIR` | CV-Verzeichnis; leer = `<server>/uploads` |
| `SCANNER_SCHEDULER` | Hintergrund-Scraper; Standard an, nur `false` schaltet ab | | `SCANNER_SCHEDULER` | Hintergrund-Scraper; Standard an, nur `false` schaltet ab |
| `SCANNER_SCHEDULER_OFFSET` | Minuten Versatz für den **allerersten** Lauf, Standard `30`. Danach trägt `settings.last_scan_at` den Zeitplan über Neustarts hinweg | | `SCANNER_SCHEDULER_OFFSET` | Minuten Versatz für den **allerersten** Lauf, seit dem Rückbau `0`. Danach trägt `settings.last_scan_at` den Zeitplan über Neustarts hinweg |
> **Kosten:** Beide Instanzen bewerten dieselben Projekte unabhängig Wer nur zuschauen will, setzt `SCANNER_SCHEDULER=false`; der Knopf *Jetzt
> voneinander - die OpenAI-Kosten fallen also doppelt an, solange die alte App scannen* funktioniert dann weiter.
> läuft. Das ist der Preis des Parallelbetriebs, kein Fehler. Wer nur zuschauen
> will, setzt `SCANNER_SCHEDULER=false`; der Knopf *Jetzt scannen* funktioniert
> dann weiter.
Der **Zeitversatz** ist nicht Kosmetik: beide Instanzen benutzen dieselben Der **Zeitversatz** war nicht Kosmetik: beide Instanzen benutzten dieselben
Portal-Sessions (aus derselben Cookie-Quelle) und würden beim ersten Start Portal-Sessions und wären beim ersten Start gleichzeitig losgezogen - das
gleichzeitig losziehen - das provoziert Rate-Limits und CAPTCHAs bei provoziert Rate-Limits und CAPTCHAs bei Freelancermap und Freelance.de. Mit dem
Freelancermap und Freelance.de. `SCANNER_SCHEDULER_OFFSET=30` verschiebt den Rückbau steht der Wert auf 0; die Mechanik bleibt im Code, weil sie bei jedem
ersten Lauf um 30 Minuten, danach laufen beide im eigenen Intervall verschränkt. Erststart einer weiteren Instanz wieder greift. Ab dem zweiten Start trägt
`settings.last_scan_at` den Zeitplan - sonst begann die Frist bei jedem
Ab dem zweiten Start trägt `settings.last_scan_at` den Zeitplan - die Frist Deployment von vorn und ein fälliger Lauf konnte beliebig lange ausfallen (siehe
greift dann nicht mehr. Sonst begann sie bei jedem Deployment von vorn und ein
fälliger Lauf konnte beliebig lange ausfallen (siehe
[docs/03-scanner.md](docs/03-scanner.md#wann-laeuft-er)). [docs/03-scanner.md](docs/03-scanner.md#wann-laeuft-er)).
Innerhalb dieser Instanz kann **nur ein Lauf** gleichzeitig arbeiten: Scheduler Innerhalb dieser Instanz kann **nur ein Lauf** gleichzeitig arbeiten: Scheduler
@@ -309,57 +298,41 @@ Aufruf bekommt 409 und den Hinweis *„Ein Suchlauf läuft bereits"*. Das Dashbo
zeigt oben links den Zustand - automatische Suche aktiv, nur manuelle Suche, zeigt oben links den Zustand - automatische Suche aktiv, nur manuelle Suche,
pausiert, Suchlauf läuft - samt Namen der Datenbank. pausiert, Suchlauf läuft - samt Namen der Datenbank.
### Daten aus der alten App übernehmen ### Was aus der alten App wurde
Damit die neue Instanz nicht bei Null anfängt, holt ein Skript Agenten, Portale Beim Rollout am 06.08.2026 hat ein Skript (`importLegacy.js`,
und Einstellungen herüber. Es liest die alte Datenbank nur, geschrieben wird `npm run scanner:import`) Agenten, Portale, Einstellungen und Session-Cookies aus
ausschließlich in `perinco_scanner`: `project_scanner` herübergeholt; die CV-PDFs wurden in das eigene
Upload-Verzeichnis kopiert. Mit dem Rückbau am 09.09.2026 sind Quelle und Skript
weg.
```bash > **Damals festgestellt:** Die in der alten Datenbank gespeicherten
cd /var/www/perinco_CRM/server > Portal-Passwörter liessen sich mit dem dortigen `ENCRYPTION_KEY` **nicht** mehr
npm run scanner:import -- --dry-run # nur anzeigen > entschlüsseln - der Schlüssel war nach dem Speichern getauscht worden. Die alte
npm run scanner:import # Einstellungen, Portale, Agenten + CVs > App konnte sie damit ebenso wenig lesen, es ging also nichts verloren. Die
npm run scanner:import -- --with-matches # zusätzlich Projekte und Treffer > Sessions (Cookies) lagen im Klartext und wurden übernommen.
npm run scanner:import -- --force # vorhandene Zieldaten vorher löschen
npm run scanner:import -- --agents-only --skip=Bilal # später: nur fehlende Agenten
```
`--agents-only` ist der nachträgliche Lauf: nur Suchagenten, additiv, vorhandene Nicht mitgekommen sind 13380 Treffer, die es nur in der alten Datenbank gab,
werden über den Namen erkannt und übersprungen. `--skip=` lässt Agenten nach darunter 92 als *reviewed* und 5 als *applied* markierte. Ein Nachimport hätte
Namen aus. Details: [03 Projekt Scanner](docs/03-scanner.md#agenten-nachträglich-holen). mit den vorhandenen Ids in `perinco_scanner` kollidiert, und der Bestand war
ohnehin flüchtig - der Aufräumjob hält nur die jüngsten Tage. Ein Dump liegt
Quelle kommt aus `LEGACY_*` in `server/.env`: unter `/root/backups/project_scanner_20260909-173447.sql.gz`.
| Variable | Wofür |
| --- | --- |
| `LEGACY_DB_NAME` | Standard `project_scanner` |
| `LEGACY_DB_HOST/_PORT/_USER/_PASSWORD` | leer = CRM-Zugangsdaten |
| `LEGACY_UPLOAD_DIR` | Verzeichnis der alten CV-PDFs (`/var/www/Projekt Scanner/backend/uploads`); die Dateien werden **kopiert**. Wegen des Leerzeichens im Pfad in der `.env` in Anführungszeichen setzen - systemd liest die Datei als `EnvironmentFile` und trennt sonst am Leerzeichen |
| `LEGACY_ENCRYPTION_KEY` | nur damit lassen sich die Portal-Passwörter übernehmen: entschlüsseln mit dem alten, neu verschlüsseln mit dem eigenen Schlüssel |
Übernommen werden auch die Session-Cookies der Portale (Klartext-JSON in der
DB) - der neue Scanner ist damit sofort scharf.
Agenten, Projekte und Treffer behalten ihre Ids, damit übernommene Treffer
weiter auf die richtigen Agenten und Projekte zeigen. Deshalb bricht das Skript
ab, wenn im Ziel schon Agenten stehen - `--force` räumt vorher auf.
> **Beim Rollout am 06.08.2026 festgestellt:** Die in der alten Datenbank
> gespeicherten Portal-Passwörter 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. Wer den Notfall-Login des Scrapers will, gibt die
> Passwörter unter *Börsen-Konten* neu ein. Die Sessions (Cookies) sind davon
> unberührt und wurden übernommen.
### Playwright ### Playwright
`playwright` ist auf **exakt 1.60.0** gepinnt - dieselbe Version wie die laufende `playwright` ist auf **exakt 1.60.0** gepinnt. Der passende Chromium-Build
Scanner-App. Deren Chromium-Build (`chromium-1223`) liegt auf dem Server bereits (`chromium-1223`) liegt auf dem Server in `/root/.cache/ms-playwright`, also
in `/root/.cache/ms-playwright`, also fällt kein zweiter Browser-Download an und fällt kein zweiter Browser-Download an und gescrapt wird mit der Version, die
gescrapt wird mit der Version, die dort produktiv funktioniert. Ein `^` in der dort produktiv funktioniert. Ein `^` in der Version würde beides aufgeben,
Version würde beides aufgeben, deshalb steht in `server/package.json` ein deshalb steht in `server/package.json` ein `comments`-Feld mit dieser
`comments`-Feld mit dieser Begründung. Begründung.
> **Nicht mit aufräumen:** Dieser Cache (641 MB) stammt aus der Installation der
> alten Scanner-App, liegt aber ausserhalb beider Anwendungsverzeichnisse und
> wurde von beiden benutzt - `PLAYWRIGHT_BROWSERS_PATH` ist nirgends gesetzt.
> Beim Rückbau am 09.09.2026 ist er deshalb bewusst stehen geblieben. Wer ihn
> löscht, nimmt dem Scanner den Browser; Reparatur:
> `cd /var/www/perinco_CRM/server && npx playwright install chromium`.
## Diktat (Whisper im Browser) ## Diktat (Whisper im Browser)
@@ -601,16 +574,10 @@ Ein Rollout mit neuen Backend-Paketen braucht `-Deps`.
### Einmalige Scanner-Einrichtung (am 06.08.2026 erledigt) ### Einmalige Scanner-Einrichtung (am 06.08.2026 erledigt)
```bash ```bash
# 1. .env ergänzen: erzeugt SCANNER_ENCRYPTION_KEY, holt die LEGACY_*-Werte # 1. .env ergänzen: erzeugt SCANNER_ENCRYPTION_KEY, legt vorher ein .env.bak an
# aus "/var/www/Projekt Scanner/backend/.env", legt vorher ein .env.bak an
bash /tmp/scanner-setup.sh # aus deploy/scanner-setup.sh bash /tmp/scanner-setup.sh # aus deploy/scanner-setup.sh
# 2. Bestand übernehmen # 2. MemoryMax anheben und neu starten
cd /var/www/perinco_CRM/server
npm run scanner:import -- --dry-run
npm run scanner:import -- --with-matches
# 3. MemoryMax anheben und neu starten
cp /var/www/perinco_CRM/deploy/perinco-crm-backend.service /etc/systemd/system/ cp /var/www/perinco_CRM/deploy/perinco-crm-backend.service /etc/systemd/system/
systemctl daemon-reload && systemctl restart perinco-crm-backend systemctl daemon-reload && systemctl restart perinco-crm-backend
journalctl -u perinco-crm-backend -n 40 journalctl -u perinco-crm-backend -n 40
@@ -618,26 +585,14 @@ journalctl -u perinco-crm-backend -n 40
Die Datenbank `perinco_scanner` muss **nicht** von Hand angelegt werden, das Die Datenbank `perinco_scanner` muss **nicht** von Hand angelegt werden, das
erledigt das Backend beim Start. Playwright-Browser ebenfalls nicht, siehe erledigt das Backend beim Start. Playwright-Browser ebenfalls nicht, siehe
Abschnitt *Playwright* oben. Abschnitt *Playwright* oben. Der damalige Schritt *Bestand übernehmen*
(`npm run scanner:import`) ist mit dem Rückbau der alten App entfallen.
> **Reihenfolge beachten:** Der Import schreibt `search_interval` aus der alten
> App (15 Minuten) in die Einstellungen. Der laufende Scheduler bemerkt die
> Änderung binnen einer Minute und plant sich neu - beim Rollout hat er deshalb
> direkt nach dem Import einen Scan gestartet, den der folgende Restart mitten im
> Scrapen abgeschossen hat (`Target page ... has been closed` im Log, harmlos).
> Wer das vermeiden will, macht Schritt 3 vor Schritt 2.
Das höhere `MemoryMax` (1536M statt 512M) ist kein Luxus: ein Scan-Lauf startet Das höhere `MemoryMax` (1536M statt 512M) ist kein Luxus: ein Scan-Lauf startet
Chromium als Kindprozess in derselben cgroup, mit 512M würde der OOM-Killer das Chromium als Kindprozess in derselben cgroup, mit 512M würde der OOM-Killer das
Backend mitten im Scrapen beenden. `deploy.ps1` lädt die Unit nicht mit hoch - Backend mitten im Scrapen beenden. `deploy.ps1` lädt die Unit nicht mit hoch -
daher der `cp` oben. daher der `cp` oben.
> **RAM im Parallelbetrieb:** Im schlechtesten Fall scrapen beide Scanner
> gleichzeitig, also laufen zwei Chromium-Instanzen. `SCANNER_SCHEDULER_OFFSET`
> verschiebt die Läufe gegeneinander, garantiert aber nichts - der alte Dienst
> hat seinen eigenen Zeitplan. Auf einem Server mit knappem RAM und vollem Swap
> ist das der Punkt, den man nach dem ersten Tag im Journal nachsieht.
Manuell entspricht das: Manuell entspricht das:
```powershell ```powershell
+11 -46
View File
@@ -2,23 +2,22 @@
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Einmalige Einrichtung des portierten Projekt Scanners im CRM-Backend. # Einmalige Einrichtung des portierten Projekt Scanners im CRM-Backend.
# #
# Ergaenzt /var/www/perinco_CRM/server/.env um die SCANNER_*- und LEGACY_*- # Ergaenzt /var/www/perinco_CRM/server/.env um die SCANNER_*-Variablen.
# Variablen. Idempotent: vorhandene Werte werden nicht ueberschrieben, der # Idempotent: vorhandene Werte werden nicht ueberschrieben, der
# Verschluesselungsschluessel wird nur erzeugt, wenn noch keiner drinsteht. # Verschluesselungsschluessel wird nur erzeugt, wenn noch keiner drinsteht.
# #
# Geheimnisse werden nie ausgegeben - nur, ob ein Wert gesetzt ist. # Geheimnisse werden nie ausgegeben - nur, ob ein Wert gesetzt ist.
# #
# Die eigenstaendige Vorgaenger-App (scanner.perinco.de, Port 5005, Datenbank
# project_scanner) wurde am 09.09.2026 zurueckgebaut. Die frueher hier
# gesetzten LEGACY_*-Variablen und das Importskript sind damit entfallen.
#
# Aufruf auf dem Server: # Aufruf auf dem Server:
# bash /tmp/scanner-setup.sh # bash /tmp/scanner-setup.sh
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
set -euo pipefail set -euo pipefail
CRM_ENV=/var/www/perinco_CRM/server/.env CRM_ENV=/var/www/perinco_CRM/server/.env
# Achtung: der Pfad der laufenden Scanner-App enthaelt ein Leerzeichen, deshalb
# ueberall in Anfuehrungszeichen. Die Unit heisst project-scanner-backend und
# hoert auf Port 5005.
LEGACY_DIR="/var/www/Projekt Scanner/backend"
LEGACY_ENV="$LEGACY_DIR/.env"
if [ ! -f "$CRM_ENV" ]; then if [ ! -f "$CRM_ENV" ]; then
echo "FEHLER: $CRM_ENV fehlt." echo "FEHLER: $CRM_ENV fehlt."
@@ -30,9 +29,8 @@ cp -a "$CRM_ENV" "$CRM_ENV.bak.$(date +%Y%m%d%H%M%S)"
# Wert nur setzen, wenn der Schluessel fehlt oder leer ist. # Wert nur setzen, wenn der Schluessel fehlt oder leer ist.
# #
# Werte mit Leerzeichen werden gequotet: systemd liest diese Datei als # Werte mit Leerzeichen werden gequotet: systemd liest diese Datei als
# EnvironmentFile und trennt dort an Leerzeichen - ein ungequoteter Pfad wie # EnvironmentFile und trennt dort an Leerzeichen - ein ungequoteter Pfad kaeme
# "/var/www/Projekt Scanner/backend/uploads" kaeme abgeschnitten an. dotenv # abgeschnitten an. dotenv entfernt die Quotes beim Einlesen wieder.
# entfernt die Quotes beim Einlesen wieder.
upsert() { upsert() {
local key="$1" value="$2" local key="$1" value="$2"
if grep -qE "^${key}=.+" "$CRM_ENV"; then if grep -qE "^${key}=.+" "$CRM_ENV"; then
@@ -48,13 +46,6 @@ upsert() {
echo " $key: gesetzt" echo " $key: gesetzt"
} }
# Aus der alten .env lesen, ohne sie in die eigene Umgebung zu uebernehmen.
legacy_value() {
local key="$1"
[ -f "$LEGACY_ENV" ] || return 0
sed -n "s/^${key}=//p" "$LEGACY_ENV" | tail -n1 | tr -d '\r'
}
echo "== Scanner-Konfiguration in $CRM_ENV ==" echo "== Scanner-Konfiguration in $CRM_ENV =="
# Kopfzeile nur beim ersten Lauf, sonst sammelt sich bei jedem Aufruf eine an. # Kopfzeile nur beim ersten Lauf, sonst sammelt sich bei jedem Aufruf eine an.
@@ -65,7 +56,7 @@ fi
upsert SCANNER_DB_NAME perinco_scanner upsert SCANNER_DB_NAME perinco_scanner
upsert SCANNER_UPLOAD_DIR /var/www/perinco_CRM/server/uploads upsert SCANNER_UPLOAD_DIR /var/www/perinco_CRM/server/uploads
upsert SCANNER_SCHEDULER true upsert SCANNER_SCHEDULER true
upsert SCANNER_SCHEDULER_OFFSET 30 upsert SCANNER_SCHEDULER_OFFSET 0
if grep -qE '^SCANNER_ENCRYPTION_KEY=.+' "$CRM_ENV"; then if grep -qE '^SCANNER_ENCRYPTION_KEY=.+' "$CRM_ENV"; then
echo " SCANNER_ENCRYPTION_KEY: bleibt unveraendert" echo " SCANNER_ENCRYPTION_KEY: bleibt unveraendert"
@@ -75,35 +66,9 @@ else
echo " SCANNER_ENCRYPTION_KEY: neu erzeugt (64 Hex-Zeichen)" echo " SCANNER_ENCRYPTION_KEY: neu erzeugt (64 Hex-Zeichen)"
fi fi
echo "== Uebernahme aus der alten App =="
if [ ! -f "$LEGACY_ENV" ]; then
echo " $LEGACY_ENV nicht gefunden - LEGACY_* bleibt leer, Import faellt aus."
else
LEGACY_DB_NAME_VALUE="$(legacy_value DB_NAME)"
LEGACY_DB_USER_VALUE="$(legacy_value DB_USER)"
LEGACY_DB_PASSWORD_VALUE="$(legacy_value DB_PASSWORD)"
LEGACY_DB_HOST_VALUE="$(legacy_value DB_HOST)"
LEGACY_DB_PORT_VALUE="$(legacy_value DB_PORT)"
LEGACY_KEY_VALUE="$(legacy_value ENCRYPTION_KEY)"
upsert LEGACY_DB_NAME "${LEGACY_DB_NAME_VALUE:-project_scanner}"
upsert LEGACY_DB_HOST "${LEGACY_DB_HOST_VALUE:-localhost}"
upsert LEGACY_DB_PORT "${LEGACY_DB_PORT_VALUE:-3306}"
upsert LEGACY_DB_USER "${LEGACY_DB_USER_VALUE:-root}"
upsert LEGACY_DB_PASSWORD "${LEGACY_DB_PASSWORD_VALUE:-}"
upsert LEGACY_UPLOAD_DIR "$LEGACY_DIR/uploads"
if [ -n "$LEGACY_KEY_VALUE" ]; then
upsert LEGACY_ENCRYPTION_KEY "$LEGACY_KEY_VALUE"
else
echo " LEGACY_ENCRYPTION_KEY: in der alten .env nicht gefunden - Portal-Passwoerter werden nicht uebernommen"
fi
fi
chmod 600 "$CRM_ENV" chmod 600 "$CRM_ENV"
echo "== Ergebnis (Werte maskiert) ==" echo "== Ergebnis (Werte maskiert) =="
grep -E '^(SCANNER|LEGACY)_' "$CRM_ENV" | sed -E 's/=(.+)/= <gesetzt>/' | sort -u grep -E '^SCANNER_' "$CRM_ENV" | sed -E 's/=(.+)/= <gesetzt>/' | sort -u
echo "Fertig." echo "Fertig. Danach: systemctl restart perinco-crm-backend"
-8
View File
@@ -24,13 +24,6 @@ flowchart LR
A -->|"/api"| E["Express :5006"] A -->|"/api"| E["Express :5006"]
E --> C[("perinco_CRM")] E --> C[("perinco_CRM")]
E --> S[("perinco_scanner")] E --> S[("perinco_scanner")]
U2(("Browser")) -->|"HTTPS"| A2["Apache :443<br/>scanner.perinco.de"]
A2 -->|"/api, /uploads, /health"| E2["alte Scanner-App :5005"]
E2 --> S2[("project_scanner")]
classDef alt fill:#f7f7f7,stroke:#9a9a9a,stroke-dasharray: 4 3
class U2,A2,E2,S2 alt
``` ```
Der Scanner im CRM hängt unter `/api/scanner` und braucht damit **keine eigene Der Scanner im CRM hängt unter `/api/scanner` und braucht damit **keine eigene
@@ -115,6 +108,5 @@ Portbelegung `ss -tlnp` prüfen. Relevant hier:
| Port | Dienst | | Port | Dienst |
| --- | --- | | --- | --- |
| 5006 | perinco CRM Backend (nur `127.0.0.1`) | | 5006 | perinco CRM Backend (nur `127.0.0.1`) |
| 5005 | alte Scanner-App (`0.0.0.0`) |
| 3000 | weitere Vite-App | | 3000 | weitere Vite-App |
| 5678 | n8n | | 5678 | n8n |
+50 -90
View File
@@ -2,7 +2,8 @@
Der Scanner durchsucht Projektbörsen nach Aufträgen und bewertet jeden Fund mit 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 OpenAI gegen die Lebensläufe der Berater. Er ist eine vollständige Portierung
der eigenständigen App und läuft parallel zu ihr. der eigenständigen App, die er seit dem 09.09.2026 ersetzt
([Rückbau](#rückbau-der-alten-app---09092026)).
## Ein Suchlauf ## Ein Suchlauf
@@ -71,10 +72,13 @@ 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 obendrauf, aus 15 Minuten wurden 19 - rund ein Fuenftel weniger Durchgaenge pro
Tag, und die Trefferliste der Boersen zeigt nur die neuesten Projekte. Tag, und die Trefferliste der Boersen zeigt nur die neuesten Projekte.
Die **Sperrfrist** ist die Antwort auf ein konkretes Problem: beide Instanzen Die **Sperrfrist** war die Antwort auf ein konkretes Problem des
benutzen dieselben Portal-Cookies. Beim ersten Start ziehen sonst beide Parallelbetriebs: beide Instanzen benutzten dieselben Portal-Cookies. Beim
gleichzeitig los, was Rate-Limits und CAPTCHAs provoziert - und zwei Chromium ersten Start zogen sonst beide gleichzeitig los, was Rate-Limits und CAPTCHAs
gleichzeitig belasten den Speicher doppelt. 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 Der Zeitpunkt des letzten Laufs steht in `settings.last_scan_at`. Das ist kein
Luxus, sondern nach zwei beobachteten Fehlern nötig: Luxus, sondern nach zwei beobachteten Fehlern nötig:
@@ -155,97 +159,53 @@ 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 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. erholt sich der Scanner selbst, statt auf ein Deployment zu warten.
## Parallelbetrieb - alles verdoppelt ## Rückbau der alten App - 09.09.2026
```mermaid Der Scanner lief seit dem Rollout am 06.08.2026 **parallel** zur eigenständigen
flowchart LR Vorgänger-App unter `scanner.perinco.de`. Diese ist am 09.09.2026 vollständig
subgraph alt["alte App (scanner.perinco.de)"] zurückgebaut. Verschwunden sind:
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)"] | Weg | War |
N1["Express :5006<br/>/api/scanner"] --> N2[("perinco_scanner")] | --- | --- |
N1 --> N3["uploads/ in<br/>/var/www/perinco_CRM/server"] | Unit `project-scanner-backend` | Express auf `0.0.0.0:5005` |
N4["SCANNER_ENCRYPTION_KEY"] --- N1 | Vhosts `scanner.perinco.de(.conf/-le-ssl.conf)` | eigener Apache-Vhost, Basic Auth |
end | 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` |
B["Projektbörsen"] **Was bewusst stehen geblieben ist:** `/root/.cache/ms-playwright` (641 MB, Build
O["OpenAI"] `chromium-1223`). Der Cache stammt aus der Installation der alten App, aber
A1 --> B `PLAYWRIGHT_BROWSERS_PATH` ist nirgends gesetzt - **beide** Apps haben ihn
N1 --> B benutzt, und `playwright` ist im CRM auf exakt `1.60.0` gepinnt, damit es genau
A1 --> O dieser Build bleibt. Wer den Cache mit dem alten Verzeichnis löscht, nimmt dem
N1 --> O 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.
IMP["npm run scanner:import"] -.->|"liest"| A2 Was der Rückbau ändert:
IMP -.->|"schreibt"| N2
IMP -.->|"kopiert"| N3
```
| | alte App | CRM | - **Kosten:** Beide Instanzen haben dieselben Projekte unabhängig voneinander
| --- | --- | --- | bewertet, die OpenAI-Kosten fielen doppelt an. Faktisch war das schon am
| Datenbank | `project_scanner` | `perinco_scanner` | 09.09.2026 um 08:49 vorbei - da wurde `scanning_active` in der alten
| Schlüssel | `ENCRYPTION_KEY` | `SCANNER_ENCRYPTION_KEY` | Oberfläche auf `false` gesetzt, letzter Lauf dort 09:02.
| CV-Dateien | eigener Ordner | eigener Ordner | - **Portal-Sessions:** Beide Instanzen benutzten dieselben Cookies der
| Aufräumen | 03:00 | 03:30 | Projektbörsen. Der Grund für `SCANNER_SCHEDULER_OFFSET` fällt damit weg, der
| Anmeldung | HTTP Basic | CRM-Sitzung (JWT) | 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.
**Der Preis:** Beide bewerten dieselben Projekte unabhängig voneinander, die Die Domain `scanner.perinco.de` zeigt weiter auf den Server, hat aber keinen
OpenAI-Kosten fallen also doppelt an. Wer das senken will, stellt im CRM ein eigenen Vhost mehr: HTTP landet beim ersten aktivierten Vhost, HTTPS gibt eine
größeres Suchintervall ein - die Einstellung gilt nur für diese Instanz. Zertifikatswarnung. Der DNS-Eintrag muss beim Anbieter gelöscht werden.
## Ü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.
### Agenten nachträglich holen
Der Vollimport ist ein Erststart-Werkzeug. Um später nur die Suchagenten
nachzuziehen, ohne Einstellungen, Portale und Treffer anzufassen:
```bash
cd /var/www/perinco_CRM/server
npm run scanner:import -- --agents-only --skip=Bilal --dry-run
npm run scanner:import -- --agents-only --skip=Bilal
```
`--agents-only` läuft **additiv**: vorhandene Agenten blockieren nicht, sondern
werden über den Namen erkannt und übersprungen - was hier im CRM schon
bearbeitet wurde, bleibt also unangetastet. Die Id der Quelle wird nur
übernommen, solange sie in `perinco_scanner` frei ist; sonst vergibt die
Datenbank eine neue. `--skip=` nimmt einen Namen oder eine Liste
(`--skip=Bilal,Nico`), Groß- und Kleinschreibung spielt keine Rolle.
`--force` ist in diesem Modus gesperrt: es würde Projekte und Treffer löschen,
die der Lauf gar nicht anrührt.
> **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 ## Oberfläche
+20 -18
View File
@@ -34,11 +34,10 @@ Das Passwort kommt aus `$env:PERINCO_CRM_SSH_PASSWORD` oder per `-Password`.
| Unit | Verzeichnis | Port | Anmerkung | | Unit | Verzeichnis | Port | Anmerkung |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `perinco-crm-backend` | `/var/www/perinco_CRM/server` | 5006 | `MemoryMax=1536M` | | `perinco-crm-backend` | `/var/www/perinco_CRM/server` | 5006 | `MemoryMax=1536M` |
| `project-scanner-backend` | `/var/www/Projekt Scanner/backend` | 5005 | alte App, läuft bis zur Ablosung |
Der Verzeichnisname der alten App enthält ein **Leerzeichen**. In Skripten und in Die zweite Unit `project-scanner-backend` (Port 5005, `/var/www/Projekt Scanner/backend`)
der `.env` immer in Anführungszeichen setzen - systemd liest `.env` als ist mit dem [Rückbau der alten App](03-scanner.md#rückbau-der-alten-app---09092026)
`EnvironmentFile` und trennt sonst am Leerzeichen. am 09.09.2026 entfallen. Port 5005 ist seither frei.
`MemoryMax=1536M` statt der ursprünglichen 512M: ein Scan-Lauf startet Chromium `MemoryMax=1536M` statt der ursprünglichen 512M: ein Scan-Lauf startet Chromium
als Kindprozess in derselben cgroup. Mit 512M würde der OOM-Killer das Backend als Kindprozess in derselben cgroup. Mit 512M würde der OOM-Killer das Backend
@@ -58,8 +57,7 @@ mitten im Scrapen beenden.
| `SCANNER_ENCRYPTION_KEY` | AES-Schlüssel für Portal-Passwörter | | `SCANNER_ENCRYPTION_KEY` | AES-Schlüssel für Portal-Passwörter |
| `SCANNER_UPLOAD_DIR` | CV-Verzeichnis, leer = `<server>/uploads` | | `SCANNER_UPLOAD_DIR` | CV-Verzeichnis, leer = `<server>/uploads` |
| `SCANNER_SCHEDULER` | Hintergrund-Scraper, Standard an, nur `false` schaltet ab | | `SCANNER_SCHEDULER` | Hintergrund-Scraper, Standard an, nur `false` schaltet ab |
| `SCANNER_SCHEDULER_OFFSET` | Minuten Versatz für den ersten Lauf, Standard 30 | | `SCANNER_SCHEDULER_OFFSET` | Minuten Versatz für den ersten Lauf, seit dem Rückbau der alten App 0 |
| `LEGACY_*` | Quelle für `npm run scanner:import` |
| `MS_TENANT_ID`, `MS_CLIENT_ID`, `MS_CLIENT_SECRET` | App-only-Zugriff auf Microsoft Graph | | `MS_TENANT_ID`, `MS_CLIENT_ID`, `MS_CLIENT_SECRET` | App-only-Zugriff auf Microsoft Graph |
| `MS_MAILBOX` | abzugleichendes Postfach, Standard `crm@perinco.de` | | `MS_MAILBOX` | abzugleichendes Postfach, Standard `crm@perinco.de` |
| `MS_MAIL_FOLDER` | Ordner, Standard `inbox` | | `MS_MAIL_FOLDER` | Ordner, Standard `inbox` |
@@ -81,10 +79,17 @@ Exchange Online: [11 Postfachanbindung](11-postfach.md).
## Playwright ## Playwright
`playwright` ist auf **exakt 1.60.0** gepinnt - dieselbe Version wie die alte App. `playwright` ist auf **exakt 1.60.0** gepinnt. Der passende Chromium-Build
Deren Chromium-Build (`chromium-1223`) liegt in `/root/.cache/ms-playwright`, also (`chromium-1223`) liegt in `/root/.cache/ms-playwright` - dieser Cache stammt aus
fällt kein zweiter Browser-Download an. Ein `^` in der Version würde das der Installation der alten App und hat deren Rückbau überlebt, weil beide Apps
aufgeben; die Begründung steht als `comments`-Feld in `server/package.json`. ihn geteilt haben und `PLAYWRIGHT_BROWSERS_PATH` nirgends gesetzt ist. Ein `^` in
der Version würde einen zweiten Browser-Download ziehen; die Begründung steht als
`comments`-Feld in `server/package.json`.
> **Beim Aufräumen nicht anfassen:** `/root/.cache/ms-playwright` (641 MB) liegt
> ausserhalb beider Anwendungsverzeichnisse. Wer ihn mit löscht, nimmt dem
> Scanner den Browser - Oberfläche und Treffer bleiben, Scan-Lauf und
> Verbindungstest fallen aus.
Fehlt der Browser, funktioniert alles ausser Scan-Lauf und Verbindungstest: Fehlt der Browser, funktioniert alles ausser Scan-Lauf und Verbindungstest:
@@ -96,17 +101,14 @@ cd /var/www/perinco_CRM/server && npx playwright install chromium
```bash ```bash
bash /var/www/perinco_CRM/deploy/scanner-setup.sh # .env ergänzen, Schlüssel erzeugen bash /var/www/perinco_CRM/deploy/scanner-setup.sh # .env ergänzen, Schlüssel erzeugen
cd /var/www/perinco_CRM/server
npm run scanner:import -- --dry-run
npm run scanner:import -- --with-matches
systemctl restart perinco-crm-backend systemctl restart perinco-crm-backend
``` ```
> **Reihenfolge:** Der Import schreibt `search_interval` aus der alten App in die Datenbank und Tabellen legt der Start selbst an, Portale und
> Einstellungen. Der laufende Scheduler merkt die Änderung binnen einer Minute Standardeinstellungen werden geseedet. Agenten, Zugangsdaten und Cookies kommen
> und plant sich neu - beim Rollout hat er direkt nach dem Import einen Scan danach über die Oberfläche. Das frühere `npm run scanner:import` holte diese
> gestartet, den der folgende Restart mitten im Scrapen beendet hat. Harmlos, aber Daten aus der Datenbank der alten App - beides ist mit dem Rückbau am 09.09.2026
> vermeidbar: erst neu starten, dann importieren. entfallen.
## Störungssuche ## Störungssuche
-1
View File
@@ -187,7 +187,6 @@ Diese Punkte haben real Zeit gekostet:
| **Reihenfolge in `server.js`** | Der 404-Fallback `app.use('/api', ...)` fängt alles, was danach registriert wird. Neue Router davor einhängen. | | **Reihenfolge in `server.js`** | Der 404-Fallback `app.use('/api', ...)` fängt alles, was danach registriert wird. Neue Router davor einhängen. |
| **`Content-Type` bei FormData** | `apiRequest` setzt JSON nur, wenn der Body **keine** `FormData` ist - sonst fehlt die multipart-Grenze und der Upload scheitert. | | **`Content-Type` bei FormData** | `apiRequest` setzt JSON nur, wenn der Body **keine** `FormData` ist - sonst fehlt die multipart-Grenze und der Upload scheitert. |
| **Dateien in neuen Tabs** | Ein `<a href>` schickt kein Bearer-Token. Deshalb holt `openAgentCv()` das PDF per `fetch` und öffnet einen Blob. | | **Dateien in neuen Tabs** | Ein `<a href>` schickt kein Bearer-Token. Deshalb holt `openAgentCv()` das PDF per `fetch` und öffnet einen Blob. |
| **Leerzeichen in Pfaden** | Der alte Scanner liegt unter `/var/www/Projekt Scanner/…`. In der `.env` muss der Wert gequotet sein, sonst schneidet systemd ihn am Leerzeichen ab. |
| **`.env` ist gleichzeitig `EnvironmentFile`** | Also kein `export`, keine Shell-Ausdrücke, Werte mit Leerzeichen in Anführungszeichen. | | **`.env` ist gleichzeitig `EnvironmentFile`** | Also kein `export`, keine Shell-Ausdrücke, Werte mit Leerzeichen in Anführungszeichen. |
| **Zeitstempel können nur vorziehen** | Ein „vorgealterter" `lastRunTime` verzögert keinen Cronlauf. Wer verzögern will, braucht eine eigene Frist. | | **Zeitstempel können nur vorziehen** | Ein „vorgealterter" `lastRunTime` verzögert keinen Cronlauf. Wer verzögern will, braucht eine eigene Frist. |
| **Sperrfristen bei jedem Start neu** | Früher schob jedes Deployment den fälligen Scan um 30 Minuten - er fand nie statt. Deshalb liegt `last_scan_at` in der Datenbank. | | **Sperrfristen bei jedem Start neu** | Früher schob jedes Deployment den fälligen Scan um 30 Minuten - er fand nie statt. Deshalb liegt `last_scan_at` in der Datenbank. |
-1
View File
@@ -188,7 +188,6 @@ Die portierte Scanner-API unter `/api/scanner`, vollständig in
| `services/pdfParser.js` | `pdf-parse`, doppelte Leerzeilen raus | | `services/pdfParser.js` | `pdf-parse`, doppelte Leerzeilen raus |
| `utils/crypto.js` | AES über `crypto-js`; `decryptWith()` für fremde Schlüssel | | `utils/crypto.js` | AES über `crypto-js`; `decryptWith()` für fremde Schlüssel |
| `uploadDir.js` | `UPLOAD_DIR`, `ensureUploadDir()`, `resolveCvPath()` | | `uploadDir.js` | `UPLOAD_DIR`, `ensureUploadDir()`, `resolveCvPath()` |
| `importLegacy.js` | einmalige Übernahme aus der alten Datenbank |
Zusagen, auf die sich der Rest verlässt: Zusagen, auf die sich der Rest verlässt:
+3 -10
View File
@@ -12,7 +12,7 @@ keine Vorkenntnisse voraus.
| --- | --- | | --- | --- |
| [01 Architektur](01-architektur.md) | Topologie, Prozesse, Ports, Anfrageweg, Rollen | | [01 Architektur](01-architektur.md) | Topologie, Prozesse, Ports, Anfrageweg, Rollen |
| [02 Datenmodell](02-datenmodell.md) | Tabellen beider Datenbanken als ER-Diagramme | | [02 Datenmodell](02-datenmodell.md) | Tabellen beider Datenbanken als ER-Diagramme |
| [03 Projekt Scanner](03-scanner.md) | Suchlauf von der Börse bis zum Treffer, Parallelbetrieb | | [03 Projekt Scanner](03-scanner.md) | Suchlauf von der Börse bis zum Treffer, Rückbau der alten App |
| [04 Diktat](04-diktat.md) | Spracherkennung im Browser, Modell-Lebenszyklus | | [04 Diktat](04-diktat.md) | Spracherkennung im Browser, Modell-Lebenszyklus |
| [05 Frontend](05-frontend.md) | Ansichten, Menü, Komponentenkarte, Zustand | | [05 Frontend](05-frontend.md) | Ansichten, Menü, Komponentenkarte, Zustand |
| [06 Betrieb](06-betrieb.md) | Deployment, Umgebungsvariablen, Dienste, Störungssuche | | [06 Betrieb](06-betrieb.md) | Deployment, Umgebungsvariablen, Dienste, Störungssuche |
@@ -42,8 +42,6 @@ flowchart TB
PW["Playwright<br/>Chromium"] PW["Playwright<br/>Chromium"]
DB1[("MariaDB<br/>perinco_CRM")] DB1[("MariaDB<br/>perinco_CRM")]
DB2[("MariaDB<br/>perinco_scanner")] DB2[("MariaDB<br/>perinco_scanner")]
ALT["alte Scanner-App<br/>:5005"]
DB3[("MariaDB<br/>project_scanner")]
end end
OAI["OpenAI<br/>gpt-4o-mini"] OAI["OpenAI<br/>gpt-4o-mini"]
@@ -62,13 +60,8 @@ flowchart TB
API -.-> MSCH API -.-> MSCH
MSCH -->|"Delta Query, nur lesend"| GRAPH MSCH -->|"Delta Query, nur lesend"| GRAPH
MSCH --> DB1 MSCH --> DB1
ALT --> DB3
ALT --> BOERSE
SPA -.->|"Audio bleibt im Browser"| WSP SPA -.->|"Audio bleibt im Browser"| WSP
classDef alt fill:#f7f7f7,stroke:#9a9a9a,stroke-dasharray: 4 3
class ALT,DB3 alt
``` ```
Gestrichelt: die ursprüngliche Scanner-App, die bis zur Ablosung parallel Die ursprüngliche Scanner-App unter `scanner.perinco.de` ist am 09.09.2026
weiterläuft. Sie hat ihre eigene Datenbank und wird vom CRM nicht angefasst. zurückgebaut worden; das CRM ist seither die einzige Instanz.
+13 -26
View File
@@ -62,48 +62,35 @@ MAIL_SYNC=true
# MAIL_MAX_BODY_CHARS=400000 # MAIL_MAX_BODY_CHARS=400000
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Projekt Scanner (portiert, laeuft parallel zur alten App) # Projekt Scanner (portiert)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Diese Instanz ist vollstaendig eigenstaendig: eigene Datenbank, eigener # Eigene Datenbank, eigener Schluessel, eigenes Upload-Verzeichnis, eigener
# Schluessel, eigenes Upload-Verzeichnis, eigener Scheduler. Sie fasst die Daten # Scheduler. Die eigenstaendige Vorgaenger-App wurde am 09.09.2026
# des alten Scanners nicht an. # zurueckgebaut, es gibt keine zweite Instanz mehr.
# Eigene Datenbank - wird beim Start automatisch angelegt, falls sie fehlt. # Wird beim Start automatisch angelegt, falls sie fehlt.
# NICHT auf project_scanner zeigen lassen, das ist die Datenbank der alten App.
SCANNER_DB_NAME=perinco_scanner SCANNER_DB_NAME=perinco_scanner
# SCANNER_DB_HOST=localhost # SCANNER_DB_HOST=localhost
# SCANNER_DB_PORT=3306 # SCANNER_DB_PORT=3306
# SCANNER_DB_USER=root # SCANNER_DB_USER=root
# SCANNER_DB_PASSWORD= # SCANNER_DB_PASSWORD=
# Eigener Schluessel fuer die Portal-Passwoerter, unabhaengig von der alten App. # Schluessel fuer die Portal-Passwoerter (AES).
# Langer Zufallswert, z. B.: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" # Langer Zufallswert, z. B.: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
SCANNER_ENCRYPTION_KEY= SCANNER_ENCRYPTION_KEY=
# Eigenes Verzeichnis fuer die CV-PDFs. Leer = <server>/uploads. # Verzeichnis fuer die CV-PDFs. Leer = <server>/uploads.
SCANNER_UPLOAD_DIR= SCANNER_UPLOAD_DIR=
# Hintergrund-Scraper (Playwright + OpenAI). Standard ist AN; nur "false" # Hintergrund-Scraper (Playwright + OpenAI). Standard ist AN; nur "false"
# schaltet ihn ab. Achtung: solange die alte App laeuft, bewerten beide Instanzen # schaltet ihn ab.
# dieselben Projekte unabhaengig voneinander - die OpenAI-Kosten fallen doppelt an.
SCANNER_SCHEDULER=true SCANNER_SCHEDULER=true
# Minuten Versatz fuer den allerersten Lauf dieser Instanz. Verhindert, dass # Minuten Versatz fuer den allerersten Lauf. Stammt aus dem Parallelbetrieb mit
# beide Scanner gleichzeitig mit denselben Portal-Sessions losziehen (CAPTCHA). # der alten App und ist seit deren Rueckbau ohne Wirkung, solange in der
# Greift nur, solange noch kein Lauf stattgefunden hat - danach traegt der in der # Datenbank ein last_scan_at steht - der traegt den Zeitplan ueber Neustarts.
# Datenbank gespeicherte Zeitpunkt (last_scan_at) den Zeitplan ueber Neustarts # 0 = sofort loslegen.
# hinweg. 0 = sofort loslegen. SCANNER_SCHEDULER_OFFSET=0
SCANNER_SCHEDULER_OFFSET=30
# Fallback fuer den OpenAI-Key; bevorzugt wird der Wert aus den Einstellungen. # Fallback fuer den OpenAI-Key; bevorzugt wird der Wert aus den Einstellungen.
# OPENAI_API_KEY= # OPENAI_API_KEY=
# --- Einmalige Uebernahme aus der alten Scanner-Datenbank ---
# npm run scanner:import [-- --with-matches] [-- --force] [-- --dry-run]
LEGACY_DB_NAME=project_scanner
# LEGACY_DB_HOST / _PORT / _USER / _PASSWORD (leer = CRM-Werte von oben)
# Verzeichnis der CV-PDFs der alten App - von dort werden die Dateien kopiert.
# LEGACY_UPLOAD_DIR=/opt/project-scanner/backend/uploads
# ENCRYPTION_KEY der alten App - nur damit koennen die Portal-Passwoerter
# uebernommen (und hier neu verschluesselt) werden.
# LEGACY_ENCRYPTION_KEY=
-1
View File
@@ -12,7 +12,6 @@
"start": "node src/server.js", "start": "node src/server.js",
"dev": "node --watch src/server.js", "dev": "node --watch src/server.js",
"migrate": "node src/migrate.js", "migrate": "node src/migrate.js",
"scanner:import": "node src/scanner/importLegacy.js",
"test": "node test/mail.test.mjs && node test/acquisition.test.mjs && node test/userFields.test.mjs" "test": "node test/mail.test.mjs && node test/acquisition.test.mjs && node test/userFields.test.mjs"
}, },
"dependencies": { "dependencies": {
-339
View File
@@ -1,339 +0,0 @@
/*
* Einmalige Uebernahme aus der alten Projekt-Scanner-Datenbank in die eigene
* Datenbank dieser Instanz. Beide Scanner laufen danach parallel und getrennt -
* dieses Skript kopiert nur, es veraendert die Quelle nicht.
*
* Aufruf (aus server/):
* npm run scanner:import
* npm run scanner:import -- --with-matches auch Projekte und Treffer
* npm run scanner:import -- --force vorhandene Zieldaten ersetzen
* npm run scanner:import -- --dry-run nur anzeigen, nichts schreiben
* npm run scanner:import -- --agents-only nur Suchagenten, sonst nichts
* npm run scanner:import -- --skip=Bilal,Nico Agenten nach Namen auslassen
*
* Quelle konfigurieren (server/.env):
* LEGACY_DB_NAME=project_scanner
* LEGACY_DB_HOST / _PORT / _USER / _PASSWORD (leer = CRM-Werte)
* LEGACY_UPLOAD_DIR=/opt/project-scanner/backend/uploads
* LEGACY_ENCRYPTION_KEY=... Portal-Passwoerter mit uebernehmen
*
* Was uebernommen wird:
* - Einstellungen (inkl. OpenAI-Key, SMTP-Zugang, Schwellenwerte)
* - Portale: Benutzername, Session-Cookies, Status; Passwoerter nur mit
* LEGACY_ENCRYPTION_KEY, weil sie mit dem alten Schluessel verschluesselt
* sind und hier neu verschluesselt werden muessen
* - Suchagenten inkl. CV-Text und CV-PDF (Datei wird kopiert)
* - mit --with-matches zusaetzlich Projekte und Treffer
*
* --agents-only laesst Einstellungen und Portale unberuehrt und laeuft
* nachtraeglich: vorhandene Agenten blockieren nicht mehr, sondern werden am
* Namen erkannt und uebersprungen. Die Ids der Quelle werden nur uebernommen,
* solange sie hier frei sind - sonst vergibt die Datenbank eine neue.
*/
import 'dotenv/config';
import fs from 'node:fs';
import path from 'node:path';
import mysql from 'mysql2/promise';
import {
Agent,
Match,
Portal,
Project,
Setting,
initScannerDatabase,
} from './models/index.js';
import { encrypt, decryptWith } from './utils/crypto.js';
import { ensureUploadDir } from './uploadDir.js';
const args = process.argv.slice(2);
const withMatches = args.includes('--with-matches');
const force = args.includes('--force');
const dryRun = args.includes('--dry-run');
const agentsOnly = args.includes('--agents-only');
// --skip=Bilal oder --skip=Bilal,Nico - Vergleich ohne Gross-/Kleinschreibung.
const skipNames = args
.filter((arg) => arg.startsWith('--skip='))
.flatMap((arg) => arg.slice('--skip='.length).split(','))
.map((name) => name.trim().toLowerCase())
.filter(Boolean);
const LEGACY_UPLOAD_DIR = process.env.LEGACY_UPLOAD_DIR || '';
const LEGACY_KEY = process.env.LEGACY_ENCRYPTION_KEY || '';
const log = (message) => console.log(`[Import] ${message}`);
async function openLegacy() {
const database = process.env.LEGACY_DB_NAME || 'project_scanner';
const connection = await mysql.createConnection({
host: process.env.LEGACY_DB_HOST || process.env.DB_HOST || 'localhost',
port: Number(process.env.LEGACY_DB_PORT || process.env.DB_PORT || 3306),
user: process.env.LEGACY_DB_USER || process.env.DB_USER || 'root',
password: process.env.LEGACY_DB_PASSWORD || process.env.DB_PASSWORD || '',
database,
dateStrings: false,
});
log(`Quelle: ${database}`);
return connection;
}
/** Kopiert das CV-PDF in das eigene Upload-Verzeichnis. */
function copyCv(storedPath, uploadDir) {
if (!storedPath) return '';
const fileName = path.basename(storedPath);
const candidates = [
path.isAbsolute(storedPath) ? storedPath : null,
LEGACY_UPLOAD_DIR ? path.join(LEGACY_UPLOAD_DIR, fileName) : null,
path.resolve(storedPath),
].filter(Boolean);
const source = candidates.find((candidate) => fs.existsSync(candidate));
if (!source) {
log(` CV-Datei nicht gefunden: ${storedPath} (LEGACY_UPLOAD_DIR setzen?)`);
return '';
}
const target = path.join(uploadDir, fileName);
if (!dryRun) fs.copyFileSync(source, target);
return target;
}
async function importSettings(legacy) {
const [rows] = await legacy.query('SELECT key_name, value FROM settings');
for (const row of rows) {
if (!dryRun) await Setting.setVal(row.key_name, row.value ?? '');
}
log(`Einstellungen: ${rows.length} Schluessel uebernommen.`);
}
async function importPortals(legacy) {
const [rows] = await legacy.query(
'SELECT name, username, password_encrypted, session_state, connection_status, last_login_attempt FROM portals'
);
let withSession = 0;
let withPassword = 0;
for (const row of rows) {
// Portale sind bei der Initialisierung schon angelegt - hier wird nur der
// Inhalt nachgezogen, ueber den Namen zugeordnet.
const portal = await Portal.findOne({ where: { name: row.name } });
if (!portal) {
log(` Portal ${row.name} gibt es hier nicht - uebersprungen.`);
continue;
}
portal.username = row.username || portal.username;
if (row.session_state) {
portal.sessionState = row.session_state;
portal.connectionStatus = row.connection_status || 'connected';
portal.lastLoginAttempt = row.last_login_attempt || new Date();
withSession++;
}
if (row.password_encrypted && LEGACY_KEY) {
const plain = decryptWith(row.password_encrypted, LEGACY_KEY);
if (plain) {
// Neu verschluesseln - diese Instanz hat ihren eigenen Schluessel.
portal.passwordEncrypted = encrypt(plain);
withPassword++;
} else {
log(` Passwort von ${row.name} liess sich mit LEGACY_ENCRYPTION_KEY nicht lesen.`);
}
}
if (!dryRun) await portal.save();
}
log(`Portale: ${rows.length} geprueft, ${withSession} mit Session, ${withPassword} mit Passwort.`);
if (!LEGACY_KEY) {
log(' Hinweis: ohne LEGACY_ENCRYPTION_KEY bleiben die Portal-Passwoerter leer.');
}
}
async function importAgents(legacy, uploadDir) {
const [rows] = await legacy.query(
`SELECT id, name, email, cv_text, cv_file_path, keywords, exclude_keywords, is_active,
created_at, updated_at
FROM agents ORDER BY id`
);
let created = 0;
let skipped = 0;
for (const row of rows) {
if (skipNames.includes((row.name || '').trim().toLowerCase())) {
log(` Agent "${row.name}" per --skip ausgelassen.`);
skipped++;
continue;
}
// Nachtraegliche Uebernahme: was hier schon unter dem Namen steht, bleibt
// unangetastet - der Agent koennte hier inzwischen bearbeitet worden sein.
const existing = await Agent.findOne({ where: { name: row.name } });
if (existing) {
log(` Agent "${row.name}" gibt es hier schon (Id ${existing.id}) - uebersprungen.`);
skipped++;
continue;
}
if (dryRun) {
log(` wuerde Agent ${row.id} "${row.name}" anlegen`);
created++;
continue;
}
const cvFilePath = copyCv(row.cv_file_path, uploadDir);
// Die Id der Quelle bleibt erhalten, damit uebernommene Treffer weiter
// passen - aber nur, wenn sie hier noch frei ist.
const idTaken = await Agent.findByPk(row.id);
await Agent.create(
{
...(idTaken ? {} : { id: row.id }),
name: row.name,
email: row.email,
cvText: row.cv_text,
cvFilePath,
keywords: row.keywords,
excludeKeywords: row.exclude_keywords,
isActive: Boolean(row.is_active),
createdAt: row.created_at,
updatedAt: row.updated_at,
},
{ silent: true }
);
created++;
}
log(`Suchagenten: ${created} uebernommen, ${skipped} uebersprungen (${rows.length} in der Quelle).`);
}
async function importProjectsAndMatches(legacy) {
const [projects] = await legacy.query(
`SELECT id, portal, portal_project_id, title, description, company, location, rate, url,
published_at, created_at
FROM projects ORDER BY id`
);
if (!dryRun) {
for (const row of projects) {
await Project.create(
{
id: row.id,
portal: row.portal,
portalProjectId: row.portal_project_id,
title: row.title,
description: row.description,
company: row.company,
location: row.location,
rate: row.rate,
url: row.url,
publishedAt: row.published_at,
createdAt: row.created_at,
},
{ silent: true }
);
}
}
log(`Projekte: ${projects.length} uebernommen.`);
const [matches] = await legacy.query(
`SELECT id, agent_id, project_id, match_percentage, pros, cons, status, notification_sent,
created_at, updated_at
FROM matches ORDER BY id`
);
const parseList = (value) => {
if (!value) return [];
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
if (!dryRun) {
for (const row of matches) {
await Match.create(
{
id: row.id,
agentId: row.agent_id,
projectId: row.project_id,
matchPercentage: row.match_percentage,
pros: parseList(row.pros),
cons: parseList(row.cons),
status: row.status,
notificationSent: Boolean(row.notification_sent),
createdAt: row.created_at,
updatedAt: row.updated_at,
},
{ silent: true }
);
}
}
log(`Treffer: ${matches.length} uebernommen.`);
}
async function main() {
if (dryRun) log('Probelauf - es wird nichts geschrieben.');
await initScannerDatabase();
const uploadDir = ensureUploadDir();
log(`Ziel-Upload-Verzeichnis: ${uploadDir}`);
// Bei --agents-only ist der Lauf nachtraeglich gedacht und laeuft additiv:
// vorhandene Agenten sind kein Grund zum Abbruch, sie werden in
// importAgents am Namen erkannt.
const existingAgents = await Agent.count();
if (existingAgents > 0 && !force && !agentsOnly) {
log(
`Abbruch: es gibt hier schon ${existingAgents} Suchagenten. Mit --force werden Agenten, ` +
'Projekte und Treffer vorher geloescht, mit --agents-only kommen nur die fehlenden dazu.'
);
process.exit(1);
}
const legacy = await openLegacy();
try {
if (force && agentsOnly) {
log('Abbruch: --force und --agents-only zusammen ergeben keinen Sinn.');
process.exit(1);
}
if (force && !dryRun) {
// Reihenfolge wegen der Fremdschluessel: Treffer, dann Projekte, dann Agenten.
await Match.destroy({ where: {}, truncate: false });
await Project.destroy({ where: {}, truncate: false });
await Agent.destroy({ where: {}, truncate: false });
log('Vorhandene Agenten, Projekte und Treffer geloescht.');
}
if (agentsOnly) {
log('Nur Suchagenten - Einstellungen und Portale bleiben unberuehrt.');
} else {
await importSettings(legacy);
await importPortals(legacy);
}
await importAgents(legacy, uploadDir);
if (withMatches && !agentsOnly) {
await importProjectsAndMatches(legacy);
} else {
log('Projekte und Treffer uebersprungen (--with-matches aktiviert sie).');
}
} finally {
await legacy.end();
}
log('Fertig.');
process.exit(0);
}
main().catch((err) => {
console.error('[Import] Fehlgeschlagen:', err.message);
process.exit(1);
});