Scanner: ohne Altersgrenze suchen, Filter Anzeigenalter, Alarm-Schlagwörter

- Suche ohne 21-Tage-Grenze (BA ohne veroeffentlichtseit, Adzuna ohne
  max_days_old); Einstellung Höchstalter entfällt
- Dashboard: Filter Anzeigenalter (bis/älter als 21 Tage), Sortierung
  älteste Anzeige, Filter nur Alarm, Zeitraum heißt jetzt Gefunden
- Suchagent: Schlagwörter mit Alarm; Treffer mit alarm = 1 melden sich
  über den AlarmWatcher (App-weit, jede Minute) mit Ton und Pop-up,
  Quittieren gilt für alle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-29 13:28:29 +02:00
co-authored by Claude Opus 5.5
parent b34a1968df
commit 1ad774fdd2
16 changed files with 978 additions and 625 deletions
+44 -10
View File
@@ -1,8 +1,12 @@
# 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.
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).
@@ -16,10 +20,10 @@ 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`) |
| `ba` | Agentur für Arbeit | DE | offizielle Jobsuche-API v6, JSON, 100 Anzeigen je Begriff | 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 |
| `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).
@@ -59,7 +63,6 @@ sequenceDiagram
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
@@ -101,6 +104,34 @@ mit „SAP FI" im Text → (2 + 1 + 0) / 6 = **50 %**.
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**.
## Bekannte Unternehmen
Jede Anzeige wird mit `icon_companies` abgeglichen. Beide Namen werden
@@ -118,8 +149,7 @@ 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.
Einstellungen → Scanner. Vorgabe: **stündlich, Mo–Fr, 06:00–20:00 Uhr**.
```mermaid
flowchart TD
@@ -151,7 +181,6 @@ Liste neu, sobald er fertig ist.
| `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` | leer | gehen nie im Klartext zum Browser |
| `scanner_last_scan_at` | – | Beginn des letzten Laufs (ISO) |
@@ -168,6 +197,7 @@ erDiagram
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 {
@@ -189,6 +219,8 @@ erDiagram
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
@@ -220,10 +252,12 @@ Alle unter `/api/scanner`, alle hinter `requireAuth`.
| --- | --- | --- | --- |
| 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 }` |
| 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`, `agentId`, `source`, `known=1`, `status` (`alle` zeigt auch Verworfene), `minScore`, `q`, `sort` (`known`/`score`/`published`), `limit` |
| 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`/`published`/`oldest`), `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 |