Files
ICON-CRM/docs/12-scanner.md
T
schakal00783andClaude Opus 5.5 651ad2b954 Scanner: neue Anzeigen zuerst abfragen, Uhrzeiten korrekt anzeigen
Seit dem Wegfall der Altersgrenze lieferten BA und StepStone nur die
relevantesten Anzeigen aller Jahre - fünf Läufe lang 0 neue Treffer.
- BA: erst veroeffentlichtseit=7 (bis 200), dann ohne Grenze (bis 200)
- StepStone: sort=2 Seiten 1-2 plus Relevanz Seite 1
  (action=sort_publish hatte keine Wirkung)
- Dashboard liest DB-Zeitstempel als UTC (vorher 2 h zu früh)

Erster Lauf danach: 481 statt 298 Anzeigen, 40 neue Treffer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 17:28:04 +02:00

288 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 12 Scanner
Der Scanner durchsucht Jobbörsen nach offenen Stellen, die ICON besetzen kann –
Festanstellungen ebenso wie Projekte. Region immer DACH. Eingeführt am 29.09.2026.
Gesucht wird **ohne Altersgrenze** (Änderung vom 29.09.2026, vorher höchstens
21 Tage): auch ältere Anzeigen kommen herein. Im Dashboard filtert
**Anzeigenalter** nach „bis 21 Tage" bzw. „älter als 21 Tage", die Sortierung
kennt „älteste Anzeige".
Menü: **Scanner** (Dashboard) mit dem Unterpunkt **Suchagenten**; Zeitplan und
API-Schlüssel unter **Einstellungen → Scanner** (nur Administratoren).
## Quellen
Nur kostenlose Wege (Entscheidung 29.09.2026). LinkedIn und Indeed sind ohne
kostenpflichtigen Dienst nicht sauber abrufbar, Monster.de antwortet mit
CAPTCHA – alle drei sind deshalb nicht dabei. Kein Chromium: jede Quelle ist ein
einfacher HTTP-Abruf (`server/src/scanner/sources.js`).
| Id | Börse | Land | Weg | Schlüssel |
| --- | --- | --- | --- | --- |
| `ba` | Agentur für Arbeit | DE | offizielle Jobsuche-API v6, JSON: je Begriff bis 200 der letzten 7 Tage (`veroeffentlichtseit=7`) plus 200 ohne Grenze | keiner (öffentlicher Header `X-API-Key: jobboerse-jobsuche`) |
| `stepstone` | StepStone.de | DE | HTML: `sort=2` (neueste zuerst) Seiten 1–2 plus Seite 1 nach Relevanz, `data-at`-Attribute | keiner |
| `karriere` | karriere.at | AT | HTML der Trefferliste, `.m-jobsListItem` | keiner |
| `adzuna` | Adzuna | DE · AT · CH | REST-API, neueste zuerst | kostenlos, App ID + Key |
| `freelancermap` | freelancermap | DE | HTML, Selektoren wie im perinco-Scanner | keiner |
Adzuna ist die einzige Quelle für die Schweiz (jobs.ch blockt mit CAPTCHA).
Jooble war anfangs dabei und ist am 29.09.2026 auf Wunsch wieder entfallen;
Agenten, die es noch in ihrer Liste tragen, überspringen es stillschweigend.
Eine Quelle ohne Schlüssel wird übersprungen, der Lauf geht mit den anderen
weiter. Scheitert eine Quelle, steht das im Lauf-Protokoll und als Hinweis über
der Trefferliste.
> **Anführungszeichen um Schlagwörter** (`"Interim", "Vakanz"`) werden beim
> Speichern, in der Eingabe und beim Lesen alter Agenten entfernt
> (`stripQuotes` in `matching.js`). Vorher suchte die Bewertung nach den Zeichen
> mit und fand am 29.09.2026 in 178 Anzeigen keinen Treffer.
>
> **Umlaute in Such-URLs** werden umgeschrieben (`ö` → `oe`). karriere.at
> antwortet auf Begriffe ohne eigene Suchseite mit 404 – das gilt als „keine
> Treffer", nicht als Fehler.
> **Neueste zuerst abfragen.** Ohne Altersgrenze liefern Arbeitsagentur und
> StepStone auf der ersten Seite die „relevantesten" Anzeigen quer durch alle
> Jahre. Am 29.09.2026 kamen so fünf Läufe lang immer dieselben ~298 Anzeigen
> und 0 neue Treffer. Deshalb fragt jede Quelle zuerst die frischen Anzeigen
> ab und erst danach den Altbestand. StepStones `action=sort_publish` wirkt
> nicht, `sort=2` schon.
>
> **Uhrzeiten sind UTC.** Server und MariaDB laufen in UTC; das Dashboard hängt
> an Zeitstempel aus der Datenbank ein `Z` an (`parseDbDate` in
> `scannerUi.tsx`), sonst erscheinen sie zwei Stunden zu früh.
> **HTML-Quellen brechen, wenn die Börse ihr Layout ändert.** Liefert StepStone,
> karriere.at oder freelancermap plötzlich 0 Treffer, zuerst die Selektoren in
> `sources.js` gegen die aktuelle Seite prüfen.
## Ein Suchlauf
```mermaid
sequenceDiagram
participant T as Zeitplan (jede Minute) / Knopf
participant P as pipeline.js
participant B as Börsen
participant DB as MariaDB
T->>P: runScan()
P->>P: läuft schon einer? dann abbrechen
P->>DB: scanner_last_scan_at = jetzt (Beginn)
P->>DB: aktive Agenten laden
P->>P: je Börse die Schlagwörter aller Agenten zusammenführen
loop je Börse × Schlagwort (1,5 s Pause)
P->>B: Suche
B-->>P: Anzeigen
end
loop je Anzeige
P->>DB: speichern (eindeutig je Börse + Kennung), Firma abgleichen
loop je Agent, der diese Börse durchsucht
P->>P: Ausschlussbegriff? weiter
P->>P: Schlagwörter zählen → Quote
P->>DB: Treffer (eindeutig je Agent + Anzeige)
end
end
P->>DB: Firmen neu zuordnen, Altes löschen, Lauf protokollieren
```
Die Sperre gegen parallele Läufe und das Schreiben des **Beginns** (nicht des
Endes) sind aus dem perinco-Scanner übernommen – dort haben fünf gleichzeitige
Läufe am 26.08.2026 den Server lahmgelegt. Die Sperre wird im `finally`
freigegeben; hängt ein Lauf länger als 30 Minuten, gibt der nächste Aufruf sie
frei.
## Bewertung ohne KI
Es wird nicht gegen Lebensläufe gematcht. Die Quote sagt, wie viele
Schlagwörter des Agenten in der Anzeige stehen (`server/src/scanner/matching.js`):
```
Gewicht je Schlagwort: 2 = im Titel, 1 = nur im Text, 0 = nicht gefunden
Quote = Summe der Gewichte / (2 × Anzahl Schlagwörter)
```
Beispiel: Agent mit „Interim CFO", „SAP FI", „IT"; Anzeige „Interim CFO (m/w/d)"
mit „SAP FI" im Text → (2 + 1 + 0) / 6 = **50 %**.
- Kurze Begriffe (≤ 3 Zeichen, z. B. „IT", „SAP") zählen nur als ganzes Wort –
sonst trifft „IT" jeden „Mitarbeiter". Längere auch als Wortteil
(„Projektleiter" in „IT-Projektleiterin").
- Ein Ausschlussbegriff in Titel, Firma oder Text verwirft die Anzeige für
diesen Agenten.
- Kein Schlagwort gefunden → kein Treffer.
Das kostet nichts und ist nachvollziehbar; OpenAI wird vom Scanner nicht
aufgerufen.
## Schlagwörter mit Alarm
Neben den Schlagwörtern trägt ein Agent **Schlagwörter mit Alarm**
(`alarm_keywords`). Sie werden genauso gesucht und für die Quote gezählt; steht
eines in einer **neu gefundenen** Anzeige, bekommt der Treffer `alarm = 1`.
```mermaid
sequenceDiagram
participant P as pipeline.js
participant DB as icon_scanner_matches
participant W as AlarmWatcher (jede Ansicht, jede Minute)
P->>DB: Treffer mit alarm = 1, alarm_ack = 0
W->>DB: GET /api/scanner/alarms
DB-->>W: unquittierte Alarme
W->>W: neuer Alarm? Zweiklang (WebAudio) + Pop-up
W->>DB: Gesehen / Zum Scanner → POST /alarms/ack
```
- Der Wächter hängt in `App.tsx`, nicht im Dashboard – der Alarm kommt also in
jeder Ansicht (beim perinco-Scanner hörte man den Ton nur im Dashboard).
- **Quittieren gilt für alle Benutzer.** Sonst klingelte derselbe Fund bei jedem
einzeln.
- Der Ton braucht einen vorherigen Klick auf der Seite (Autoplay-Regel der
Browser). Ohne bleibt es still, das Pop-up kommt trotzdem.
- Schon gespeicherte Treffer werden nicht nachträglich zu Alarmen, wenn man ein
Alarm-Wort später ergänzt (Treffer sind eindeutig je Agent und Anzeige).
- Im Dashboard tragen Alarm-Treffer eine rote Glocke; Filter **nur Alarm**.
## Sortieren
Jede Spaltenüberschrift der Trefferliste sortiert per Klick, ein zweiter Klick
dreht die Richtung (▲/▼). Texte beginnen mit A–Z, Quote und Daten mit dem
höchsten bzw. neuesten Wert. Sortiert wird **auf dem Server** – die Liste lädt
höchstens 300 Treffer, im Browser wäre nur dieser Ausschnitt geordnet. Leere
Werte (Anzeige ohne Datum oder Firma) stehen immer unten. Das Feld
*Sortierung* bleibt als Schnellwahl und zeigt „nach Spalte“, wenn eine andere
Spalte gewählt ist.
## Bekannte Unternehmen
Jede Anzeige wird mit `icon_companies` abgeglichen. Beide Namen werden
normalisiert (Kleinschrift, Rechtsformen wie GmbH/AG/KG/Co. und Satzzeichen
raus): „Müller Holding GmbH & Co. KG" → `müller holding`.
- gleicher Name → Treffer
- sonst darf der Name der Anzeige mit dem CRM-Namen beginnen („Siemens" →
„Siemens Mobility"), aber erst ab fünf Zeichen
Treffer bei bekannten Firmen stehen im Dashboard oben, sind hinterlegt und
tragen **Kunde** bzw. **Bekannt**; ein Klick öffnet das Unternehmen. Am Ende
jedes Laufs werden alle gespeicherten Anzeigen neu zugeordnet – ein heute
angelegtes Unternehmen wird so auch bei den Anzeigen von gestern erkannt.
## Zeitplan
Einstellungen → Scanner. Vorgabe: **stündlich, Mo–Fr, 06:00–20:00 Uhr**.
```mermaid
flowchart TD
T["Takt, jede Minute"] --> A{"automatische Suche an?"}
A -->|nein| X["nichts"]
A -->|ja| D{"Wochentag erlaubt?<br/>(deutsche Zeit)"}
D -->|nein| X
D -->|ja| W{"Uhrzeit zwischen Von und Bis?<br/>(Bis einschließlich)"}
W -->|nein| X
W -->|ja| I{"Intervall seit<br/>scanner_last_scan_at um?"}
I -->|nein| X
I -->|ja| R["runScan('auto')"]
```
`scanner_last_scan_at` steht in der Datenbank und wird auch vom Knopf
„Jetzt scannen" geschrieben. Ein Neustart verschiebt daher nichts, und der
Zeitplan zieht nicht direkt hinter einem manuellen Lauf noch einmal los.
Lokal lässt sich der Zeitplan mit `SCANNER_DISABLED=true` abschalten.
„Jetzt scannen" geht jederzeit, auch außerhalb des Fensters. Der Lauf läuft im
Hintergrund (Antwort `202`); das Dashboard fragt alle 5 s nach und lädt die
Liste neu, sobald er fertig ist.
## Einstellungen (icon_settings)
| Schlüssel | Vorgabe | Bedeutung |
| --- | --- | --- |
| `scanner_active` | `true` | automatische Suche an/aus |
| `scanner_days` | `1,2,3,4,5` | ISO-Wochentage, 1 = Montag |
| `scanner_from` / `scanner_to` | `06:00` / `20:00` | Fenster, deutsche Zeit |
| `scanner_interval` | `60` | Minuten, 15 bis 1440 |
| `scanner_adzuna_id`, `scanner_adzuna_key` | leer | gehen nie im Klartext zum Browser |
| `scanner_last_scan_at` | – | Beginn des letzten Laufs (ISO) |
## Tabellen
```mermaid
erDiagram
icon_scanner_agents ||--o{ icon_scanner_matches : findet
icon_scanner_jobs ||--o{ icon_scanner_matches : "wird gefunden von"
icon_companies |o--o{ icon_scanner_jobs : "bekannte Firma"
icon_scanner_agents {
int id
varchar name
text keywords "JSON-Array"
text sources "JSON-Array der Quellen-Ids"
text excludes "JSON-Array"
text alarm_keywords "JSON-Array, Ton + Pop-up"
tinyint is_active
}
icon_scanner_jobs {
int id
varchar source "UNIQUE mit external_id"
varchar external_id
varchar title
varchar company
int company_id "NULL = unbekannt"
char country "DE AT CH"
varchar url
datetime published_at
timestamp last_seen_at
}
icon_scanner_matches {
int id
int agent_id "UNIQUE mit job_id"
int job_id
tinyint score "0-100"
text hits "gefundene Schlagwörter"
enum status "neu gesehen in_bearbeitung verworfen"
tinyint alarm "Alarm-Schlagwort getroffen"
tinyint alarm_ack "Pop-up quittiert (für alle)"
}
icon_scanner_runs {
int id
enum trigger_kind "auto manual"
int jobs_found
int matches_new
text per_source "JSON je Quelle"
}
```
Aufräumen am Ende jedes Laufs: Anzeigen, die 60 Tage nicht mehr gesehen wurden,
fliegen samt Treffern raus – außer ein Treffer steht auf **In Bearbeitung**.
Das Lauf-Protokoll hält 30 Tage.
## Treffer-Status
| Status | Bedeutung |
| --- | --- |
| Neu | noch nicht angesehen – zählt im Menü-Abzeichen |
| Gesehen | Detail wurde geöffnet (automatisch) |
| In Bearbeitung | jemand kümmert sich; wird nie automatisch gelöscht |
| Verworfen | ausgeblendet, über „verworfene zeigen" wieder sichtbar |
## Endpunkte
Alle unter `/api/scanner`, alle hinter `requireAuth`.
| Methode | Pfad | Recht | Zweck |
| --- | --- | --- | --- |
| GET | `/overview` | alle | Kennzahlen, Quellen samt Ergebnis des letzten Laufs, Zeitplan, läuft gerade ein Lauf |
| GET | `/agents` | alle | Suchagenten mit Trefferzahl |
| POST | `/agents` | alle | anlegen `{ name, keywords[], alarm_keywords[], sources[], excludes[], is_active }` – Schlagwort oder Alarm-Schlagwort Pflicht |
| PUT | `/agents/:id` | alle | ändern (gleicher Körper) |
| DELETE | `/agents/:id` | alle | löschen, Treffer gehen mit |
| GET | `/matches` | alle | Filter `hours` (gefunden), `age` (`recent`/`old`, Grenze 21 Tage ab Veröffentlichung), `agentId`, `source`, `known=1`, `alarm=1`, `status` (`alle` zeigt auch Verworfene), `minScore`, `q`, `sort` (`known`/`score`/`agent`/`title`/`company`/`source`/`published`/`status`/`created`; `oldest` = `published` aufsteigend) mit `dir` (`asc`/`desc`), `limit` |
| GET | `/alarms` | alle | unquittierte Alarm-Treffer (höchstens 50) |
| POST | `/alarms/ack` | alle | `{ ids: [] }` – quittiert für alle Benutzer |
| PATCH | `/matches/:id` | alle | `{ status }` |
| POST | `/scan` | alle | Lauf starten, `202`; `409`, wenn schon einer läuft |
| GET | `/settings` | Admin | Zeitplan, Schlüssel verkürzt |
| PATCH | `/settings` | Admin | Zeitplan und/oder Schlüssel; `''` entfernt einen Schlüssel |
Das Abzeichen im Menü kommt aus `GET /api/dashboard` (`counts.scanner_new`).