Files
ICON-CRM/docs/12-scanner.md
T
schakal00783andClaude Opus 5.5 1969d0b77a feat(scanner): Ausschluss-Unternehmen je Suchagent
Neues Feld exclude_companies: steht ein Eintrag im Firmennamen der
Anzeige (z. B. "Hays"), verwirft der Agent sie. Beim Speichern werden
vorhandene Treffer dieser Firmen gelöscht (außer "in Bearbeitung").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:41:47 +02:00

13 KiB
Raw Blame History

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

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.

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.

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

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 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[], 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
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).