Neues Feld alarm_min_score (0-100 %): Alarm nur, wenn ein Alarm-Schlagwort gefunden wurde und die Quote den Wert erreicht. 0 % = bei jedem Fund wie bisher. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
306 lines
14 KiB
Markdown
306 lines
14 KiB
Markdown
# 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 grenzen die Felder
|
||
**Älter als** und **Jünger als** (Tage) das Alter frei ein – z. B. älter 14,
|
||
jünger 30. Leer heißt keine Grenze; Anzeigen ohne Datum fallen heraus, sobald
|
||
eines der Felder gesetzt ist. 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 oder Ausschluss-Unternehmen? 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.
|
||
- **Ausschluss-Unternehmen** (seit 20.10.2026, Spalte `exclude_companies`):
|
||
steht ein Eintrag im **Firmennamen** der Anzeige, verwirft der Agent sie –
|
||
„Hays" blendet „Hays Professional Solutions GmbH" aus, nicht aber eine
|
||
Anzeige, die Hays nur im Text erwähnt. Gleiche Wortregel wie oben (≤ 3
|
||
Zeichen nur als ganzes Wort). Beim Speichern des Agenten werden schon
|
||
vorhandene Treffer dieser Firmen gelöscht, außer Status „in Bearbeitung".
|
||
- 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`.
|
||
|
||
Seit 20.10.2026 gilt dafür eine **Mindest-Quote** je Agent
|
||
(`alarm_min_score`, 0–100 %, Feld „ab … %“ neben den Alarm-Schlagwörtern):
|
||
Alarm nur, wenn ein Alarm-Schlagwort gefunden wurde **und** die Quote des
|
||
Treffers mindestens diesen Wert erreicht. 0 % = bei jedem Fund (bisheriges
|
||
Verhalten). Darunter wird der Treffer normal gespeichert, nur ohne Alarm. Wird
|
||
die Grenze angehoben, verlieren noch nicht quittierte Alarme darunter ihr
|
||
`alarm`-Kennzeichen; ein Absenken macht alte Treffer nicht nachträglich zu
|
||
Alarmen.
|
||
|
||
```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 exclude_companies "JSON-Array, Abgleich mit Firmenname"
|
||
text alarm_keywords "JSON-Array, Ton + Pop-up"
|
||
tinyint alarm_min_score "Alarm erst ab dieser Quote, 0 = immer"
|
||
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[], alarm_min_score, sources[], excludes[], exclude_companies[], is_active }` – Schlagwort oder Alarm-Schlagwort Pflicht |
|
||
| PUT | `/agents/:id` | alle | ändern (gleicher Körper); löscht vorhandene Treffer von Ausschluss-Unternehmen (außer „in Bearbeitung“) |
|
||
| DELETE | `/agents/:id` | alle | löschen, Treffer gehen mit |
|
||
| GET | `/matches` | alle | Filter `hours` (gefunden), `olderThan` / `youngerThan` (Tage ab Veröffentlichung, einzeln oder zusammen; `age=recent|old` = feste 21-Tage-Grenze, aus der ersten Fassung), `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`).
|