Scanner: Stellenanzeigen aus Jobbörsen (DACH, max. 21 Tage)
Neuer Menüpunkt Scanner mit Dashboard und Suchagenten, Zeitplan und API-Schlüssel unter Einstellungen -> Scanner. - Quellen: Agentur für Arbeit (API), StepStone, karriere.at, freelancermap (HTML) sowie Adzuna und Jooble (kostenlose Schlüssel) - Quote = gefundene Schlagwörter, Titel zählt doppelt; keine KI - Abgleich mit icon_companies, bekannte Firmen oben mit Abzeichen - Zeitplan Vorgabe stündlich Mo-Fr 06-20 Uhr, einstellbar - Sperre gegen parallele Läufe wie im perinco-Scanner Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
6fe5b18c11
commit
1b82f580db
@@ -0,0 +1,222 @@
|
||||
# 12 Scanner
|
||||
|
||||
Der Scanner durchsucht Jobbörsen nach offenen Stellen, die ICON besetzen kann –
|
||||
Festanstellungen ebenso wie Projekte. Region immer DACH, Anzeigen höchstens
|
||||
21 Tage alt (einstellbar). Eingeführt am 29.09.2026.
|
||||
|
||||
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, Filter `veroeffentlichtseit` | keiner (öffentlicher Header `X-API-Key: jobboerse-jobsuche`) |
|
||||
| `stepstone` | StepStone.de | DE | HTML der Trefferliste, `data-at`-Attribute | keiner |
|
||||
| `karriere` | karriere.at | AT | HTML der Trefferliste, `.m-jobsListItem` | keiner |
|
||||
| `adzuna` | Adzuna | DE · AT · CH | REST-API, `max_days_old` | kostenlos, App ID + Key |
|
||||
| `jooble` | Jooble | DE · AT · CH | REST-API je Land, `datecreatedfrom` | kostenlos, API-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).
|
||||
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.
|
||||
|
||||
> **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
|
||||
P->>P: älter als maxAge? verwerfen
|
||||
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.
|
||||
|
||||
## 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**,
|
||||
Anzeigen höchstens **21 Tage** alt.
|
||||
|
||||
```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_max_age` | `21` | Tage, 1 bis 90 |
|
||||
| `scanner_adzuna_id`, `scanner_adzuna_key`, `scanner_jooble_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"
|
||||
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"
|
||||
}
|
||||
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[], sources[], excludes[], is_active }` |
|
||||
| PUT | `/agents/:id` | alle | ändern (gleicher Körper) |
|
||||
| DELETE | `/agents/:id` | alle | löschen, Treffer gehen mit |
|
||||
| GET | `/matches` | alle | Filter `hours`, `agentId`, `source`, `known=1`, `status` (`alle` zeigt auch Verworfene), `minScore`, `q`, `sort` (`known`/`score`/`published`), `limit` |
|
||||
| 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`).
|
||||
Reference in New Issue
Block a user