# 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 | | `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. > **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?
(deutsche Zeit)"} D -->|nein| X D -->|ja| W{"Uhrzeit zwischen Von und Bis?
(Bis einschließlich)"} W -->|nein| X W -->|ja| I{"Intervall seit
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` | 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`).