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
Der eigenständige *Projekt Scanner* ist vollständig hier eingezogen und soll
die alte App ersetzen. Die läuft weiter unter `scanner.perinco.de`:
Der eigenständige *Projekt Scanner* ist vollständig hier eingezogen und hat die
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.
```
/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:
Menüpunkt **Scanner** links; ein Klick klappt die Reiter darunter auf:
| 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/` |
| REST-API | `server/src/routes/scanner.js`, gemountet als `/api/scanner` |
| 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:
@@ -259,19 +249,23 @@ Drei bewusste Unterschiede zum Original:
mitschicken, deshalb holt das Frontend die Datei und öffnet sie als Blob.
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.
Dafür ist hier jede geteilte Ressource verdoppelt:
Der Scanner benutzt durchweg Eigenes und nichts aus der Vorgänger-App - das war
die Voraussetzung für den Parallelbetrieb und macht den Rückbau folgenlos:
| | 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 |
| 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` |
| 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**
(`CREATE DATABASE IF NOT EXISTS`), die Tabellen kommen aus `sequelize.sync()`,
Portale und Standardeinstellungen werden geseedet. Es genügt also, das Backend
@@ -279,28 +273,23 @@ zu starten.
| 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_ENCRYPTION_KEY` | eigener 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_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` | CV-Verzeichnis; leer = `<server>/uploads` |
| `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
> voneinander - die OpenAI-Kosten fallen also doppelt an, solange die alte App
> 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.
Wer nur zuschauen will, setzt `SCANNER_SCHEDULER=false`; der Knopf *Jetzt
scannen* funktioniert dann weiter.
Der **Zeitversatz** ist nicht Kosmetik: beide Instanzen benutzen dieselben
Portal-Sessions (aus derselben Cookie-Quelle) und würden beim ersten Start
gleichzeitig losziehen - das provoziert Rate-Limits und CAPTCHAs bei
Freelancermap und Freelance.de. `SCANNER_SCHEDULER_OFFSET=30` verschiebt den
ersten Lauf um 30 Minuten, danach laufen beide im eigenen Intervall verschränkt.
Ab dem zweiten Start trägt `settings.last_scan_at` den Zeitplan - die Frist
greift dann nicht mehr. Sonst begann sie bei jedem Deployment von vorn und ein
fälliger Lauf konnte beliebig lange ausfallen (siehe
Der **Zeitversatz** war nicht Kosmetik: beide Instanzen benutzten dieselben
Portal-Sessions und wären beim ersten Start gleichzeitig losgezogen - das
provoziert Rate-Limits und CAPTCHAs bei Freelancermap und Freelance.de. Mit dem
Rückbau steht der Wert auf 0; die Mechanik bleibt im Code, weil sie bei jedem
Erststart einer weiteren Instanz wieder greift. Ab dem zweiten Start trägt
`settings.last_scan_at` den Zeitplan - sonst begann die Frist 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)).
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,
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
und Einstellungen herüber. Es liest die alte Datenbank nur, geschrieben wird
ausschließlich in `perinco_scanner`:
Beim Rollout am 06.08.2026 hat ein Skript (`importLegacy.js`,
`npm run scanner:import`) Agenten, Portale, Einstellungen und Session-Cookies aus
`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
cd /var/www/perinco_CRM/server
npm run scanner:import -- --dry-run # nur anzeigen
npm run scanner:import # Einstellungen, Portale, Agenten + CVs
npm run scanner:import -- --with-matches # zusätzlich Projekte und Treffer
npm run scanner:import -- --force # vorhandene Zieldaten vorher löschen
npm run scanner:import -- --agents-only --skip=Bilal # später: nur fehlende Agenten
```
> **Damals festgestellt:** Die in der alten Datenbank gespeicherten
> Portal-Passwörter liessen sich mit dem dortigen `ENCRYPTION_KEY` **nicht** mehr
> entschlüsseln - der Schlüssel war nach dem Speichern getauscht worden. Die alte
> App konnte sie damit ebenso wenig lesen, es ging also nichts verloren. Die
> Sessions (Cookies) lagen im Klartext und wurden übernommen.
`--agents-only` ist der nachträgliche Lauf: nur Suchagenten, additiv, vorhandene
werden über den Namen erkannt und übersprungen. `--skip=` lässt Agenten nach
Namen aus. Details: [03 Projekt Scanner](docs/03-scanner.md#agenten-nachträglich-holen).
Quelle kommt aus `LEGACY_*` in `server/.env`:
| 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.
Nicht mitgekommen sind 13380 Treffer, die es nur in der alten Datenbank gab,
darunter 92 als *reviewed* und 5 als *applied* markierte. Ein Nachimport hätte
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
unter `/root/backups/project_scanner_20260909-173447.sql.gz`.
### Playwright
`playwright` ist auf **exakt 1.60.0** gepinnt - dieselbe Version wie die laufende
Scanner-App. Deren Chromium-Build (`chromium-1223`) liegt auf dem Server bereits
in `/root/.cache/ms-playwright`, also fällt kein zweiter Browser-Download an und
gescrapt wird mit der Version, die dort produktiv funktioniert. Ein `^` in der
Version würde beides aufgeben, deshalb steht in `server/package.json` ein
`comments`-Feld mit dieser Begründung.
`playwright` ist auf **exakt 1.60.0** gepinnt. Der passende Chromium-Build
(`chromium-1223`) liegt auf dem Server in `/root/.cache/ms-playwright`, also
fällt kein zweiter Browser-Download an und gescrapt wird mit der Version, die
dort produktiv funktioniert. Ein `^` in der Version würde beides aufgeben,
deshalb steht in `server/package.json` ein `comments`-Feld mit dieser
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)
@@ -601,16 +574,10 @@ Ein Rollout mit neuen Backend-Paketen braucht `-Deps`.
### Einmalige Scanner-Einrichtung (am 06.08.2026 erledigt)
```bash
# 1. .env ergänzen: erzeugt SCANNER_ENCRYPTION_KEY, holt die LEGACY_*-Werte
# aus "/var/www/Projekt Scanner/backend/.env", legt vorher ein .env.bak an
# 1. .env ergänzen: erzeugt SCANNER_ENCRYPTION_KEY, legt vorher ein .env.bak an
bash /tmp/scanner-setup.sh # aus deploy/scanner-setup.sh
# 2. Bestand übernehmen
cd /var/www/perinco_CRM/server
npm run scanner:import -- --dry-run
npm run scanner:import -- --with-matches
# 3. MemoryMax anheben und neu starten
# 2. MemoryMax anheben und neu starten
cp /var/www/perinco_CRM/deploy/perinco-crm-backend.service /etc/systemd/system/
systemctl daemon-reload && systemctl restart perinco-crm-backend
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
erledigt das Backend beim Start. Playwright-Browser ebenfalls nicht, siehe
Abschnitt *Playwright* oben.
> **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.
Abschnitt *Playwright* oben. Der damalige Schritt *Bestand übernehmen*
(`npm run scanner:import`) ist mit dem Rückbau der alten App entfallen.
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
Backend mitten im Scrapen beenden. `deploy.ps1` lädt die Unit nicht mit hoch -
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:
```powershell
+11 -46
View File
@@ -2,23 +2,22 @@
# ---------------------------------------------------------------------------
# Einmalige Einrichtung des portierten Projekt Scanners im CRM-Backend.
#
# Ergaenzt /var/www/perinco_CRM/server/.env um die SCANNER_*- und LEGACY_*-
# Variablen. Idempotent: vorhandene Werte werden nicht ueberschrieben, der
# Ergaenzt /var/www/perinco_CRM/server/.env um die SCANNER_*-Variablen.
# Idempotent: vorhandene Werte werden nicht ueberschrieben, der
# Verschluesselungsschluessel wird nur erzeugt, wenn noch keiner drinsteht.
#
# 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:
# bash /tmp/scanner-setup.sh
# ---------------------------------------------------------------------------
set -euo pipefail
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
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.
#
# Werte mit Leerzeichen werden gequotet: systemd liest diese Datei als
# EnvironmentFile und trennt dort an Leerzeichen - ein ungequoteter Pfad wie
# "/var/www/Projekt Scanner/backend/uploads" kaeme abgeschnitten an. dotenv
# entfernt die Quotes beim Einlesen wieder.
# EnvironmentFile und trennt dort an Leerzeichen - ein ungequoteter Pfad kaeme
# abgeschnitten an. dotenv entfernt die Quotes beim Einlesen wieder.
upsert() {
local key="$1" value="$2"
if grep -qE "^${key}=.+" "$CRM_ENV"; then
@@ -48,13 +46,6 @@ upsert() {
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 =="
# 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_UPLOAD_DIR /var/www/perinco_CRM/server/uploads
upsert SCANNER_SCHEDULER true
upsert SCANNER_SCHEDULER_OFFSET 30
upsert SCANNER_SCHEDULER_OFFSET 0
if grep -qE '^SCANNER_ENCRYPTION_KEY=.+' "$CRM_ENV"; then
echo " SCANNER_ENCRYPTION_KEY: bleibt unveraendert"
@@ -75,35 +66,9 @@ else
echo " SCANNER_ENCRYPTION_KEY: neu erzeugt (64 Hex-Zeichen)"
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"
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"]
E --> C[("perinco_CRM")]
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
@@ -115,6 +108,5 @@ Portbelegung `ss -tlnp` prüfen. Relevant hier:
| Port | Dienst |
| --- | --- |
| 5006 | perinco CRM Backend (nur `127.0.0.1`) |
| 5005 | alte Scanner-App (`0.0.0.0`) |
| 3000 | weitere Vite-App |
| 5678 | n8n |
+50 -90
View File
@@ -2,7 +2,8 @@
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.
der eigenständigen App, die er seit dem 09.09.2026 ersetzt
([Rückbau](#rückbau-der-alten-app---09092026)).
## 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
Tag, und die Trefferliste der Boersen zeigt nur die neuesten Projekte.
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.
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:
@@ -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
erholt sich der Scanner selbst, statt auf ein Deployment zu warten.
## Parallelbetrieb - alles verdoppelt
## Rückbau der alten App - 09.09.2026
```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
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:
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
| 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` |
B["Projektbörsen"]
O["OpenAI"]
A1 --> B
N1 --> B
A1 --> O
N1 --> O
**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.
IMP["npm run scanner:import"] -.->|"liest"| A2
IMP -.->|"schreibt"| N2
IMP -.->|"kopiert"| N3
```
Was der Rückbau ändert:
| | 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) |
- **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.
**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.
### 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.
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
+20 -18
View File
@@ -34,11 +34,10 @@ Das Passwort kommt aus `$env:PERINCO_CRM_SSH_PASSWORD` oder per `-Password`.
| Unit | Verzeichnis | Port | Anmerkung |
| --- | --- | --- | --- |
| `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
der `.env` immer in Anführungszeichen setzen - systemd liest `.env` als
`EnvironmentFile` und trennt sonst am Leerzeichen.
Die zweite Unit `project-scanner-backend` (Port 5005, `/var/www/Projekt Scanner/backend`)
ist mit dem [Rückbau der alten App](03-scanner.md#rückbau-der-alten-app---09092026)
am 09.09.2026 entfallen. Port 5005 ist seither frei.
`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
@@ -58,8 +57,7 @@ mitten im Scrapen beenden.
| `SCANNER_ENCRYPTION_KEY` | AES-Schlüssel für Portal-Passwörter |
| `SCANNER_UPLOAD_DIR` | CV-Verzeichnis, leer = `<server>/uploads` |
| `SCANNER_SCHEDULER` | Hintergrund-Scraper, Standard an, nur `false` schaltet ab |
| `SCANNER_SCHEDULER_OFFSET` | Minuten Versatz für den ersten Lauf, Standard 30 |
| `LEGACY_*` | Quelle für `npm run scanner:import` |
| `SCANNER_SCHEDULER_OFFSET` | Minuten Versatz für den ersten Lauf, seit dem Rückbau der alten App 0 |
| `MS_TENANT_ID`, `MS_CLIENT_ID`, `MS_CLIENT_SECRET` | App-only-Zugriff auf Microsoft Graph |
| `MS_MAILBOX` | abzugleichendes Postfach, Standard `crm@perinco.de` |
| `MS_MAIL_FOLDER` | Ordner, Standard `inbox` |
@@ -81,10 +79,17 @@ Exchange Online: [11 Postfachanbindung](11-postfach.md).
## Playwright
`playwright` ist auf **exakt 1.60.0** gepinnt - dieselbe Version wie die alte App.
Deren Chromium-Build (`chromium-1223`) liegt in `/root/.cache/ms-playwright`, also
fällt kein zweiter Browser-Download an. Ein `^` in der Version würde das
aufgeben; die Begründung steht als `comments`-Feld in `server/package.json`.
`playwright` ist auf **exakt 1.60.0** gepinnt. Der passende Chromium-Build
(`chromium-1223`) liegt in `/root/.cache/ms-playwright` - dieser Cache stammt aus
der Installation der alten App und hat deren Rückbau überlebt, weil beide Apps
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:
@@ -96,17 +101,14 @@ cd /var/www/perinco_CRM/server && npx playwright install chromium
```bash
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
```
> **Reihenfolge:** Der Import schreibt `search_interval` aus der alten App in die
> Einstellungen. Der laufende Scheduler merkt die Änderung binnen einer Minute
> und plant sich neu - beim Rollout hat er direkt nach dem Import einen Scan
> gestartet, den der folgende Restart mitten im Scrapen beendet hat. Harmlos, aber
> vermeidbar: erst neu starten, dann importieren.
Datenbank und Tabellen legt der Start selbst an, Portale und
Standardeinstellungen werden geseedet. Agenten, Zugangsdaten und Cookies kommen
danach über die Oberfläche. Das frühere `npm run scanner:import` holte diese
Daten aus der Datenbank der alten App - beides ist mit dem Rückbau am 09.09.2026
entfallen.
## 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. |
| **`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. |
| **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. |
| **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. |
-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 |
| `utils/crypto.js` | AES über `crypto-js`; `decryptWith()` für fremde Schlüssel |
| `uploadDir.js` | `UPLOAD_DIR`, `ensureUploadDir()`, `resolveCvPath()` |
| `importLegacy.js` | einmalige Übernahme aus der alten Datenbank |
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 |
| [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 |
| [05 Frontend](05-frontend.md) | Ansichten, Menü, Komponentenkarte, Zustand |
| [06 Betrieb](06-betrieb.md) | Deployment, Umgebungsvariablen, Dienste, Störungssuche |
@@ -42,8 +42,6 @@ flowchart TB
PW["Playwright<br/>Chromium"]
DB1[("MariaDB<br/>perinco_CRM")]
DB2[("MariaDB<br/>perinco_scanner")]
ALT["alte Scanner-App<br/>:5005"]
DB3[("MariaDB<br/>project_scanner")]
end
OAI["OpenAI<br/>gpt-4o-mini"]
@@ -62,13 +60,8 @@ flowchart TB
API -.-> MSCH
MSCH -->|"Delta Query, nur lesend"| GRAPH
MSCH --> DB1
ALT --> DB3
ALT --> BOERSE
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
weiterläuft. Sie hat ihre eigene Datenbank und wird vom CRM nicht angefasst.
Die ursprüngliche Scanner-App unter `scanner.perinco.de` ist am 09.09.2026
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
# ---------------------------------------------------------------------------
# Projekt Scanner (portiert, laeuft parallel zur alten App)
# Projekt Scanner (portiert)
# ---------------------------------------------------------------------------
# Diese Instanz ist vollstaendig eigenstaendig: eigene Datenbank, eigener
# Schluessel, eigenes Upload-Verzeichnis, eigener Scheduler. Sie fasst die Daten
# des alten Scanners nicht an.
# Eigene Datenbank, eigener Schluessel, eigenes Upload-Verzeichnis, eigener
# Scheduler. Die eigenstaendige Vorgaenger-App wurde am 09.09.2026
# zurueckgebaut, es gibt keine zweite Instanz mehr.
# Eigene Datenbank - wird beim Start automatisch angelegt, falls sie fehlt.
# NICHT auf project_scanner zeigen lassen, das ist die Datenbank der alten App.
# Wird beim Start automatisch angelegt, falls sie fehlt.
SCANNER_DB_NAME=perinco_scanner
# SCANNER_DB_HOST=localhost
# SCANNER_DB_PORT=3306
# SCANNER_DB_USER=root
# 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'))"
SCANNER_ENCRYPTION_KEY=
# Eigenes Verzeichnis fuer die CV-PDFs. Leer = <server>/uploads.
# Verzeichnis fuer die CV-PDFs. Leer = <server>/uploads.
SCANNER_UPLOAD_DIR=
# Hintergrund-Scraper (Playwright + OpenAI). Standard ist AN; nur "false"
# schaltet ihn ab. Achtung: solange die alte App laeuft, bewerten beide Instanzen
# dieselben Projekte unabhaengig voneinander - die OpenAI-Kosten fallen doppelt an.
# schaltet ihn ab.
SCANNER_SCHEDULER=true
# Minuten Versatz fuer den allerersten Lauf dieser Instanz. Verhindert, dass
# beide Scanner gleichzeitig mit denselben Portal-Sessions losziehen (CAPTCHA).
# Greift nur, solange noch kein Lauf stattgefunden hat - danach traegt der in der
# Datenbank gespeicherte Zeitpunkt (last_scan_at) den Zeitplan ueber Neustarts
# hinweg. 0 = sofort loslegen.
SCANNER_SCHEDULER_OFFSET=30
# Minuten Versatz fuer den allerersten Lauf. Stammt aus dem Parallelbetrieb mit
# der alten App und ist seit deren Rueckbau ohne Wirkung, solange in der
# Datenbank ein last_scan_at steht - der traegt den Zeitplan ueber Neustarts.
# 0 = sofort loslegen.
SCANNER_SCHEDULER_OFFSET=0
# Fallback fuer den OpenAI-Key; bevorzugt wird der Wert aus den Einstellungen.
# 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",
"dev": "node --watch src/server.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"
},
"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);
});