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
@@ -187,6 +187,29 @@ Zahl der Grund dort – eine leere Zeile ließe offen, ob die Marge null oder
|
|||||||
unbekannt ist. Der Tagessatz wird am Projekt gepflegt, die Stundensätze beim
|
unbekannt ist. Der Tagessatz wird am Projekt gepflegt, die Stundensätze beim
|
||||||
Kontakt (*Kontakt → Stundensatz vor Ort / remote*).
|
Kontakt (*Kontakt → Stundensatz vor Ort / remote*).
|
||||||
|
|
||||||
|
## Scanner
|
||||||
|
|
||||||
|
Sucht in Jobbörsen (Agentur für Arbeit, StepStone, karriere.at, Adzuna, Jooble,
|
||||||
|
freelancermap) nach offenen Stellen in DACH, höchstens 21 Tage alt. Technik
|
||||||
|
siehe [12 Scanner](12-scanner.md).
|
||||||
|
|
||||||
|
1. **Scanner → Suchagenten → + Neuer Suchagent.** Name, Schlagwörter (Enter
|
||||||
|
oder Komma trennt), Ausschlussbegriffe und Börsen wählen. Jedes Schlagwort
|
||||||
|
ist eine eigene Suche.
|
||||||
|
2. **Scanner (Dashboard).** Die Treffer erscheinen nach dem nächsten Lauf –
|
||||||
|
automatisch nach Zeitplan oder sofort über **Jetzt scannen**.
|
||||||
|
3. Die **Quote** zeigt, wie viele Schlagwörter in der Anzeige stehen; ein
|
||||||
|
Treffer im Titel zählt doppelt.
|
||||||
|
4. **Kunde** / **Bekannt** neben der Firma heißt: das Unternehmen steht schon im
|
||||||
|
CRM. Diese Treffer stehen oben; ein Klick öffnet das Unternehmen.
|
||||||
|
5. **Details** zeigt Auszug und gefundene Schlagwörter, **Anzeige öffnen** führt
|
||||||
|
zur Börse. Status setzen: *In Bearbeitung*, wenn sich jemand kümmert,
|
||||||
|
*Verworfen*, wenn es nicht passt.
|
||||||
|
|
||||||
|
Zeitplan (Wochentage, Uhrzeit, Intervall, Höchstalter) und die kostenlosen
|
||||||
|
Schlüssel für Adzuna und Jooble pflegt ein Administrator unter
|
||||||
|
*Einstellungen → Scanner*.
|
||||||
|
|
||||||
## Historie
|
## Historie
|
||||||
|
|
||||||
Alles, was im CRM getan wurde, mit Zeitpunkt, Person und – bei Änderungen – den
|
Alles, was im CRM getan wurde, mit Zeitpunkt, Person und – bei Änderungen – den
|
||||||
|
|||||||
@@ -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`).
|
||||||
@@ -21,6 +21,7 @@ keine Vorkenntnisse voraus.
|
|||||||
| [09 Anleitung für Entwickler](09-anleitung-entwickler.md) | Einrichten, erweitern, bekannte Fallen |
|
| [09 Anleitung für Entwickler](09-anleitung-entwickler.md) | Einrichten, erweitern, bekannte Fallen |
|
||||||
| [10 API-Referenz](10-api.md) | Jeder Endpunkt, seine Rechte und seine Antwort |
|
| [10 API-Referenz](10-api.md) | Jeder Endpunkt, seine Rechte und seine Antwort |
|
||||||
| [11 Glossar](11-glossar.md) | Lead, Ausschreibung, Phase, Art, Status – was was bedeutet |
|
| [11 Glossar](11-glossar.md) | Lead, Ausschreibung, Phase, Art, Status – was was bedeutet |
|
||||||
|
| [12 Scanner](12-scanner.md) | Stellenanzeigen aus Jobbörsen: Quellen, Bewertung, Zeitplan, Endpunkte |
|
||||||
|
|
||||||
Die Designentscheidungen stehen im [README des Projekts](../README.md).
|
Die Designentscheidungen stehen im [README des Projekts](../README.md).
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cheerio": "^1.2.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ router.get('/', async (req, res, next) => {
|
|||||||
(SELECT COUNT(*) FROM icon_tenders) AS tenders,
|
(SELECT COUNT(*) FROM icon_tenders) AS tenders,
|
||||||
(SELECT COUNT(*) FROM icon_projects WHERE status = 'Aktiv') AS projects_active,
|
(SELECT COUNT(*) FROM icon_projects WHERE status = 'Aktiv') AS projects_active,
|
||||||
(SELECT COUNT(*) FROM icon_leads WHERE archived = 0) AS leads_open,
|
(SELECT COUNT(*) FROM icon_leads WHERE archived = 0) AS leads_open,
|
||||||
(SELECT COUNT(*) FROM icon_leads WHERE archived = 1) AS leads_archived`
|
(SELECT COUNT(*) FROM icon_leads WHERE archived = 1) AS leads_archived,
|
||||||
|
(SELECT COUNT(*) FROM icon_scanner_matches WHERE status = 'neu') AS scanner_new`
|
||||||
);
|
);
|
||||||
|
|
||||||
const [byStatus] = await pool.query(
|
const [byStatus] = await pool.query(
|
||||||
|
|||||||
@@ -0,0 +1,347 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { pool } from '../db.js';
|
||||||
|
import { requireAdmin, requireAuth } from '../middleware/auth.js';
|
||||||
|
import { logHistory } from '../history.js';
|
||||||
|
import { SOURCES, SOURCE_IDS, sourceReady } from '../scanner/sources.js';
|
||||||
|
import { parseList, runScan, scanStatus } from '../scanner/pipeline.js';
|
||||||
|
import {
|
||||||
|
getLastScanAt,
|
||||||
|
loadScannerSecrets,
|
||||||
|
loadScannerSettings,
|
||||||
|
maskedScannerSecrets,
|
||||||
|
saveScannerSettings,
|
||||||
|
} from '../scanner/settings.js';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Scanner: Stellenanzeigen aus Jobboersen (siehe docs/12-scanner.md).
|
||||||
|
*
|
||||||
|
* Suchagenten und Treffer pflegt jeder Benutzer; Zeitplan und API-Schluessel
|
||||||
|
* nur Administratoren (Einstellungen -> Scanner).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
router.use(requireAuth);
|
||||||
|
|
||||||
|
const STATUSES = ['neu', 'gesehen', 'in_bearbeitung', 'verworfen'];
|
||||||
|
|
||||||
|
const cleanList = (value) =>
|
||||||
|
[...new Set((Array.isArray(value) ? value : []).map((item) => String(item).trim()).filter(Boolean))].slice(0, 50);
|
||||||
|
|
||||||
|
const agentOut = (row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
keywords: parseList(row.keywords),
|
||||||
|
sources: parseList(row.sources),
|
||||||
|
excludes: parseList(row.excludes),
|
||||||
|
is_active: Boolean(row.is_active),
|
||||||
|
matches: Number(row.matches ?? 0),
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------
|
||||||
|
* Uebersicht: Kennzahlen, Quellen, Zustand des Zeitplans
|
||||||
|
* ---------------------------------------------------------------------- */
|
||||||
|
router.get('/overview', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const settings = await loadScannerSettings();
|
||||||
|
const secrets = await loadScannerSecrets();
|
||||||
|
const [[agents]] = await pool.query(
|
||||||
|
'SELECT COUNT(*) AS active FROM icon_scanner_agents WHERE is_active = 1'
|
||||||
|
);
|
||||||
|
const [[jobs]] = await pool.query('SELECT COUNT(*) AS total FROM icon_scanner_jobs');
|
||||||
|
const [[matches]] = await pool.query(
|
||||||
|
`SELECT COUNT(*) AS total,
|
||||||
|
SUM(m.status = 'neu') AS fresh,
|
||||||
|
SUM(j.company_id IS NOT NULL) AS known
|
||||||
|
FROM icon_scanner_matches m JOIN icon_scanner_jobs j ON j.id = m.job_id`
|
||||||
|
);
|
||||||
|
const [[lastRun]] = await pool.query(
|
||||||
|
'SELECT * FROM icon_scanner_runs WHERE finished_at IS NOT NULL ORDER BY id DESC LIMIT 1'
|
||||||
|
);
|
||||||
|
const perSource = lastRun?.per_source ? JSON.parse(lastRun.per_source) : {};
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
stats: {
|
||||||
|
agents: Number(agents.active),
|
||||||
|
jobs: Number(jobs.total),
|
||||||
|
matches: Number(matches.total ?? 0),
|
||||||
|
fresh: Number(matches.fresh ?? 0),
|
||||||
|
known: Number(matches.known ?? 0),
|
||||||
|
},
|
||||||
|
sources: SOURCE_IDS.map((id) => ({
|
||||||
|
id,
|
||||||
|
label: SOURCES[id].label,
|
||||||
|
countries: SOURCES[id].countries,
|
||||||
|
ready: sourceReady(id, secrets),
|
||||||
|
needsKey: SOURCES[id].needsKey,
|
||||||
|
last: perSource[id] ?? null,
|
||||||
|
})),
|
||||||
|
schedule: settings,
|
||||||
|
lastScanAt: (await getLastScanAt())?.toISOString() ?? null,
|
||||||
|
lastRun: lastRun
|
||||||
|
? {
|
||||||
|
id: lastRun.id,
|
||||||
|
trigger: lastRun.trigger_kind,
|
||||||
|
started_at: lastRun.started_at,
|
||||||
|
finished_at: lastRun.finished_at,
|
||||||
|
jobs_found: lastRun.jobs_found,
|
||||||
|
matches_new: lastRun.matches_new,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
status: scanStatus(),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------
|
||||||
|
* Suchagenten
|
||||||
|
* ---------------------------------------------------------------------- */
|
||||||
|
router.get('/agents', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT a.*, (SELECT COUNT(*) FROM icon_scanner_matches m WHERE m.agent_id = a.id) AS matches
|
||||||
|
FROM icon_scanner_agents a ORDER BY a.name`
|
||||||
|
);
|
||||||
|
res.json({ agents: rows.map(agentOut) });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function readAgent(body) {
|
||||||
|
const name = String(body?.name ?? '').trim().slice(0, 190);
|
||||||
|
const keywords = cleanList(body?.keywords);
|
||||||
|
const sources = cleanList(body?.sources).filter((id) => SOURCE_IDS.includes(id));
|
||||||
|
const excludes = cleanList(body?.excludes);
|
||||||
|
if (!name) return { error: 'Bitte einen Namen angeben.' };
|
||||||
|
if (keywords.length === 0) return { error: 'Mindestens ein Schlagwort angeben.' };
|
||||||
|
if (sources.length === 0) return { error: 'Mindestens eine Börse auswählen.' };
|
||||||
|
return { name, keywords, sources, excludes, is_active: body?.is_active !== false };
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/agents', async (req, res, next) => {
|
||||||
|
const agent = readAgent(req.body);
|
||||||
|
if (agent.error) return res.status(400).json({ error: agent.error });
|
||||||
|
try {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
`INSERT INTO icon_scanner_agents (name, keywords, sources, excludes, is_active, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
agent.name,
|
||||||
|
JSON.stringify(agent.keywords),
|
||||||
|
JSON.stringify(agent.sources),
|
||||||
|
JSON.stringify(agent.excludes),
|
||||||
|
agent.is_active ? 1 : 0,
|
||||||
|
req.user.id,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
await logHistory({
|
||||||
|
entityType: 'system',
|
||||||
|
entityLabel: 'Scanner',
|
||||||
|
action: 'created',
|
||||||
|
text: `Suchagent „${agent.name}" angelegt`,
|
||||||
|
userId: req.user.id,
|
||||||
|
});
|
||||||
|
const [[row]] = await pool.query('SELECT * FROM icon_scanner_agents WHERE id = ?', [result.insertId]);
|
||||||
|
res.status(201).json({ agent: agentOut(row) });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/agents/:id', async (req, res, next) => {
|
||||||
|
const agent = readAgent(req.body);
|
||||||
|
if (agent.error) return res.status(400).json({ error: agent.error });
|
||||||
|
try {
|
||||||
|
const [result] = await pool.query(
|
||||||
|
`UPDATE icon_scanner_agents
|
||||||
|
SET name = ?, keywords = ?, sources = ?, excludes = ?, is_active = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[
|
||||||
|
agent.name,
|
||||||
|
JSON.stringify(agent.keywords),
|
||||||
|
JSON.stringify(agent.sources),
|
||||||
|
JSON.stringify(agent.excludes),
|
||||||
|
agent.is_active ? 1 : 0,
|
||||||
|
Number(req.params.id),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
if (result.affectedRows === 0) return res.status(404).json({ error: 'Suchagent nicht gefunden.' });
|
||||||
|
const [[row]] = await pool.query('SELECT * FROM icon_scanner_agents WHERE id = ?', [Number(req.params.id)]);
|
||||||
|
res.json({ agent: agentOut(row) });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/agents/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [[row]] = await pool.query('SELECT name FROM icon_scanner_agents WHERE id = ?', [Number(req.params.id)]);
|
||||||
|
if (!row) return res.status(404).json({ error: 'Suchagent nicht gefunden.' });
|
||||||
|
// Treffer gehen per ON DELETE CASCADE mit.
|
||||||
|
await pool.query('DELETE FROM icon_scanner_agents WHERE id = ?', [Number(req.params.id)]);
|
||||||
|
await logHistory({
|
||||||
|
entityType: 'system',
|
||||||
|
entityLabel: 'Scanner',
|
||||||
|
action: 'deleted',
|
||||||
|
text: `Suchagent „${row.name}" gelöscht`,
|
||||||
|
userId: req.user.id,
|
||||||
|
});
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------
|
||||||
|
* Treffer
|
||||||
|
* ---------------------------------------------------------------------- */
|
||||||
|
router.get('/matches', async (req, res, next) => {
|
||||||
|
const where = [];
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
const hours = Number(req.query.hours);
|
||||||
|
if (Number.isFinite(hours) && hours > 0) {
|
||||||
|
where.push('m.created_at >= NOW() - INTERVAL ? HOUR');
|
||||||
|
params.push(Math.min(hours, 24 * 90));
|
||||||
|
}
|
||||||
|
if (req.query.agentId) {
|
||||||
|
where.push('m.agent_id = ?');
|
||||||
|
params.push(Number(req.query.agentId));
|
||||||
|
}
|
||||||
|
if (req.query.source && SOURCE_IDS.includes(String(req.query.source))) {
|
||||||
|
where.push('j.source = ?');
|
||||||
|
params.push(String(req.query.source));
|
||||||
|
}
|
||||||
|
if (req.query.known === '1') where.push('j.company_id IS NOT NULL');
|
||||||
|
if (req.query.status && STATUSES.includes(String(req.query.status))) {
|
||||||
|
where.push('m.status = ?');
|
||||||
|
params.push(String(req.query.status));
|
||||||
|
} else if (req.query.status !== 'alle') {
|
||||||
|
// Verworfenes nur auf ausdruecklichen Wunsch.
|
||||||
|
where.push("m.status <> 'verworfen'");
|
||||||
|
}
|
||||||
|
const minScore = Number(req.query.minScore);
|
||||||
|
if (Number.isFinite(minScore) && minScore > 0) {
|
||||||
|
where.push('m.score >= ?');
|
||||||
|
params.push(minScore);
|
||||||
|
}
|
||||||
|
const q = String(req.query.q ?? '').trim();
|
||||||
|
if (q) {
|
||||||
|
where.push('(j.title LIKE ? OR j.company LIKE ? OR j.location LIKE ?)');
|
||||||
|
params.push(`%${q}%`, `%${q}%`, `%${q}%`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const order =
|
||||||
|
req.query.sort === 'score'
|
||||||
|
? 'm.score DESC, m.created_at DESC'
|
||||||
|
: req.query.sort === 'published'
|
||||||
|
? 'j.published_at IS NULL, j.published_at DESC'
|
||||||
|
: // bekannte Firmen zuerst, dann neueste
|
||||||
|
'known DESC, m.created_at DESC';
|
||||||
|
|
||||||
|
const limit = Math.min(Math.max(Number(req.query.limit) || 300, 1), 1000);
|
||||||
|
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
`SELECT m.id, m.score, m.hits, m.status, m.created_at,
|
||||||
|
a.id AS agent_id, a.name AS agent_name,
|
||||||
|
j.id AS job_id, j.source, j.title, j.company, j.location, j.country,
|
||||||
|
j.employment_type, j.url, j.description, j.published_at,
|
||||||
|
j.company_id, c.name AS company_name, c.is_customer,
|
||||||
|
(j.company_id IS NOT NULL) AS known
|
||||||
|
FROM icon_scanner_matches m
|
||||||
|
JOIN icon_scanner_agents a ON a.id = m.agent_id
|
||||||
|
JOIN icon_scanner_jobs j ON j.id = m.job_id
|
||||||
|
LEFT JOIN icon_companies c ON c.id = j.company_id
|
||||||
|
${whereSql}
|
||||||
|
ORDER BY ${order}
|
||||||
|
LIMIT ${limit}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
const [[count]] = await pool.query(
|
||||||
|
`SELECT COUNT(*) AS total
|
||||||
|
FROM icon_scanner_matches m JOIN icon_scanner_jobs j ON j.id = m.job_id
|
||||||
|
${whereSql}`,
|
||||||
|
params
|
||||||
|
);
|
||||||
|
res.json({
|
||||||
|
total: Number(count.total),
|
||||||
|
matches: rows.map((row) => ({
|
||||||
|
...row,
|
||||||
|
hits: parseList(row.hits),
|
||||||
|
known: Boolean(row.known),
|
||||||
|
is_customer: row.is_customer == null ? null : Boolean(row.is_customer),
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch('/matches/:id', async (req, res, next) => {
|
||||||
|
const status = String(req.body?.status ?? '');
|
||||||
|
if (!STATUSES.includes(status)) return res.status(400).json({ error: 'Unbekannter Status.' });
|
||||||
|
try {
|
||||||
|
const [result] = await pool.query('UPDATE icon_scanner_matches SET status = ? WHERE id = ?', [
|
||||||
|
status,
|
||||||
|
Number(req.params.id),
|
||||||
|
]);
|
||||||
|
if (result.affectedRows === 0) return res.status(404).json({ error: 'Treffer nicht gefunden.' });
|
||||||
|
res.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------
|
||||||
|
* Jetzt scannen - laeuft im Hintergrund, die Oberflaeche fragt /overview ab.
|
||||||
|
* Ein Lauf dauert je nach Zahl der Schlagwoerter Minuten; darauf zu warten
|
||||||
|
* hiesse, gegen das Zeitlimit des Proxys zu laufen.
|
||||||
|
* ---------------------------------------------------------------------- */
|
||||||
|
router.post('/scan', (req, res) => {
|
||||||
|
if (scanStatus().running) return res.status(409).json({ error: 'Es läuft bereits ein Suchlauf.' });
|
||||||
|
runScan('manual').catch((err) => console.error('[Scanner] Manueller Lauf:', err));
|
||||||
|
res.status(202).json({ started: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------
|
||||||
|
* Einstellungen (nur Administratoren)
|
||||||
|
* ---------------------------------------------------------------------- */
|
||||||
|
router.get('/settings', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
res.json({ settings: await loadScannerSettings(), secrets: await maskedScannerSecrets() });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch('/settings', requireAdmin, async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const settings = await saveScannerSettings(req.body ?? {});
|
||||||
|
const touchedKeys = ['adzunaId', 'adzunaKey', 'joobleKey'].filter((key) => req.body?.[key] !== undefined);
|
||||||
|
await logHistory({
|
||||||
|
entityType: 'system',
|
||||||
|
entityLabel: 'Scanner',
|
||||||
|
action: 'updated',
|
||||||
|
// Nie die Schluessel selbst - nur, dass sie geaendert wurden.
|
||||||
|
text: [
|
||||||
|
`Zeitplan: ${settings.active ? 'an' : 'aus'}, alle ${settings.interval} min, ${settings.from}–${settings.to} Uhr`,
|
||||||
|
touchedKeys.length ? 'API-Schlüssel geändert' : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(', '),
|
||||||
|
userId: req.user.id,
|
||||||
|
});
|
||||||
|
res.json({ settings, secrets: await maskedScannerSecrets() });
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.status === 400) return res.status(400).json({ error: err.message });
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
/*
|
||||||
|
* Bewertung einer Anzeige gegen einen Suchagenten - ohne KI.
|
||||||
|
*
|
||||||
|
* Wunsch vom 29.09.2026: kein Abgleich mit Lebenslaeufen, sondern "je mehr
|
||||||
|
* Schlagwoerter gefunden werden, desto besser der Treffer". Gezaehlt wird in
|
||||||
|
* Titel und Text; ein Schlagwort im Titel zaehlt doppelt, weil der Titel die
|
||||||
|
* Rolle benennt und der Text oft nur Umfeld beschreibt.
|
||||||
|
*
|
||||||
|
* score = Summe(Gewicht je gefundenem Schlagwort) / (2 * Anzahl Schlagwoerter)
|
||||||
|
* Gewicht 2 = im Titel, 1 = nur im Text
|
||||||
|
*/
|
||||||
|
|
||||||
|
const normalize = (text) => String(text ?? '').normalize('NFC').toLowerCase();
|
||||||
|
|
||||||
|
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kurze Begriffe ("IT", "SAP", "HR") nur als ganzes Wort - sonst steckt "it" in
|
||||||
|
* jedem "Mitarbeiter". Laengere als Teilwort, damit "Projektleiter" auch in
|
||||||
|
* "IT-Projektleiterin" und "Projektleiterstelle" trifft.
|
||||||
|
*/
|
||||||
|
export function containsTerm(text, term) {
|
||||||
|
const needle = normalize(term).trim();
|
||||||
|
if (!needle) return false;
|
||||||
|
const haystack = normalize(text);
|
||||||
|
if (needle.length <= 3) {
|
||||||
|
return new RegExp(`(?<![\\p{L}\\p{N}])${escape(needle)}(?![\\p{L}\\p{N}])`, 'u').test(haystack);
|
||||||
|
}
|
||||||
|
return haystack.includes(needle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns {{ score: number, hits: string[] } | null} null = ausgeschlossen
|
||||||
|
* oder kein einziges Schlagwort gefunden
|
||||||
|
*/
|
||||||
|
export function scoreJob(agent, job) {
|
||||||
|
const title = job.title ?? '';
|
||||||
|
const all = `${title}\n${job.company ?? ''}\n${job.description ?? ''}`;
|
||||||
|
|
||||||
|
if ((agent.excludes ?? []).some((term) => containsTerm(all, term))) return null;
|
||||||
|
|
||||||
|
const keywords = agent.keywords ?? [];
|
||||||
|
if (keywords.length === 0) return null;
|
||||||
|
|
||||||
|
let points = 0;
|
||||||
|
const hits = [];
|
||||||
|
for (const keyword of keywords) {
|
||||||
|
if (containsTerm(title, keyword)) {
|
||||||
|
points += 2;
|
||||||
|
hits.push(keyword);
|
||||||
|
} else if (containsTerm(all, keyword)) {
|
||||||
|
points += 1;
|
||||||
|
hits.push(keyword);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hits.length === 0) return null;
|
||||||
|
|
||||||
|
return { score: Math.round((100 * points) / (2 * keywords.length)), hits };
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Firmennamen der Boersen und des CRM auf einen Nenner bringen:
|
||||||
|
* "Müller Holding GmbH & Co. KG" -> "müller holding".
|
||||||
|
*/
|
||||||
|
const LEGAL_FORMS =
|
||||||
|
/\b(gmbh|mbh|ag|se|kg|kgaa|ohg|gbr|ug|e\.?\s?v|e\.?\s?k|co|inc|ltd|llc|plc|sa|sarl|bv|nv|haftungsbeschränkt|gesellschaft mit beschränkter haftung|aktiengesellschaft)\b\.?/g;
|
||||||
|
|
||||||
|
export function normalizeCompany(name) {
|
||||||
|
return normalize(name)
|
||||||
|
.replace(/\(.*?\)/g, ' ')
|
||||||
|
.replace(/&/g, ' ')
|
||||||
|
.replace(LEGAL_FORMS, ' ')
|
||||||
|
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sucht das Unternehmen im CRM. Gleicher Name gewinnt; sonst darf der Name der
|
||||||
|
* Anzeige mit dem CRM-Namen beginnen ("Siemens" -> "Siemens Mobility"), aber
|
||||||
|
* nur ab fuenf Zeichen, sonst wird aus "ABC" jeder, der mit "ABC" anfaengt.
|
||||||
|
*
|
||||||
|
* @param {Array<{id:number, key:string}>} companies vorberechnet, laengste zuerst
|
||||||
|
*/
|
||||||
|
export function findCompany(companies, name) {
|
||||||
|
const key = normalizeCompany(name);
|
||||||
|
if (!key) return null;
|
||||||
|
const exact = companies.find((company) => company.key === key);
|
||||||
|
if (exact) return exact.id;
|
||||||
|
const prefix = companies.find((company) => company.key.length >= 5 && key.startsWith(`${company.key} `));
|
||||||
|
return prefix ? prefix.id : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
import { pool } from '../db.js';
|
||||||
|
import { SOURCES, sourceReady } from './sources.js';
|
||||||
|
import { findCompany, normalizeCompany, scoreJob } from './matching.js';
|
||||||
|
import { loadScannerSecrets, loadScannerSettings, setLastScanAt } from './settings.js';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Ein Suchlauf.
|
||||||
|
*
|
||||||
|
* 1. aktive Agenten laden
|
||||||
|
* 2. je Boerse die Schlagwoerter aller Agenten zusammenfuehren - dasselbe
|
||||||
|
* Wort wird nur einmal abgefragt, egal wie viele Agenten es tragen
|
||||||
|
* 3. Anzeigen abrufen, alles ueber der Altersgrenze verwerfen
|
||||||
|
* 4. Anzeigen speichern, Firma gegen das CRM abgleichen
|
||||||
|
* 5. jede Anzeige gegen jeden Agenten bewerten, der diese Boerse durchsucht
|
||||||
|
*
|
||||||
|
* Zwei Aufrufer (Zeitplan und Knopf "Jetzt scannen") teilen sich die Sperre
|
||||||
|
* unten. Lehre aus dem perinco-Scanner (26.08.2026): ohne sie stapeln sich die
|
||||||
|
* Laeufe. Das Freigeben steht zwingend im finally, sonst bliebe die Sperre
|
||||||
|
* nach einer Ausnahme fuer immer stehen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let running = false;
|
||||||
|
let runningSince = null;
|
||||||
|
// Notventil: haengt ein Lauf, gibt der naechste Aufruf die Sperre frei.
|
||||||
|
const MAX_RUNTIME_MS = 30 * 60 * 1000;
|
||||||
|
// Pause zwischen zwei Abrufen derselben Boerse - freundlich bleiben.
|
||||||
|
const REQUEST_GAP_MS = 1500;
|
||||||
|
// Anzeigen, die so lange nicht mehr gesehen wurden, fliegen samt Treffern raus.
|
||||||
|
const KEEP_DAYS = 60;
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
export function scanStatus() {
|
||||||
|
return { running, since: runningSince ? new Date(runningSince).toISOString() : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runScan(trigger = 'manual') {
|
||||||
|
if (running && Date.now() - runningSince < MAX_RUNTIME_MS) {
|
||||||
|
return { skipped: true, reason: 'Es läuft bereits ein Suchlauf.' };
|
||||||
|
}
|
||||||
|
running = true;
|
||||||
|
runningSince = Date.now();
|
||||||
|
try {
|
||||||
|
return await execute(trigger);
|
||||||
|
} finally {
|
||||||
|
running = false;
|
||||||
|
runningSince = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function execute(trigger) {
|
||||||
|
const started = new Date();
|
||||||
|
// Beginn des Laufs, nicht sein Ende: das Intervall ist ein Takt.
|
||||||
|
await setLastScanAt(started);
|
||||||
|
|
||||||
|
const [runResult] = await pool.query('INSERT INTO icon_scanner_runs (trigger_kind) VALUES (?)', [trigger]);
|
||||||
|
const runId = runResult.insertId;
|
||||||
|
|
||||||
|
const settings = await loadScannerSettings();
|
||||||
|
const secrets = await loadScannerSecrets();
|
||||||
|
const agents = await loadActiveAgents();
|
||||||
|
|
||||||
|
// Boerse -> Schlagwoerter (ohne Doppelte, Gross/klein egal)
|
||||||
|
const plan = new Map();
|
||||||
|
for (const agent of agents) {
|
||||||
|
for (const source of agent.sources) {
|
||||||
|
if (!SOURCES[source] || !sourceReady(source, secrets)) continue;
|
||||||
|
const words = plan.get(source) ?? new Map();
|
||||||
|
for (const keyword of agent.keywords) words.set(keyword.toLowerCase(), keyword);
|
||||||
|
plan.set(source, words);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const perSource = {};
|
||||||
|
const jobs = new Map();
|
||||||
|
const oldest = Date.now() - settings.maxAge * 24 * 3_600_000;
|
||||||
|
|
||||||
|
for (const [source, words] of plan) {
|
||||||
|
const stats = { ok: true, count: 0, errors: [] };
|
||||||
|
let first = true;
|
||||||
|
for (const keyword of words.values()) {
|
||||||
|
if (!first) await sleep(REQUEST_GAP_MS);
|
||||||
|
first = false;
|
||||||
|
try {
|
||||||
|
const found = await SOURCES[source].fetch(keyword, { maxAge: settings.maxAge, secrets });
|
||||||
|
for (const job of found) {
|
||||||
|
// Ohne Datum bleibt die Anzeige drin: die Boersen sortieren bzw.
|
||||||
|
// filtern selbst nach Alter, das fehlende Feld ist ein Parserluecke.
|
||||||
|
if (job.publishedAt && job.publishedAt.getTime() < oldest) continue;
|
||||||
|
jobs.set(`${job.source}:${job.externalId}`, job);
|
||||||
|
stats.count += 1;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
stats.ok = false;
|
||||||
|
stats.errors.push(`${keyword}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Teilweise Fehler sind kein Totalausfall - nur, wenn gar nichts kam.
|
||||||
|
if (stats.count > 0) stats.ok = true;
|
||||||
|
stats.errors = stats.errors.slice(0, 5);
|
||||||
|
perSource[source] = stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
const companies = await loadCompanyKeys();
|
||||||
|
let matchesNew = 0;
|
||||||
|
|
||||||
|
for (const job of jobs.values()) {
|
||||||
|
const jobId = await saveJob(job, findCompany(companies, job.company));
|
||||||
|
for (const agent of agents) {
|
||||||
|
if (!agent.sources.includes(job.source)) continue;
|
||||||
|
const result = scoreJob(agent, job);
|
||||||
|
if (!result) continue;
|
||||||
|
const [insert] = await pool.query(
|
||||||
|
`INSERT IGNORE INTO icon_scanner_matches (agent_id, job_id, score, hits) VALUES (?, ?, ?, ?)`,
|
||||||
|
[agent.id, jobId, result.score, JSON.stringify(result.hits)]
|
||||||
|
);
|
||||||
|
matchesNew += insert.affectedRows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seit dem letzten Lauf angelegte Unternehmen auch bei aelteren Anzeigen erkennen.
|
||||||
|
await relinkCompanies();
|
||||||
|
await cleanup();
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE icon_scanner_runs
|
||||||
|
SET finished_at = CURRENT_TIMESTAMP, jobs_found = ?, matches_new = ?, per_source = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
[jobs.size, matchesNew, JSON.stringify(perSource), runId]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[Scanner] Lauf ${runId} (${trigger}): ${jobs.size} Anzeigen, ${matchesNew} neue Treffer, ` +
|
||||||
|
Object.entries(perSource)
|
||||||
|
.map(([source, s]) => `${source}=${s.ok ? s.count : 'Fehler'}`)
|
||||||
|
.join(' ')
|
||||||
|
);
|
||||||
|
|
||||||
|
return { skipped: false, runId, jobs: jobs.size, matchesNew, perSource };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseList(value) {
|
||||||
|
try {
|
||||||
|
const list = JSON.parse(value ?? '[]');
|
||||||
|
return Array.isArray(list) ? list.map((item) => String(item).trim()).filter(Boolean) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadActiveAgents() {
|
||||||
|
const [rows] = await pool.query('SELECT * FROM icon_scanner_agents WHERE is_active = 1');
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
keywords: parseList(row.keywords),
|
||||||
|
sources: parseList(row.sources),
|
||||||
|
excludes: parseList(row.excludes),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCompanyKeys() {
|
||||||
|
const [rows] = await pool.query('SELECT id, name FROM icon_companies');
|
||||||
|
return rows
|
||||||
|
.map((row) => ({ id: row.id, key: normalizeCompany(row.name) }))
|
||||||
|
.filter((row) => row.key)
|
||||||
|
.sort((a, b) => b.key.length - a.key.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Speichert oder aktualisiert eine Anzeige und liefert ihre Kennung. */
|
||||||
|
async function saveJob(job, companyId) {
|
||||||
|
const cut = (value, max) => (value == null ? null : String(value).slice(0, max));
|
||||||
|
// LAST_INSERT_ID(id) im Update-Zweig: so kommt auch bei einer vorhandenen
|
||||||
|
// Anzeige deren Kennung als insertId zurueck - ohne zweite Abfrage.
|
||||||
|
const [result] = await pool.query(
|
||||||
|
`INSERT INTO icon_scanner_jobs
|
||||||
|
(source, external_id, title, company, company_id, location, country,
|
||||||
|
employment_type, url, description, published_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
id = LAST_INSERT_ID(id),
|
||||||
|
title = VALUES(title),
|
||||||
|
company = VALUES(company),
|
||||||
|
company_id = VALUES(company_id),
|
||||||
|
location = VALUES(location),
|
||||||
|
employment_type = COALESCE(VALUES(employment_type), employment_type),
|
||||||
|
description = COALESCE(VALUES(description), description),
|
||||||
|
published_at = COALESCE(published_at, VALUES(published_at)),
|
||||||
|
last_seen_at = CURRENT_TIMESTAMP`,
|
||||||
|
[
|
||||||
|
job.source,
|
||||||
|
cut(job.externalId, 190),
|
||||||
|
cut(job.title, 500),
|
||||||
|
cut(job.company, 300),
|
||||||
|
companyId,
|
||||||
|
cut(job.location, 300),
|
||||||
|
job.country,
|
||||||
|
cut(job.employmentType, 60),
|
||||||
|
cut(job.url, 1000),
|
||||||
|
cut(job.description, 60_000),
|
||||||
|
job.publishedAt,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
return result.insertId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Alte Anzeigen raeumen. Was jemand "in Bearbeitung" hat, bleibt stehen.
|
||||||
|
*/
|
||||||
|
async function cleanup() {
|
||||||
|
await pool.query(
|
||||||
|
`DELETE j FROM icon_scanner_jobs j
|
||||||
|
WHERE j.last_seen_at < NOW() - INTERVAL ? DAY
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM icon_scanner_matches m
|
||||||
|
WHERE m.job_id = j.id AND m.status = 'in_bearbeitung')`,
|
||||||
|
[KEEP_DAYS]
|
||||||
|
);
|
||||||
|
await pool.query('DELETE FROM icon_scanner_runs WHERE started_at < NOW() - INTERVAL 30 DAY');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nach dem Anlegen oder Umbenennen von Unternehmen: vorhandene Anzeigen neu
|
||||||
|
* zuordnen. Billig - reiner Namensvergleich.
|
||||||
|
*/
|
||||||
|
export async function relinkCompanies() {
|
||||||
|
const companies = await loadCompanyKeys();
|
||||||
|
const [jobs] = await pool.query('SELECT id, company, company_id FROM icon_scanner_jobs WHERE company IS NOT NULL');
|
||||||
|
let changed = 0;
|
||||||
|
for (const job of jobs) {
|
||||||
|
const id = findCompany(companies, job.company);
|
||||||
|
if (id === job.company_id) continue;
|
||||||
|
await pool.query('UPDATE icon_scanner_jobs SET company_id = ? WHERE id = ?', [id, job.id]);
|
||||||
|
changed += 1;
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { runScan } from './pipeline.js';
|
||||||
|
import { getLastScanAt, loadScannerSettings } from './settings.js';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Zeitplan des Scanners - einstellbar unter Einstellungen -> Scanner.
|
||||||
|
*
|
||||||
|
* Ein Takt pro Minute prueft: Scanner an? Wochentag erlaubt? Uhrzeit im
|
||||||
|
* Fenster? Intervall seit dem letzten Lauf um? Der letzte Lauf steht in der
|
||||||
|
* Datenbank (scanner_last_scan_at) und wird auch vom Knopf "Jetzt scannen"
|
||||||
|
* geschrieben - so zieht der Zeitplan nicht direkt hinter einem manuellen Lauf
|
||||||
|
* noch einmal los, und ein Neustart des Dienstes verschiebt nichts.
|
||||||
|
*
|
||||||
|
* Gerechnet wird in deutscher Zeit, unabhaengig von der Zeitzone des Servers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TICK_MS = 60_000;
|
||||||
|
// Ein Takt kommt nie auf die Sekunde - ohne Toleranz wuerde aus 60 Minuten 61.
|
||||||
|
const SLACK_MS = 45_000;
|
||||||
|
const WEEKDAYS = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 };
|
||||||
|
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
export function berlinNow(date = new Date()) {
|
||||||
|
const parts = Object.fromEntries(
|
||||||
|
new Intl.DateTimeFormat('en-GB', {
|
||||||
|
timeZone: 'Europe/Berlin',
|
||||||
|
weekday: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hourCycle: 'h23',
|
||||||
|
})
|
||||||
|
.formatToParts(date)
|
||||||
|
.map((part) => [part.type, part.value])
|
||||||
|
);
|
||||||
|
return { weekday: WEEKDAYS[parts.weekday], time: `${parts.hour}:${parts.minute}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liegt der Zeitpunkt in einem erlaubten Fenster? (Ende einschliesslich) */
|
||||||
|
export function inWindow(settings, date = new Date()) {
|
||||||
|
const { weekday, time } = berlinNow(date);
|
||||||
|
return settings.days.includes(weekday) && time >= settings.from && time <= settings.to;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
try {
|
||||||
|
const settings = await loadScannerSettings();
|
||||||
|
if (!settings.active || !inWindow(settings)) return;
|
||||||
|
|
||||||
|
const last = await getLastScanAt();
|
||||||
|
if (last && Date.now() - last.getTime() < settings.interval * 60_000 - SLACK_MS) return;
|
||||||
|
|
||||||
|
await runScan('auto');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Scanner] Zeitplan:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startScheduler() {
|
||||||
|
if (timer) return;
|
||||||
|
if (process.env.SCANNER_DISABLED === 'true') {
|
||||||
|
console.log('[Scanner] Zeitplan abgeschaltet (SCANNER_DISABLED).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timer = setInterval(() => void tick(), TICK_MS);
|
||||||
|
console.log('[Scanner] Zeitplan aktiv.');
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { getSetting, setSetting } from '../db.js';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Einstellungen des Scanners (Einstellungen -> Scanner).
|
||||||
|
*
|
||||||
|
* Alles liegt in icon_settings unter dem Praefix `scanner_`. Die Vorgaben
|
||||||
|
* entsprechen dem Wunsch vom 29.09.2026: stuendlich, Mo-Fr, 06-20 Uhr,
|
||||||
|
* Anzeigen hoechstens 21 Tage alt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DEFAULTS = {
|
||||||
|
active: true,
|
||||||
|
// ISO-Wochentage, 1 = Montag ... 7 = Sonntag
|
||||||
|
days: [1, 2, 3, 4, 5],
|
||||||
|
from: '06:00',
|
||||||
|
to: '20:00',
|
||||||
|
interval: 60,
|
||||||
|
maxAge: 21,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Zugangsdaten der kostenlosen APIs - gehen nie im Klartext zum Browser. */
|
||||||
|
const SECRET_KEYS = {
|
||||||
|
adzunaId: 'scanner_adzuna_id',
|
||||||
|
adzunaKey: 'scanner_adzuna_key',
|
||||||
|
joobleKey: 'scanner_jooble_key',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||||
|
|
||||||
|
export async function loadScannerSettings() {
|
||||||
|
const [active, days, from, to, interval, maxAge] = await Promise.all([
|
||||||
|
getSetting('scanner_active'),
|
||||||
|
getSetting('scanner_days'),
|
||||||
|
getSetting('scanner_from'),
|
||||||
|
getSetting('scanner_to'),
|
||||||
|
getSetting('scanner_interval'),
|
||||||
|
getSetting('scanner_max_age'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const parsedDays = (days ?? '')
|
||||||
|
.split(',')
|
||||||
|
.map(Number)
|
||||||
|
.filter((d) => d >= 1 && d <= 7);
|
||||||
|
|
||||||
|
return {
|
||||||
|
active: active === null ? DEFAULTS.active : active === 'true',
|
||||||
|
days: days === null ? DEFAULTS.days : parsedDays,
|
||||||
|
from: TIME.test(from ?? '') ? from : DEFAULTS.from,
|
||||||
|
to: TIME.test(to ?? '') ? to : DEFAULTS.to,
|
||||||
|
interval: clampInt(interval, 15, 24 * 60, DEFAULTS.interval),
|
||||||
|
maxAge: clampInt(maxAge, 1, 90, DEFAULTS.maxAge),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveScannerSettings(input) {
|
||||||
|
const current = await loadScannerSettings();
|
||||||
|
const next = { ...current };
|
||||||
|
|
||||||
|
if (typeof input.active === 'boolean') next.active = input.active;
|
||||||
|
if (Array.isArray(input.days)) {
|
||||||
|
next.days = [...new Set(input.days.map(Number).filter((d) => d >= 1 && d <= 7))].sort();
|
||||||
|
}
|
||||||
|
if (input.from !== undefined) {
|
||||||
|
if (!TIME.test(String(input.from))) throw badRequest('Startzeit bitte als HH:MM.');
|
||||||
|
next.from = String(input.from);
|
||||||
|
}
|
||||||
|
if (input.to !== undefined) {
|
||||||
|
if (!TIME.test(String(input.to))) throw badRequest('Endzeit bitte als HH:MM.');
|
||||||
|
next.to = String(input.to);
|
||||||
|
}
|
||||||
|
if (next.from >= next.to) throw badRequest('Die Startzeit muss vor der Endzeit liegen.');
|
||||||
|
if (input.interval !== undefined) next.interval = clampInt(input.interval, 15, 24 * 60, current.interval);
|
||||||
|
if (input.maxAge !== undefined) next.maxAge = clampInt(input.maxAge, 1, 90, current.maxAge);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
setSetting('scanner_active', String(next.active)),
|
||||||
|
setSetting('scanner_days', next.days.join(',')),
|
||||||
|
setSetting('scanner_from', next.from),
|
||||||
|
setSetting('scanner_to', next.to),
|
||||||
|
setSetting('scanner_interval', String(next.interval)),
|
||||||
|
setSetting('scanner_max_age', String(next.maxAge)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Schluessel: undefined = unveraendert, '' = entfernen
|
||||||
|
for (const [field, key] of Object.entries(SECRET_KEYS)) {
|
||||||
|
if (input[field] === undefined) continue;
|
||||||
|
await setSetting(key, String(input[field] ?? '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadScannerSecrets() {
|
||||||
|
const entries = await Promise.all(
|
||||||
|
Object.entries(SECRET_KEYS).map(async ([field, key]) => [field, (await getSetting(key)) || ''])
|
||||||
|
);
|
||||||
|
return Object.fromEntries(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fuer die Oberflaeche: nur, ob etwas hinterlegt ist, plus verkuerzte Form. */
|
||||||
|
export async function maskedScannerSecrets() {
|
||||||
|
const secrets = await loadScannerSecrets();
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(secrets).map(([field, value]) => [
|
||||||
|
field,
|
||||||
|
value ? `${value.slice(0, 3)}…${value.slice(-3)}` : null,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLastScanAt() {
|
||||||
|
const value = await getSetting('scanner_last_scan_at');
|
||||||
|
const date = value ? new Date(value) : null;
|
||||||
|
return date && !Number.isNaN(date.getTime()) ? date : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setLastScanAt(date) {
|
||||||
|
await setSetting('scanner_last_scan_at', date.toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampInt(value, min, max, fallback) {
|
||||||
|
const n = Number.parseInt(value, 10);
|
||||||
|
if (!Number.isFinite(n)) return fallback;
|
||||||
|
return Math.min(max, Math.max(min, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function badRequest(message) {
|
||||||
|
const err = new Error(message);
|
||||||
|
err.status = 400;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
import * as cheerio from 'cheerio';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Die Jobbörsen des Scanners.
|
||||||
|
*
|
||||||
|
* Jede Quelle liefert fuer ein Schlagwort eine Liste einheitlicher Anzeigen:
|
||||||
|
*
|
||||||
|
* { source, externalId, title, company, location, country,
|
||||||
|
* employmentType, url, description, publishedAt: Date|null }
|
||||||
|
*
|
||||||
|
* Bewusst nur kostenlose Wege (Entscheidung 29.09.2026): LinkedIn und Indeed
|
||||||
|
* sind ohne kostenpflichtigen Dienst nicht sauber abrufbar, Monster.de antwortet
|
||||||
|
* mit CAPTCHA. Kein Chromium - die Seiten hier liefern ihre Liste als HTML bzw.
|
||||||
|
* JSON aus, ein einfacher Abruf reicht und belastet den Server kaum.
|
||||||
|
*
|
||||||
|
* Eine Quelle, die scheitert, wirft. Die Pipeline faengt das je Quelle ab, der
|
||||||
|
* Lauf geht mit den anderen weiter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const UA =
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0 Safari/537.36';
|
||||||
|
const TIMEOUT_MS = 20_000;
|
||||||
|
|
||||||
|
export const SOURCES = {
|
||||||
|
ba: { label: 'Agentur für Arbeit', countries: 'DE', needsKey: null, fetch: fetchBa },
|
||||||
|
stepstone: { label: 'StepStone', countries: 'DE', needsKey: null, fetch: fetchStepstone },
|
||||||
|
karriere: { label: 'karriere.at', countries: 'AT', needsKey: null, fetch: fetchKarriere },
|
||||||
|
adzuna: { label: 'Adzuna', countries: 'DE · AT · CH', needsKey: 'adzuna', fetch: fetchAdzuna },
|
||||||
|
jooble: { label: 'Jooble', countries: 'DE · AT · CH', needsKey: 'jooble', fetch: fetchJooble },
|
||||||
|
freelancermap: { label: 'freelancermap', countries: 'DE', needsKey: null, fetch: fetchFreelancermap },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SOURCE_IDS = Object.keys(SOURCES);
|
||||||
|
|
||||||
|
/** Kann die Quelle mit den hinterlegten Schluesseln ueberhaupt laufen? */
|
||||||
|
export function sourceReady(id, secrets) {
|
||||||
|
const needs = SOURCES[id]?.needsKey;
|
||||||
|
if (!needs) return true;
|
||||||
|
if (needs === 'adzuna') return Boolean(secrets.adzunaId && secrets.adzunaKey);
|
||||||
|
if (needs === 'jooble') return Boolean(secrets.joobleKey);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
async function request(url, { json = false, headers = {}, method = 'GET', body } = {}) {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
body,
|
||||||
|
headers: {
|
||||||
|
'User-Agent': UA,
|
||||||
|
'Accept-Language': 'de-DE,de;q=0.9',
|
||||||
|
Accept: json ? 'application/json' : 'text/html,application/xhtml+xml',
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
return json ? await response.json() : await response.text();
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.name === 'AbortError') throw new Error('Zeitüberschreitung');
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clean = (value) =>
|
||||||
|
String(value ?? '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const absolute = (href, base) => {
|
||||||
|
if (!href) return null;
|
||||||
|
try {
|
||||||
|
return new URL(href, base).toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toDate = (value) => {
|
||||||
|
if (!value) return null;
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "vor 5 Tagen", "vor 3 Stunden", "Gestern", "Heute", "vor 2 Wochen",
|
||||||
|
* "28.09.2026" - die Listen zeigen das Alter meist nur so an.
|
||||||
|
*/
|
||||||
|
export function parseRelativeDate(text, now = new Date()) {
|
||||||
|
const value = clean(text).toLowerCase();
|
||||||
|
if (!value) return null;
|
||||||
|
|
||||||
|
const dotted = value.match(/(\d{1,2})\.(\d{1,2})\.(\d{2,4})/);
|
||||||
|
if (dotted) {
|
||||||
|
const year = dotted[3].length === 2 ? 2000 + Number(dotted[3]) : Number(dotted[3]);
|
||||||
|
return new Date(year, Number(dotted[2]) - 1, Number(dotted[1]), 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
const shift = (ms) => new Date(now.getTime() - ms);
|
||||||
|
const HOUR = 3_600_000;
|
||||||
|
const DAY = 24 * HOUR;
|
||||||
|
|
||||||
|
if (/heute|gerade|neu|minute/.test(value)) return now;
|
||||||
|
if (/gestern/.test(value)) return shift(DAY);
|
||||||
|
const number = Number(value.match(/(\d+)/)?.[1] ?? 1);
|
||||||
|
if (/stunde/.test(value)) return shift(number * HOUR);
|
||||||
|
if (/tag/.test(value)) return shift(number * DAY);
|
||||||
|
if (/woche/.test(value)) return shift(number * 7 * DAY);
|
||||||
|
if (/monat/.test(value)) return shift(number * 30 * DAY);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = (keyword) =>
|
||||||
|
clean(keyword)
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9äöüß]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------
|
||||||
|
* Bundesagentur fuer Arbeit - offizielle Jobsuche-API (JSON, ohne Anmeldung;
|
||||||
|
* der Schluessel "jobboerse-jobsuche" ist der oeffentliche der Jobsuche-App).
|
||||||
|
* ---------------------------------------------------------------------- */
|
||||||
|
async function fetchBa(keyword, { maxAge }) {
|
||||||
|
const url =
|
||||||
|
'https://rest.arbeitsagentur.de/jobboerse/jobsuche-service/pc/v6/jobs?' +
|
||||||
|
new URLSearchParams({ was: keyword, veroeffentlichtseit: String(maxAge), size: '100', page: '1' });
|
||||||
|
const data = await request(url, { json: true, headers: { 'X-API-Key': 'jobboerse-jobsuche' } });
|
||||||
|
const list = data?.ergebnisliste ?? data?.stellenangebote ?? [];
|
||||||
|
|
||||||
|
return list
|
||||||
|
.filter((job) => !/AUSBILDUNG|PRAKTIKUM/i.test(job.stellenangebotsart ?? job.angebotsart ?? ''))
|
||||||
|
.map((job) => {
|
||||||
|
const refnr = job.refnr ?? job.referenznummer ?? job.hashId;
|
||||||
|
const place = job.arbeitsort ?? job.arbeitsorte?.[0] ?? {};
|
||||||
|
const land = clean(place.land);
|
||||||
|
return {
|
||||||
|
source: 'ba',
|
||||||
|
externalId: String(refnr ?? job.stellenangebotsTitel ?? job.titel),
|
||||||
|
title: clean(job.stellenangebotsTitel ?? job.titel ?? job.beruf),
|
||||||
|
company: clean(job.arbeitgeber ?? job.arbeitgeberName) || null,
|
||||||
|
location: clean([place.plz, place.ort].filter(Boolean).join(' ')) || clean(place.region) || null,
|
||||||
|
// Die BA fuehrt auch Stellen ausserhalb DACH - die fallen unten raus.
|
||||||
|
country: !land || /deutschland/i.test(land) ? 'DE' : /österreich/i.test(land) ? 'AT' : /schweiz/i.test(land) ? 'CH' : null,
|
||||||
|
employmentType: /SELBST/i.test(job.stellenangebotsart ?? '') ? 'Freiberuflich' : 'Anstellung',
|
||||||
|
url: job.externeUrl || `https://www.arbeitsagentur.de/jobsuche/jobdetail/${encodeURIComponent(refnr)}`,
|
||||||
|
description: clean(job.beruf) || null,
|
||||||
|
publishedAt: toDate(job.aktuelleVeroeffentlichungsdatum ?? job.veroeffentlichungsdatum ?? job.modifikationsTimestamp),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((job) => job.title && job.externalId && job.country);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------
|
||||||
|
* StepStone.de - Trefferliste als HTML, jede Karte traegt data-at-Attribute.
|
||||||
|
* ---------------------------------------------------------------------- */
|
||||||
|
async function fetchStepstone(keyword) {
|
||||||
|
const base = 'https://www.stepstone.de';
|
||||||
|
const html = await request(`${base}/jobs/${encodeURIComponent(slug(keyword))}?action=sort_publish`);
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
const jobs = [];
|
||||||
|
|
||||||
|
$('[data-at="job-item-title"]').each((_, element) => {
|
||||||
|
const card = $(element).closest('article').length ? $(element).closest('article') : $(element).parent().parent();
|
||||||
|
const link =
|
||||||
|
$(element).attr('href') ||
|
||||||
|
$(element).closest('a').attr('href') ||
|
||||||
|
card.find('a[href*="stellenangebote--"]').first().attr('href');
|
||||||
|
const url = absolute(link, base);
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
const id = url.match(/--(\d+)(?:-inline)?\.html/)?.[1] ?? url.split('?')[0];
|
||||||
|
jobs.push({
|
||||||
|
source: 'stepstone',
|
||||||
|
externalId: id,
|
||||||
|
title: clean($(element).text()),
|
||||||
|
company: clean(card.find('[data-at="job-item-company-name"]').first().text()) || null,
|
||||||
|
location: clean(card.find('[data-at="job-item-location"]').first().text()) || null,
|
||||||
|
country: 'DE',
|
||||||
|
employmentType: clean(card.find('[data-at="job-item-work-type"], [data-at="job-item-contract-type"]').first().text()) || null,
|
||||||
|
url: url.split('?')[0],
|
||||||
|
description: clean(card.find('[data-at="jobcard-content"], [data-at="job-item-middle"]').first().text()) || null,
|
||||||
|
publishedAt: parseRelativeDate(card.find('time').first().text()) ?? toDate(card.find('time').first().attr('datetime')),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return jobs.filter((job) => job.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------
|
||||||
|
* karriere.at - groesste Jobboerse in Oesterreich, Liste als HTML.
|
||||||
|
* ---------------------------------------------------------------------- */
|
||||||
|
async function fetchKarriere(keyword) {
|
||||||
|
const base = 'https://www.karriere.at';
|
||||||
|
const html = await request(`${base}/jobs/${encodeURIComponent(slug(keyword))}`);
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
const jobs = [];
|
||||||
|
|
||||||
|
$('.m-jobsListItem').each((_, element) => {
|
||||||
|
const card = $(element);
|
||||||
|
const link = card.find('.m-jobsListItem__titleLink').first();
|
||||||
|
const url = absolute(link.attr('href'), base);
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
jobs.push({
|
||||||
|
source: 'karriere',
|
||||||
|
externalId: url.match(/\/jobs\/(\d+)/)?.[1] ?? url.split('?')[0],
|
||||||
|
title: clean(link.text()),
|
||||||
|
company: clean(card.find('.m-jobsListItem__company').first().text()) || null,
|
||||||
|
location: clean(card.find('.m-jobsListItem__locations').first().text()) || null,
|
||||||
|
country: 'AT',
|
||||||
|
employmentType: clean(card.find('.m-jobsListItem__pill').first().text()) || null,
|
||||||
|
url: url.split('?')[0],
|
||||||
|
description: clean(card.find('.m-jobsListItem__snippet').first().text()) || null,
|
||||||
|
publishedAt: parseRelativeDate(card.find('.m-jobsListItem__date').first().text()),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return jobs.filter((job) => job.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------
|
||||||
|
* Adzuna - kostenloser API-Schluessel (developer.adzuna.com), sammelt Anzeigen
|
||||||
|
* vieler Boersen fuer DE, AT und CH. Die einzige Quelle fuer die Schweiz.
|
||||||
|
* ---------------------------------------------------------------------- */
|
||||||
|
async function fetchAdzuna(keyword, { maxAge, secrets }) {
|
||||||
|
const jobs = [];
|
||||||
|
for (const country of ['de', 'at', 'ch']) {
|
||||||
|
const url =
|
||||||
|
`https://api.adzuna.com/v1/api/jobs/${country}/search/1?` +
|
||||||
|
new URLSearchParams({
|
||||||
|
app_id: secrets.adzunaId,
|
||||||
|
app_key: secrets.adzunaKey,
|
||||||
|
what_phrase: keyword,
|
||||||
|
max_days_old: String(maxAge),
|
||||||
|
results_per_page: '50',
|
||||||
|
sort_by: 'date',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
});
|
||||||
|
const data = await request(url, { json: true });
|
||||||
|
for (const job of data?.results ?? []) {
|
||||||
|
jobs.push({
|
||||||
|
source: 'adzuna',
|
||||||
|
externalId: String(job.id),
|
||||||
|
title: clean(job.title).replace(/<\/?strong>/g, ''),
|
||||||
|
company: clean(job.company?.display_name) || null,
|
||||||
|
location: clean(job.location?.display_name) || null,
|
||||||
|
country: country.toUpperCase(),
|
||||||
|
employmentType:
|
||||||
|
job.contract_type === 'permanent'
|
||||||
|
? 'Festanstellung'
|
||||||
|
: job.contract_type === 'contract'
|
||||||
|
? 'Befristet / Projekt'
|
||||||
|
: null,
|
||||||
|
url: job.redirect_url,
|
||||||
|
description: clean(job.description).replace(/<\/?strong>/g, '') || null,
|
||||||
|
publishedAt: toDate(job.created),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return jobs.filter((job) => job.title && job.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------
|
||||||
|
* Jooble - kostenloser API-Schluessel (jooble.org/api/about), Sammler fuer DACH.
|
||||||
|
* Je Land ein eigener Host.
|
||||||
|
* ---------------------------------------------------------------------- */
|
||||||
|
async function fetchJooble(keyword, { maxAge, secrets }) {
|
||||||
|
const since = new Date(Date.now() - maxAge * 24 * 3_600_000).toISOString().slice(0, 10);
|
||||||
|
const jobs = [];
|
||||||
|
for (const country of ['de', 'at', 'ch']) {
|
||||||
|
const data = await request(`https://${country}.jooble.org/api/${encodeURIComponent(secrets.joobleKey)}`, {
|
||||||
|
json: true,
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ keywords: keyword, datecreatedfrom: since, page: '1' }),
|
||||||
|
});
|
||||||
|
for (const job of data?.jobs ?? []) {
|
||||||
|
jobs.push({
|
||||||
|
source: 'jooble',
|
||||||
|
externalId: String(job.id ?? job.link),
|
||||||
|
title: clean(job.title).replace(/<[^>]+>/g, ''),
|
||||||
|
company: clean(job.company) || null,
|
||||||
|
location: clean(job.location) || null,
|
||||||
|
country: country.toUpperCase(),
|
||||||
|
employmentType: clean(job.type) || null,
|
||||||
|
url: job.link,
|
||||||
|
description: clean(job.snippet).replace(/<[^>]+>/g, '').replace(/ /g, ' ') || null,
|
||||||
|
publishedAt: toDate(job.updated),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return jobs.filter((job) => job.title && job.url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------------
|
||||||
|
* freelancermap - Projekte fuer Freiberufler und Interim, neueste zuerst.
|
||||||
|
* Selektoren wie im Scanner des perinco CRM.
|
||||||
|
* ---------------------------------------------------------------------- */
|
||||||
|
async function fetchFreelancermap(keyword) {
|
||||||
|
const base = 'https://www.freelancermap.de';
|
||||||
|
const html = await request(`${base}/projekte?q=${encodeURIComponent(keyword)}&sort=created_desc`);
|
||||||
|
const $ = cheerio.load(html);
|
||||||
|
const jobs = [];
|
||||||
|
|
||||||
|
$('.project-card').each((_, element) => {
|
||||||
|
const card = $(element);
|
||||||
|
const link = card.find('a[data-testid="title"], a[data-id="project-card-title"]').first();
|
||||||
|
const url = absolute(link.attr('href'), base);
|
||||||
|
if (!url) return;
|
||||||
|
|
||||||
|
const slugPart = url.match(/\/projekt\/([^/?#]+)/)?.[1];
|
||||||
|
const meta = ['type', 'duration', 'beginningText', 'remoteInPercent']
|
||||||
|
.map((id) => clean(card.find(`[data-testid="${id}"]`).first().text()))
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' | ');
|
||||||
|
|
||||||
|
jobs.push({
|
||||||
|
source: 'freelancermap',
|
||||||
|
externalId: slugPart?.match(/-(\d+)$/)?.[1] ?? slugPart ?? url,
|
||||||
|
title: clean(link.text()),
|
||||||
|
company: clean(card.find('.project-info div').first().text()) || null,
|
||||||
|
location: clean(card.find('[data-testid="city"]').first().text()) || null,
|
||||||
|
country: 'DE',
|
||||||
|
employmentType: 'Freiberuflich',
|
||||||
|
url: url.split('?')[0],
|
||||||
|
description: meta || null,
|
||||||
|
publishedAt:
|
||||||
|
toDate(card.find('time').first().attr('datetime')) ??
|
||||||
|
parseRelativeDate(card.find('[data-testid="created"], time').first().text()),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return jobs.filter((job) => job.title);
|
||||||
|
}
|
||||||
@@ -419,3 +419,83 @@ CREATE TABLE IF NOT EXISTS icon_settings (
|
|||||||
value MEDIUMTEXT NULL,
|
value MEDIUMTEXT NULL,
|
||||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Scanner: Stellenanzeigen aus Jobbörsen (DACH, höchstens 21 Tage alt)
|
||||||
|
--
|
||||||
|
-- Ein Suchagent ist eine Liste von Schlagwörtern plus der Börsen, in denen
|
||||||
|
-- gesucht wird, plus Ausschlussbegriffen. Jede Anzeige liegt genau einmal in
|
||||||
|
-- icon_scanner_jobs (eindeutig je Börse und deren Kennung); welcher Agent sie
|
||||||
|
-- mit welcher Güte gefunden hat, steht in icon_scanner_matches.
|
||||||
|
--
|
||||||
|
-- Bewertet wird nicht per KI, sondern per Zählung: je mehr Schlagwörter eines
|
||||||
|
-- Agenten in Titel und Text vorkommen, desto höher der Treffer.
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS icon_scanner_agents (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(190) NOT NULL,
|
||||||
|
-- JSON-Arrays: ["SAP FI", "Interim CFO"], ["ba", "stepstone"], ["Praktikum"]
|
||||||
|
keywords TEXT NOT NULL,
|
||||||
|
sources TEXT NOT NULL,
|
||||||
|
excludes TEXT NULL,
|
||||||
|
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_by INT UNSIGNED NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_icon_scanner_agents_creator FOREIGN KEY (created_by)
|
||||||
|
REFERENCES icon_users (id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS icon_scanner_jobs (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
source VARCHAR(30) NOT NULL,
|
||||||
|
external_id VARCHAR(190) NOT NULL,
|
||||||
|
title VARCHAR(500) NOT NULL,
|
||||||
|
company VARCHAR(300) NULL,
|
||||||
|
-- Treffer im CRM: Unternehmen mit gleichem (normalisiertem) Namen.
|
||||||
|
company_id INT UNSIGNED NULL,
|
||||||
|
location VARCHAR(300) NULL,
|
||||||
|
country CHAR(2) NULL,
|
||||||
|
employment_type VARCHAR(60) NULL,
|
||||||
|
url VARCHAR(1000) NOT NULL,
|
||||||
|
description MEDIUMTEXT NULL,
|
||||||
|
published_at DATETIME NULL,
|
||||||
|
first_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
last_seen_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_icon_scanner_jobs (source, external_id),
|
||||||
|
KEY idx_icon_scanner_jobs_published (published_at),
|
||||||
|
KEY idx_icon_scanner_jobs_company (company_id),
|
||||||
|
CONSTRAINT fk_icon_scanner_jobs_company FOREIGN KEY (company_id)
|
||||||
|
REFERENCES icon_companies (id) ON DELETE SET NULL
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS icon_scanner_matches (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
agent_id INT UNSIGNED NOT NULL,
|
||||||
|
job_id INT UNSIGNED NOT NULL,
|
||||||
|
score TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
-- JSON-Array der gefundenen Schlagwörter
|
||||||
|
hits TEXT NULL,
|
||||||
|
status ENUM('neu','gesehen','in_bearbeitung','verworfen') NOT NULL DEFAULT 'neu',
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE KEY uq_icon_scanner_matches (agent_id, job_id),
|
||||||
|
KEY idx_icon_scanner_matches_created (created_at),
|
||||||
|
CONSTRAINT fk_icon_scanner_matches_agent FOREIGN KEY (agent_id)
|
||||||
|
REFERENCES icon_scanner_agents (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT fk_icon_scanner_matches_job FOREIGN KEY (job_id)
|
||||||
|
REFERENCES icon_scanner_jobs (id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
-- Protokoll der Läufe: wann, wodurch ausgelöst, was je Börse herauskam.
|
||||||
|
CREATE TABLE IF NOT EXISTS icon_scanner_runs (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
trigger_kind ENUM('auto','manual') NOT NULL,
|
||||||
|
started_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
finished_at TIMESTAMP NULL,
|
||||||
|
jobs_found INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
matches_new INT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
-- JSON: { ba: { ok: true, count: 12 }, stepstone: { ok: false, error: "..." } }
|
||||||
|
per_source TEXT NULL,
|
||||||
|
KEY idx_icon_scanner_runs_started (started_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import searchRoutes from './routes/search.js';
|
|||||||
import networkRoutes from './routes/network.js';
|
import networkRoutes from './routes/network.js';
|
||||||
import connectionRoutes from './routes/connections.js';
|
import connectionRoutes from './routes/connections.js';
|
||||||
import dashboardRoutes from './routes/dashboard.js';
|
import dashboardRoutes from './routes/dashboard.js';
|
||||||
|
import scannerRoutes from './routes/scanner.js';
|
||||||
|
import { startScheduler } from './scanner/scheduler.js';
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = Number(process.env.PORT || 5007);
|
const PORT = Number(process.env.PORT || 5007);
|
||||||
@@ -71,6 +73,8 @@ app.use('/api/search', searchRoutes);
|
|||||||
app.use('/api/network', networkRoutes);
|
app.use('/api/network', networkRoutes);
|
||||||
app.use('/api/connections', connectionRoutes);
|
app.use('/api/connections', connectionRoutes);
|
||||||
app.use('/api/dashboard', dashboardRoutes);
|
app.use('/api/dashboard', dashboardRoutes);
|
||||||
|
// Stellenanzeigen aus Jobbörsen - Suchagenten, Treffer, Zeitplan.
|
||||||
|
app.use('/api/scanner', scannerRoutes);
|
||||||
|
|
||||||
app.use('/api', (req, res) => res.status(404).json({ error: 'Endpunkt nicht gefunden.' }));
|
app.use('/api', (req, res) => res.status(404).json({ error: 'Endpunkt nicht gefunden.' }));
|
||||||
|
|
||||||
@@ -109,6 +113,7 @@ try {
|
|||||||
app.listen(PORT, HOST, () => {
|
app.listen(PORT, HOST, () => {
|
||||||
console.log(`[ICON CRM] Backend läuft auf http://${HOST}:${PORT}`);
|
console.log(`[ICON CRM] Backend läuft auf http://${HOST}:${PORT}`);
|
||||||
});
|
});
|
||||||
|
startScheduler();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[ICON CRM] Start fehlgeschlagen:', err);
|
console.error('[ICON CRM] Start fehlgeschlagen:', err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import { Tenders } from './components/tenders/Tenders';
|
|||||||
import { LiveBoard } from './components/tenders/LiveBoard';
|
import { LiveBoard } from './components/tenders/LiveBoard';
|
||||||
import { Projects } from './components/projects/Projects';
|
import { Projects } from './components/projects/Projects';
|
||||||
import { History } from './components/history/History';
|
import { History } from './components/history/History';
|
||||||
|
import { ScannerDashboard } from './components/scanner/ScannerDashboard';
|
||||||
|
import { ScannerAgents } from './components/scanner/ScannerAgents';
|
||||||
import { Settings } from './components/settings/Settings';
|
import { Settings } from './components/settings/Settings';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -169,6 +171,10 @@ export const App: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{view === 'Scanner' && <ScannerDashboard />}
|
||||||
|
|
||||||
|
{view === 'Scanner · Suchagenten' && <ScannerAgents />}
|
||||||
|
|
||||||
{view === 'Historie' && <History users={users} />}
|
{view === 'Historie' && <History users={users} />}
|
||||||
|
|
||||||
{view === 'Einstellungen' && (
|
{view === 'Einstellungen' && (
|
||||||
|
|||||||
@@ -71,6 +71,13 @@ export const Sidebar: React.FC<SidebarProps> = ({ view, onSelect, counts, collap
|
|||||||
badge: 'projects_active',
|
badge: 'projects_active',
|
||||||
icon: icon('M3 6h7l2 2h9v11H3V6Zm0-2h8l2 2H3V4Z'),
|
icon: icon('M3 6h7l2 2h9v11H3V6Zm0-2h8l2 2H3V4Z'),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
view: 'Scanner',
|
||||||
|
label: 'Scanner',
|
||||||
|
badge: 'scanner_new',
|
||||||
|
icon: icon('M10 3a7 7 0 0 1 5.6 11.2l5.6 5.6-1.4 1.4-5.6-5.6A7 7 0 1 1 10 3Zm0 2a5 5 0 1 0 0 10 5 5 0 0 0 0-10Z'),
|
||||||
|
children: [{ view: 'Scanner · Suchagenten', label: 'Suchagenten' }],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
view: 'Historie',
|
view: 'Historie',
|
||||||
label: 'Historie',
|
label: 'Historie',
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
scannerApi,
|
||||||
|
type ScannerAgent,
|
||||||
|
type ScannerAgentInput,
|
||||||
|
type ScannerOverview,
|
||||||
|
type ScannerSourceId,
|
||||||
|
} from '../../lib/api';
|
||||||
|
import { useAuth } from '../../lib/auth';
|
||||||
|
import { useNavigation } from '../../lib/navigation';
|
||||||
|
import { useToast } from '../Toast';
|
||||||
|
import { ConfirmDialog, Modal } from '../ui/Modal';
|
||||||
|
import { Field, PrimaryButton, SecondaryButton, inputClass } from '../ui/form';
|
||||||
|
import { SOURCE_LABELS, SOURCE_ORDER, TermInput } from './scannerUi';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Scanner -> Suchagenten.
|
||||||
|
*
|
||||||
|
* Ein Agent = Schlagwörter + Börsen + Ausschlussbegriffe. Jedes Schlagwort
|
||||||
|
* wird in jeder gewählten Börse einzeln gesucht; eine Anzeige, die einen
|
||||||
|
* Ausschlussbegriff enthält, wird gar nicht erst gespeichert.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const EMPTY: ScannerAgentInput = {
|
||||||
|
name: '',
|
||||||
|
keywords: [],
|
||||||
|
sources: ['ba', 'stepstone', 'karriere'],
|
||||||
|
excludes: [],
|
||||||
|
is_active: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ScannerAgents: React.FC = () => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
const { goToSettings } = useNavigation();
|
||||||
|
const [agents, setAgents] = useState<ScannerAgent[] | null>(null);
|
||||||
|
const [sources, setSources] = useState<ScannerOverview['sources']>([]);
|
||||||
|
const [editing, setEditing] = useState<{ id: number | null; value: ScannerAgentInput } | null>(null);
|
||||||
|
const [removing, setRemoving] = useState<ScannerAgent | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const [{ agents: list }, overview] = await Promise.all([scannerApi.agents(), scannerApi.overview()]);
|
||||||
|
setAgents(list);
|
||||||
|
setSources(overview.sources);
|
||||||
|
} catch (err) {
|
||||||
|
toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const ready = (id: ScannerSourceId) => sources.find((item) => item.id === id)?.ready ?? true;
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!editing) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
if (editing.id) await scannerApi.updateAgent(editing.id, editing.value);
|
||||||
|
else await scannerApi.createAgent(editing.value);
|
||||||
|
toast('Suchagent gespeichert – er sucht ab dem nächsten Lauf mit.', 'success');
|
||||||
|
setEditing(null);
|
||||||
|
void load();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = async (agent: ScannerAgent) => {
|
||||||
|
try {
|
||||||
|
await scannerApi.updateAgent(agent.id, { ...agent, is_active: !agent.is_active });
|
||||||
|
void load();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err instanceof Error ? err.message : 'Ändern fehlgeschlagen.', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (!removing) return;
|
||||||
|
try {
|
||||||
|
await scannerApi.removeAgent(removing.id);
|
||||||
|
toast('Suchagent gelöscht.', 'success');
|
||||||
|
setRemoving(null);
|
||||||
|
void load();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err instanceof Error ? err.message : 'Löschen fehlgeschlagen.', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const value = editing?.value;
|
||||||
|
const patch = (changes: Partial<ScannerAgentInput>) =>
|
||||||
|
setEditing((current) => (current ? { ...current, value: { ...current.value, ...changes } } : current));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p className="max-w-3xl text-sm text-ink-500">
|
||||||
|
Jeder Agent sucht seine Schlagwörter in den gewählten Börsen. Je mehr Schlagwörter in einer Anzeige
|
||||||
|
vorkommen, desto höher die Quote – ein Treffer im Titel zählt doppelt. Anzeigen mit einem
|
||||||
|
Ausschlussbegriff fallen raus.
|
||||||
|
</p>
|
||||||
|
<PrimaryButton type="button" onClick={() => setEditing({ id: null, value: EMPTY })}>
|
||||||
|
+ Neuer Suchagent
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{agents === null ? (
|
||||||
|
<p className="py-16 text-center text-sm text-ink-400">Wird geladen …</p>
|
||||||
|
) : agents.length === 0 ? (
|
||||||
|
<p className="rounded-xl border border-dashed border-ink-200 py-16 text-center text-sm text-ink-400">
|
||||||
|
Noch keine Suchagenten.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{agents.map((agent) => (
|
||||||
|
<article
|
||||||
|
key={agent.id}
|
||||||
|
className={`rounded-xl border border-ink-200 bg-surface p-5 ${agent.is_active ? '' : 'opacity-60'}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-ink-900">{agent.name}</h3>
|
||||||
|
<p className="text-xs text-ink-400">
|
||||||
|
{agent.is_active ? 'aktiv' : 'pausiert'} · {agent.matches} Treffer
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<SecondaryButton type="button" className="px-3 py-1 text-xs" onClick={() => void toggle(agent)}>
|
||||||
|
{agent.is_active ? 'Pausieren' : 'Aktivieren'}
|
||||||
|
</SecondaryButton>
|
||||||
|
<SecondaryButton
|
||||||
|
type="button"
|
||||||
|
className="px-3 py-1 text-xs"
|
||||||
|
onClick={() =>
|
||||||
|
setEditing({
|
||||||
|
id: agent.id,
|
||||||
|
value: {
|
||||||
|
name: agent.name,
|
||||||
|
keywords: agent.keywords,
|
||||||
|
sources: agent.sources,
|
||||||
|
excludes: agent.excludes,
|
||||||
|
is_active: agent.is_active,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</SecondaryButton>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRemoving(agent)}
|
||||||
|
className="rounded-lg px-2 text-xs text-red-600 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Row label="Schlagwörter">
|
||||||
|
{agent.keywords.map((term) => (
|
||||||
|
<span key={term} className="rounded-full border border-brand-200 bg-brand-50 px-2 py-0.5 text-xs text-brand-700">
|
||||||
|
{term}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
<Row label="Börsen">
|
||||||
|
{agent.sources.map((id) => (
|
||||||
|
<span
|
||||||
|
key={id}
|
||||||
|
title={ready(id) ? undefined : 'Schlüssel fehlt – Börse wird übersprungen'}
|
||||||
|
className={`rounded-full border px-2 py-0.5 text-xs ${
|
||||||
|
ready(id) ? 'border-ink-200 bg-ink-50 text-ink-600' : 'border-amber-200 bg-amber-50 text-amber-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{SOURCE_LABELS[id] ?? id}
|
||||||
|
{!ready(id) && ' ⚠'}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
{agent.excludes.length > 0 && (
|
||||||
|
<Row label="Ausschluss">
|
||||||
|
{agent.excludes.map((term) => (
|
||||||
|
<span key={term} className="rounded-full border border-red-200 bg-red-50 px-2 py-0.5 text-xs text-red-700">
|
||||||
|
{term}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={Boolean(editing)}
|
||||||
|
onClose={() => setEditing(null)}
|
||||||
|
size="md"
|
||||||
|
title={editing?.id ? 'Suchagent bearbeiten' : 'Neuer Suchagent'}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<SecondaryButton type="button" onClick={() => setEditing(null)}>
|
||||||
|
Abbrechen
|
||||||
|
</SecondaryButton>
|
||||||
|
<PrimaryButton
|
||||||
|
type="button"
|
||||||
|
disabled={busy || !value?.name.trim() || !value?.keywords.length || !value?.sources.length}
|
||||||
|
onClick={() => void save()}
|
||||||
|
>
|
||||||
|
Speichern
|
||||||
|
</PrimaryButton>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{value && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<Field label="Name" hint="Z. B. die Rolle oder der Berater, für den gesucht wird.">
|
||||||
|
<input
|
||||||
|
value={value.name}
|
||||||
|
onChange={(event) => patch({ name: event.target.value })}
|
||||||
|
placeholder="Interim CFO"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Schlagwörter" hint="Enter oder Komma trennt. Jedes Schlagwort ist eine eigene Suche je Börse.">
|
||||||
|
<TermInput
|
||||||
|
value={value.keywords}
|
||||||
|
onChange={(keywords) => patch({ keywords })}
|
||||||
|
placeholder="z. B. Interim CFO, Finanzleiter, SAP FI"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Ausschlussbegriffe" hint="Kommt einer davon in Titel oder Text vor, wird die Anzeige ignoriert.">
|
||||||
|
<TermInput
|
||||||
|
value={value.excludes}
|
||||||
|
onChange={(excludes) => patch({ excludes })}
|
||||||
|
placeholder="z. B. Praktikum, Werkstudent, Ausbildung"
|
||||||
|
tone="red"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="field-label mb-1 block text-xs font-semibold uppercase tracking-wide text-ink-500">Börsen</p>
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2">
|
||||||
|
{SOURCE_ORDER.map((id) => {
|
||||||
|
const info = sources.find((item) => item.id === id);
|
||||||
|
const checked = value.sources.includes(id);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={id}
|
||||||
|
className={`flex cursor-pointer items-start gap-2 rounded-lg border px-3 py-2 text-sm ${
|
||||||
|
checked ? 'border-brand-400 bg-brand-50' : 'border-ink-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
className="mt-0.5"
|
||||||
|
onChange={() =>
|
||||||
|
patch({
|
||||||
|
sources: checked ? value.sources.filter((item) => item !== id) : [...value.sources, id],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="font-medium text-ink-900">{SOURCE_LABELS[id]}</span>
|
||||||
|
<span className="block text-xs text-ink-400">
|
||||||
|
{info?.countries}
|
||||||
|
{info && !info.ready && ' · Schlüssel fehlt'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{sources.some((item) => !item.ready) && (
|
||||||
|
<p className="mt-2 text-xs text-ink-400">
|
||||||
|
Adzuna und Jooble brauchen einen kostenlosen API-Schlüssel
|
||||||
|
{isAdmin ? (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
–{' '}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-brand-600 hover:underline"
|
||||||
|
onClick={() => {
|
||||||
|
setEditing(null);
|
||||||
|
goToSettings('scanner');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Einstellungen → Scanner
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
' (Administrator: Einstellungen → Scanner)'
|
||||||
|
)}
|
||||||
|
. Ohne Schlüssel werden sie übersprungen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-sm text-ink-700">
|
||||||
|
<input type="checkbox" checked={value.is_active} onChange={(event) => patch({ is_active: event.target.checked })} />
|
||||||
|
aktiv
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(removing)}
|
||||||
|
title="Suchagent löschen?"
|
||||||
|
message={`„${removing?.name}" und seine ${removing?.matches ?? 0} Treffer werden gelöscht.`}
|
||||||
|
onConfirm={() => void remove()}
|
||||||
|
onCancel={() => setRemoving(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Row: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
|
||||||
|
<div className="mt-3">
|
||||||
|
<p className="mb-1 text-[11px] font-semibold uppercase tracking-wider text-ink-400">{label}</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
scannerApi,
|
||||||
|
type ScannerAgent,
|
||||||
|
type ScannerMatch,
|
||||||
|
type ScannerMatchStatus,
|
||||||
|
type ScannerOverview,
|
||||||
|
} from '../../lib/api';
|
||||||
|
import { useAuth } from '../../lib/auth';
|
||||||
|
import { useNavigation } from '../../lib/navigation';
|
||||||
|
import { useToast } from '../Toast';
|
||||||
|
import { Modal } from '../ui/Modal';
|
||||||
|
import { PrimaryButton, SecondaryButton, inputClass } from '../ui/form';
|
||||||
|
import {
|
||||||
|
SOURCE_LABELS,
|
||||||
|
SOURCE_ORDER,
|
||||||
|
STATUS_LABELS,
|
||||||
|
ScoreBox,
|
||||||
|
StatusBadge,
|
||||||
|
WEEKDAY_LABELS,
|
||||||
|
formatAge,
|
||||||
|
formatDateTime,
|
||||||
|
} from './scannerUi';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Scanner -> Dashboard.
|
||||||
|
*
|
||||||
|
* Kennzahlen, Zustand der Börsen und die Trefferliste. Vorbild ist das
|
||||||
|
* Dashboard des perinco-Scanners; statt Lebenslauf-Abgleich zeigt die Quote,
|
||||||
|
* wie viele Schlagwörter des Agenten in der Anzeige stehen.
|
||||||
|
*
|
||||||
|
* Treffer bei Unternehmen, die schon im CRM stehen, stehen oben und tragen
|
||||||
|
* ein Abzeichen - das ist der eigentliche Wert: eine offene Stelle bei einem
|
||||||
|
* Kunden oder bekannten Unternehmen ist ein Anlass anzurufen.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PERIODS = [
|
||||||
|
{ value: 24, label: 'letzte 24 Stunden' },
|
||||||
|
{ value: 72, label: 'letzte 3 Tage' },
|
||||||
|
{ value: 168, label: 'letzte 7 Tage' },
|
||||||
|
{ value: 504, label: 'letzte 21 Tage' },
|
||||||
|
{ value: 0, label: 'alle' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const POLL_MS = 30_000;
|
||||||
|
const POLL_RUNNING_MS = 5_000;
|
||||||
|
|
||||||
|
export const ScannerDashboard: React.FC = () => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
const { open, goToSettings } = useNavigation();
|
||||||
|
|
||||||
|
const [overview, setOverview] = useState<ScannerOverview | null>(null);
|
||||||
|
const [agents, setAgents] = useState<ScannerAgent[]>([]);
|
||||||
|
const [matches, setMatches] = useState<ScannerMatch[] | null>(null);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [detail, setDetail] = useState<ScannerMatch | null>(null);
|
||||||
|
|
||||||
|
const [hours, setHours] = useState(168);
|
||||||
|
const [agentId, setAgentId] = useState('');
|
||||||
|
const [source, setSource] = useState('');
|
||||||
|
const [minScore, setMinScore] = useState(0);
|
||||||
|
const [knownOnly, setKnownOnly] = useState(false);
|
||||||
|
const [showDiscarded, setShowDiscarded] = useState(false);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [sort, setSort] = useState<'known' | 'score' | 'published'>('known');
|
||||||
|
|
||||||
|
const wasRunning = useRef(false);
|
||||||
|
|
||||||
|
const loadMatches = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await scannerApi.matches({
|
||||||
|
hours: hours || undefined,
|
||||||
|
agentId: agentId || undefined,
|
||||||
|
source: source || undefined,
|
||||||
|
minScore: minScore || undefined,
|
||||||
|
known: knownOnly ? 1 : undefined,
|
||||||
|
status: showDiscarded ? 'alle' : undefined,
|
||||||
|
q: search.trim() || undefined,
|
||||||
|
sort,
|
||||||
|
});
|
||||||
|
setMatches(data.matches);
|
||||||
|
setTotal(data.total);
|
||||||
|
} catch (err) {
|
||||||
|
toast(err instanceof Error ? err.message : 'Treffer nicht ladbar.', 'error');
|
||||||
|
}
|
||||||
|
}, [hours, agentId, source, minScore, knownOnly, showDiscarded, search, sort, toast]);
|
||||||
|
|
||||||
|
const loadOverview = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await scannerApi.overview();
|
||||||
|
setOverview(data);
|
||||||
|
// Lauf gerade fertig geworden -> Liste auffrischen.
|
||||||
|
if (wasRunning.current && !data.status.running) {
|
||||||
|
const run = data.lastRun;
|
||||||
|
toast(
|
||||||
|
run ? `Suchlauf fertig: ${run.jobs_found} Anzeigen, ${run.matches_new} neue Treffer.` : 'Suchlauf fertig.',
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
void loadMatches();
|
||||||
|
}
|
||||||
|
wasRunning.current = data.status.running;
|
||||||
|
} catch {
|
||||||
|
/* Übersicht ist Beiwerk - die Liste meldet eigene Fehler. */
|
||||||
|
}
|
||||||
|
}, [loadMatches, toast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void loadOverview();
|
||||||
|
scannerApi
|
||||||
|
.agents()
|
||||||
|
.then((data) => setAgents(data.agents))
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, [loadOverview]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handle = setTimeout(() => void loadMatches(), search ? 300 : 0);
|
||||||
|
return () => clearTimeout(handle);
|
||||||
|
}, [loadMatches, search]);
|
||||||
|
|
||||||
|
const running = overview?.status.running ?? false;
|
||||||
|
useEffect(() => {
|
||||||
|
const handle = setInterval(() => void loadOverview(), running ? POLL_RUNNING_MS : POLL_MS);
|
||||||
|
return () => clearInterval(handle);
|
||||||
|
}, [loadOverview, running]);
|
||||||
|
|
||||||
|
const startScan = async () => {
|
||||||
|
try {
|
||||||
|
await scannerApi.scan();
|
||||||
|
wasRunning.current = true;
|
||||||
|
toast('Suchlauf gestartet – das dauert ein paar Minuten.', 'success');
|
||||||
|
void loadOverview();
|
||||||
|
} catch (err) {
|
||||||
|
toast(err instanceof Error ? err.message : 'Start fehlgeschlagen.', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setStatus = async (match: ScannerMatch, status: ScannerMatchStatus) => {
|
||||||
|
try {
|
||||||
|
await scannerApi.setStatus(match.id, status);
|
||||||
|
setMatches((list) => list?.map((item) => (item.id === match.id ? { ...item, status } : item)) ?? null);
|
||||||
|
setDetail((current) => (current?.id === match.id ? { ...current, status } : current));
|
||||||
|
} catch (err) {
|
||||||
|
toast(err instanceof Error ? err.message : 'Status nicht gespeichert.', 'error');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openDetail = (match: ScannerMatch) => {
|
||||||
|
setDetail(match);
|
||||||
|
if (match.status === 'neu') void setStatus(match, 'gesehen');
|
||||||
|
};
|
||||||
|
|
||||||
|
const readySources = overview?.sources.filter((item) => item.ready).length ?? 0;
|
||||||
|
const failing = overview?.sources.filter((item) => item.last && !item.last.ok) ?? [];
|
||||||
|
|
||||||
|
const scheduleText = useMemo(() => {
|
||||||
|
if (!overview) return '';
|
||||||
|
const s = overview.schedule;
|
||||||
|
if (!s.active) return 'Automatische Suche aus';
|
||||||
|
const days = s.days.map((d) => WEEKDAY_LABELS[d - 1]).join(', ');
|
||||||
|
const every = s.interval % 60 === 0 ? `${s.interval / 60 === 1 ? 'stündlich' : `alle ${s.interval / 60} h`}` : `alle ${s.interval} min`;
|
||||||
|
return `${every} · ${days} · ${s.from}–${s.to} Uhr`;
|
||||||
|
}, [overview]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{/* Kopf */}
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-ink-500">
|
||||||
|
Offene Stellen aus Jobbörsen in DACH, höchstens {overview?.schedule.maxAge ?? 21} Tage alt.
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1.5 rounded border px-2 py-0.5 font-bold uppercase tracking-wide ${
|
||||||
|
overview?.schedule.active
|
||||||
|
? 'border-emerald-300 bg-emerald-50 text-emerald-700'
|
||||||
|
: 'border-ink-200 bg-ink-50 text-ink-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`h-1.5 w-1.5 rounded-full ${overview?.schedule.active ? 'bg-emerald-500' : 'bg-ink-300'}`} />
|
||||||
|
{overview?.schedule.active ? 'Automatische Suche aktiv' : 'Automatische Suche aus'}
|
||||||
|
</span>
|
||||||
|
<span className="text-ink-500">{overview?.schedule.active ? scheduleText : ''}</span>
|
||||||
|
{overview?.lastScanAt && (
|
||||||
|
<span className="text-ink-400">· letzter Lauf {formatDateTime(overview.lastScanAt)}</span>
|
||||||
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<button type="button" onClick={() => goToSettings('scanner')} className="text-brand-600 hover:underline">
|
||||||
|
Zeitplan ändern
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<PrimaryButton type="button" onClick={() => void startScan()} disabled={running} className="flex items-center gap-2">
|
||||||
|
<svg viewBox="0 0 24 24" className={`h-4 w-4 ${running ? 'animate-spin' : ''}`} fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M12 4a8 8 0 0 1 7.4 5H17v2h6V5h-2v2.3A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8Zm8 8a8 8 0 0 1-15.4 3H7v-2H1v6h2v-2.3A10 10 0 0 0 22 12h-2Z" />
|
||||||
|
</svg>
|
||||||
|
{running ? 'Suche läuft …' : 'Jetzt scannen'}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Kennzahlen */}
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<Kpi
|
||||||
|
value={overview?.stats.agents ?? '–'}
|
||||||
|
label="Aktive Agenten"
|
||||||
|
icon="M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 9v-1c0-3 3-5 7-5s7 2 7 5v1H5Z"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
value={overview ? `${readySources} / ${overview.sources.length}` : '–'}
|
||||||
|
label="Börsen bereit"
|
||||||
|
icon="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm6.9 6h-3a15 15 0 0 0-1.3-3.9A8 8 0 0 1 18.9 8ZM12 4c.8 1.2 1.5 2.5 1.9 4h-3.8c.4-1.5 1.1-2.8 1.9-4ZM4.3 14a8 8 0 0 1 0-4h3.4a17 17 0 0 0 0 4H4.3Zm.8 2h3a15 15 0 0 0 1.3 3.9A8 8 0 0 1 5.1 16Zm3-8h-3a8 8 0 0 1 4.3-3.9C8.9 5.3 8.4 6.6 8.1 8ZM12 20c-.8-1.2-1.5-2.5-1.9-4h3.8c-.4 1.5-1.1 2.8-1.9 4Zm2.3-6H9.7a15 15 0 0 1 0-4h4.6a15 15 0 0 1 0 4Zm.3 5.9c.6-1.2 1-2.5 1.3-3.9h3a8 8 0 0 1-4.3 3.9Zm1.7-5.9a17 17 0 0 0 0-4h3.4a8 8 0 0 1 0 4h-3.4Z"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
value={overview?.stats.jobs ?? '–'}
|
||||||
|
label="Gefundene Anzeigen"
|
||||||
|
icon="M4 4h16v4H4V4Zm0 6h16v10H4V10Zm3 3v2h10v-2H7Z"
|
||||||
|
/>
|
||||||
|
<Kpi
|
||||||
|
value={overview?.stats.matches ?? '–'}
|
||||||
|
label={`Treffer · ${overview?.stats.known ?? 0} bei bekannten Firmen`}
|
||||||
|
icon="M12 2 3 7v10l9 5 9-5V7l-9-5Zm-1 14-4-4 1.4-1.4 2.6 2.6 5.6-5.6L18 9l-7 7Z"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{failing.length > 0 && (
|
||||||
|
<p className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-800">
|
||||||
|
Im letzten Lauf ohne Ergebnis: {failing.map((item) => item.label).join(', ')} –{' '}
|
||||||
|
{failing[0].last?.errors[0] ?? 'unbekannter Fehler'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Trefferliste */}
|
||||||
|
<section className="rounded-xl border border-ink-200 bg-surface">
|
||||||
|
<div className="flex flex-wrap items-end gap-3 border-b border-ink-200 p-4">
|
||||||
|
<h2 className="mr-auto text-sm font-bold uppercase tracking-wider text-ink-900">Stellen-Treffer</h2>
|
||||||
|
|
||||||
|
<Filter label="Zeitraum">
|
||||||
|
<select value={hours} onChange={(event) => setHours(Number(event.target.value))} className={inputClass}>
|
||||||
|
{PERIODS.map((period) => (
|
||||||
|
<option key={period.value} value={period.value}>
|
||||||
|
{period.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Filter>
|
||||||
|
<Filter label="Agent">
|
||||||
|
<select value={agentId} onChange={(event) => setAgentId(event.target.value)} className={inputClass}>
|
||||||
|
<option value="">alle</option>
|
||||||
|
{agents.map((agent) => (
|
||||||
|
<option key={agent.id} value={agent.id}>
|
||||||
|
{agent.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Filter>
|
||||||
|
<Filter label="Börse">
|
||||||
|
<select value={source} onChange={(event) => setSource(event.target.value)} className={inputClass}>
|
||||||
|
<option value="">alle</option>
|
||||||
|
{SOURCE_ORDER.map((id) => (
|
||||||
|
<option key={id} value={id}>
|
||||||
|
{SOURCE_LABELS[id]}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Filter>
|
||||||
|
<Filter label="Ab %">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step={5}
|
||||||
|
value={minScore}
|
||||||
|
onChange={(event) => setMinScore(Number(event.target.value) || 0)}
|
||||||
|
className={`${inputClass} w-20`}
|
||||||
|
/>
|
||||||
|
</Filter>
|
||||||
|
<Filter label="Sortierung">
|
||||||
|
<select value={sort} onChange={(event) => setSort(event.target.value as typeof sort)} className={inputClass}>
|
||||||
|
<option value="known">bekannte Firmen zuerst</option>
|
||||||
|
<option value="score">beste Quote</option>
|
||||||
|
<option value="published">neueste Anzeige</option>
|
||||||
|
</select>
|
||||||
|
</Filter>
|
||||||
|
<Filter label="Suche">
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={(event) => setSearch(event.target.value)}
|
||||||
|
placeholder="Titel, Firma, Ort"
|
||||||
|
className={`${inputClass} w-44`}
|
||||||
|
/>
|
||||||
|
</Filter>
|
||||||
|
<div className="flex flex-col gap-1 pb-1 text-sm text-ink-600">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" checked={knownOnly} onChange={(event) => setKnownOnly(event.target.checked)} />
|
||||||
|
nur bekannte Firmen
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" checked={showDiscarded} onChange={(event) => setShowDiscarded(event.target.checked)} />
|
||||||
|
verworfene zeigen
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<span className="pb-2 text-xs text-ink-400">
|
||||||
|
{matches?.length ?? 0} / {total}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{matches === null ? (
|
||||||
|
<p className="py-16 text-center text-sm text-ink-400">Wird geladen …</p>
|
||||||
|
) : matches.length === 0 ? (
|
||||||
|
<div className="py-16 text-center text-sm text-ink-400">
|
||||||
|
{agents.length === 0 ? (
|
||||||
|
<>Noch keine Suchagenten angelegt – unter <em>Scanner → Suchagenten</em> geht es los.</>
|
||||||
|
) : (
|
||||||
|
'Keine Treffer für diese Auswahl.'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-ink-200 text-left text-[11px] font-semibold uppercase tracking-wider text-ink-500">
|
||||||
|
<th className="px-4 py-3">Match</th>
|
||||||
|
<th className="px-4 py-3">Agent</th>
|
||||||
|
<th className="px-4 py-3">Stelle</th>
|
||||||
|
<th className="px-4 py-3">Unternehmen</th>
|
||||||
|
<th className="px-4 py-3">Börse</th>
|
||||||
|
<th className="px-4 py-3">Status</th>
|
||||||
|
<th className="px-4 py-3">Gefunden am</th>
|
||||||
|
<th className="px-4 py-3 text-right">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{matches.map((match) => (
|
||||||
|
<tr
|
||||||
|
key={match.id}
|
||||||
|
className={`border-b border-ink-100 last:border-0 hover:bg-ink-50 ${
|
||||||
|
match.known ? 'bg-brand-50/40' : ''
|
||||||
|
} ${match.status === 'verworfen' ? 'opacity-60' : ''}`}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<ScoreBox score={match.score} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-semibold text-ink-900">{match.agent_name}</td>
|
||||||
|
<td className="max-w-md px-4 py-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openDetail(match)}
|
||||||
|
className="text-left font-semibold text-ink-900 hover:text-brand-700"
|
||||||
|
>
|
||||||
|
{match.title}
|
||||||
|
</button>
|
||||||
|
<p className="mt-0.5 text-xs text-ink-500">
|
||||||
|
{[match.location, match.country, match.employment_type].filter(Boolean).join(' · ') || '–'}
|
||||||
|
<span className="text-ink-400"> · {formatAge(match.published_at)}</span>
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="text-ink-800">{match.company ?? '–'}</span>
|
||||||
|
{match.known && match.company_id && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => open('company', match.company_id!)}
|
||||||
|
title={`Im CRM: ${match.company_name}`}
|
||||||
|
className={`ml-2 inline-flex rounded border px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wide ${
|
||||||
|
match.is_customer
|
||||||
|
? 'border-emerald-300 bg-emerald-50 text-emerald-700'
|
||||||
|
: 'border-brand-300 bg-brand-50 text-brand-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{match.is_customer ? 'Kunde' : 'Bekannt'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-ink-600">{SOURCE_LABELS[match.source] ?? match.source}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<StatusBadge status={match.status} />
|
||||||
|
</td>
|
||||||
|
<td className="whitespace-nowrap px-4 py-3 text-ink-500">{formatDateTime(match.created_at)}</td>
|
||||||
|
<td className="px-4 py-3 text-right">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openDetail(match)}
|
||||||
|
className="rounded-md border border-ink-200 px-3 py-1 text-xs font-semibold text-ink-700 hover:bg-ink-100"
|
||||||
|
>
|
||||||
|
Details
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<MatchDetail
|
||||||
|
match={detail}
|
||||||
|
onClose={() => setDetail(null)}
|
||||||
|
onStatus={(status) => detail && void setStatus(detail, status)}
|
||||||
|
onOpenCompany={(id) => {
|
||||||
|
setDetail(null);
|
||||||
|
open('company', id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Kpi: React.FC<{ value: React.ReactNode; label: string; icon: string }> = ({ value, label, icon }) => (
|
||||||
|
<div className="flex items-center gap-4 rounded-xl border border-ink-200 bg-surface p-5">
|
||||||
|
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-brand-50 text-brand-600">
|
||||||
|
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d={icon} />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-2xl font-bold tabular-nums text-ink-900">{value}</p>
|
||||||
|
<p className="truncate text-[11px] font-semibold uppercase tracking-wider text-ink-500">{label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const Filter: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-ink-500">{label}</span>
|
||||||
|
{children}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Schlagwörter im Text hervorheben. */
|
||||||
|
function highlight(text: string, terms: string[]): React.ReactNode {
|
||||||
|
if (!text || terms.length === 0) return text;
|
||||||
|
const pattern = new RegExp(`(${terms.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})`, 'gi');
|
||||||
|
return text.split(pattern).map((part, index) =>
|
||||||
|
index % 2 === 1 ? (
|
||||||
|
<mark key={index} className="rounded bg-brand-100 px-0.5 text-brand-800">
|
||||||
|
{part}
|
||||||
|
</mark>
|
||||||
|
) : (
|
||||||
|
part
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MatchDetail: React.FC<{
|
||||||
|
match: ScannerMatch | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onStatus: (status: ScannerMatchStatus) => void;
|
||||||
|
onOpenCompany: (id: number) => void;
|
||||||
|
}> = ({ match, onClose, onStatus, onOpenCompany }) => (
|
||||||
|
<Modal
|
||||||
|
open={Boolean(match)}
|
||||||
|
onClose={onClose}
|
||||||
|
size="lg"
|
||||||
|
title={match?.title ?? ''}
|
||||||
|
subtitle={
|
||||||
|
match
|
||||||
|
? [match.company, match.location, match.country, SOURCE_LABELS[match.source]].filter(Boolean).join(' · ')
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
footer={
|
||||||
|
match && (
|
||||||
|
<>
|
||||||
|
<SecondaryButton type="button" onClick={onClose}>
|
||||||
|
Schließen
|
||||||
|
</SecondaryButton>
|
||||||
|
<a
|
||||||
|
href={match.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rounded-lg bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700"
|
||||||
|
>
|
||||||
|
Anzeige öffnen ↗
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{match && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<ScoreBox score={match.score} />
|
||||||
|
<div className="text-sm text-ink-600">
|
||||||
|
Agent <strong className="text-ink-900">{match.agent_name}</strong> · veröffentlicht{' '}
|
||||||
|
{formatAge(match.published_at)} · gefunden {formatDateTime(match.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-ink-500">Gefundene Schlagwörter</p>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{match.hits.map((hit) => (
|
||||||
|
<span key={hit} className="rounded-full border border-brand-200 bg-brand-50 px-2 py-0.5 text-xs font-medium text-brand-700">
|
||||||
|
{hit}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{match.known && match.company_id && (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-brand-200 bg-brand-50 px-4 py-3 text-sm">
|
||||||
|
<span className="text-brand-800">
|
||||||
|
<strong>{match.company_name}</strong> steht bereits im CRM
|
||||||
|
{match.is_customer ? ' – als Kunde.' : '.'}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpenCompany(match.company_id!)}
|
||||||
|
className="font-semibold text-brand-700 hover:underline"
|
||||||
|
>
|
||||||
|
Unternehmen öffnen →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-ink-500">Auszug</p>
|
||||||
|
<p className="whitespace-pre-line text-sm leading-relaxed text-ink-700">
|
||||||
|
{match.description ? highlight(match.description, match.hits) : 'Die Börse liefert in der Trefferliste keinen Text – Details in der Anzeige.'}
|
||||||
|
</p>
|
||||||
|
{match.employment_type && <p className="mt-2 text-xs text-ink-500">Art: {match.employment_type}</p>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-ink-500">Status</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(Object.keys(STATUS_LABELS) as ScannerMatchStatus[]).map((status) => (
|
||||||
|
<button
|
||||||
|
key={status}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onStatus(status)}
|
||||||
|
className={`rounded-lg border px-3 py-1.5 text-sm ${
|
||||||
|
match.status === status
|
||||||
|
? 'border-brand-500 bg-brand-50 font-semibold text-brand-700'
|
||||||
|
: 'border-ink-200 text-ink-600 hover:bg-ink-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{STATUS_LABELS[status]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { ScannerMatchStatus, ScannerSourceId } from '../../lib/api';
|
||||||
|
|
||||||
|
/* Gemeinsame Beschriftungen und Bausteine von Dashboard und Suchagenten. */
|
||||||
|
|
||||||
|
export const SOURCE_LABELS: Record<ScannerSourceId, string> = {
|
||||||
|
ba: 'Agentur für Arbeit',
|
||||||
|
stepstone: 'StepStone',
|
||||||
|
karriere: 'karriere.at',
|
||||||
|
adzuna: 'Adzuna',
|
||||||
|
jooble: 'Jooble',
|
||||||
|
freelancermap: 'freelancermap',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SOURCE_ORDER: ScannerSourceId[] = ['ba', 'stepstone', 'karriere', 'adzuna', 'jooble', 'freelancermap'];
|
||||||
|
|
||||||
|
export const STATUS_LABELS: Record<ScannerMatchStatus, string> = {
|
||||||
|
neu: 'Neu',
|
||||||
|
gesehen: 'Gesehen',
|
||||||
|
in_bearbeitung: 'In Bearbeitung',
|
||||||
|
verworfen: 'Verworfen',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATUS_TONES: Record<ScannerMatchStatus, string> = {
|
||||||
|
neu: 'border-sky-200 bg-sky-50 text-sky-700',
|
||||||
|
gesehen: 'border-amber-200 bg-amber-50 text-amber-700',
|
||||||
|
in_bearbeitung: 'border-emerald-200 bg-emerald-50 text-emerald-700',
|
||||||
|
verworfen: 'border-ink-200 bg-ink-50 text-ink-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const StatusBadge: React.FC<{ status: ScannerMatchStatus }> = ({ status }) => (
|
||||||
|
<span
|
||||||
|
className={`inline-flex rounded border px-1.5 py-0.5 text-[11px] font-bold uppercase tracking-wide ${STATUS_TONES[status]}`}
|
||||||
|
>
|
||||||
|
{STATUS_LABELS[status]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Quadrat mit der Trefferquote - Farbe nach Güte. */
|
||||||
|
export const ScoreBox: React.FC<{ score: number }> = ({ score }) => {
|
||||||
|
const tone =
|
||||||
|
score >= 75
|
||||||
|
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
|
||||||
|
: score >= 50
|
||||||
|
? 'border-amber-500 bg-amber-50 text-amber-700'
|
||||||
|
: 'border-ink-300 bg-ink-50 text-ink-600';
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex h-9 w-11 items-center justify-center rounded-md border-2 text-xs font-bold tabular-nums ${tone}`}
|
||||||
|
>
|
||||||
|
{score}%
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatDateTime = (value: string | null) => {
|
||||||
|
if (!value) return '–';
|
||||||
|
const date = new Date(value.includes('T') ? value : value.replace(' ', 'T'));
|
||||||
|
if (Number.isNaN(date.getTime())) return '–';
|
||||||
|
return date.toLocaleString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/** "vor 3 Tagen" - fürs Alter einer Anzeige genauer als ein Datum. */
|
||||||
|
export const formatAge = (value: string | null) => {
|
||||||
|
if (!value) return 'unbekannt';
|
||||||
|
const date = new Date(value.includes('T') ? value : value.replace(' ', 'T'));
|
||||||
|
const days = Math.floor((Date.now() - date.getTime()) / 86_400_000);
|
||||||
|
if (Number.isNaN(days)) return 'unbekannt';
|
||||||
|
if (days <= 0) return 'heute';
|
||||||
|
if (days === 1) return 'gestern';
|
||||||
|
return `vor ${days} Tagen`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||||
|
|
||||||
|
/** Eingabe von Begriffen als Chips: Enter oder Komma fügt hinzu. */
|
||||||
|
export const TermInput: React.FC<{
|
||||||
|
value: string[];
|
||||||
|
onChange: (value: string[]) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
tone?: 'brand' | 'red';
|
||||||
|
}> = ({ value, onChange, placeholder, tone = 'brand' }) => {
|
||||||
|
const [draft, setDraft] = React.useState('');
|
||||||
|
|
||||||
|
const add = (text: string) => {
|
||||||
|
const terms = text
|
||||||
|
.split(',')
|
||||||
|
.map((term) => term.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.filter((term) => !value.some((existing) => existing.toLowerCase() === term.toLowerCase()));
|
||||||
|
if (terms.length) onChange([...value, ...terms]);
|
||||||
|
setDraft('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const chip =
|
||||||
|
tone === 'red' ? 'border-red-200 bg-red-50 text-red-700' : 'border-brand-200 bg-brand-50 text-brand-700';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[42px] flex-wrap items-center gap-1.5 rounded-lg border border-ink-200 bg-surface px-2 py-1.5">
|
||||||
|
{value.map((term) => (
|
||||||
|
<span key={term} className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium ${chip}`}>
|
||||||
|
{term}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(value.filter((item) => item !== term))}
|
||||||
|
className="opacity-60 hover:opacity-100"
|
||||||
|
aria-label={`${term} entfernen`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<input
|
||||||
|
value={draft}
|
||||||
|
onChange={(event) => {
|
||||||
|
const text = event.target.value;
|
||||||
|
if (text.includes(',')) add(text);
|
||||||
|
else setDraft(text);
|
||||||
|
}}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
add(draft);
|
||||||
|
} else if (event.key === 'Backspace' && !draft && value.length) {
|
||||||
|
onChange(value.slice(0, -1));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => draft.trim() && add(draft)}
|
||||||
|
placeholder={value.length ? '' : placeholder}
|
||||||
|
className="min-w-[140px] flex-1 border-0 bg-transparent px-1 py-0.5 text-sm text-ink-900 placeholder:text-ink-400 focus:outline-none focus:ring-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { scannerApi, type ScannerSchedule, type ScannerSecrets } from '../../lib/api';
|
||||||
|
import { Field, PrimaryButton, inputClass } from '../ui/form';
|
||||||
|
import { useToast } from '../Toast';
|
||||||
|
import { WEEKDAY_LABELS } from '../scanner/scannerUi';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Reiter "Scanner" in den Einstellungen (nur Administratoren).
|
||||||
|
*
|
||||||
|
* Wann und wie oft der Scanner selbst sucht, wie alt Anzeigen höchstens sein
|
||||||
|
* dürfen, und die kostenlosen API-Schlüssel für Adzuna und Jooble. Die
|
||||||
|
* Schlüssel kommen - wie der OpenAI-Schlüssel - nie zurück zum Browser.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const INTERVALS = [15, 30, 60, 120, 180, 240, 360];
|
||||||
|
|
||||||
|
export const ScannerSettings: React.FC = () => {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [schedule, setSchedule] = useState<ScannerSchedule | null>(null);
|
||||||
|
const [secrets, setSecrets] = useState<ScannerSecrets | null>(null);
|
||||||
|
const [keys, setKeys] = useState({ adzunaId: '', adzunaKey: '', joobleKey: '' });
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scannerApi
|
||||||
|
.settings()
|
||||||
|
.then((data) => {
|
||||||
|
setSchedule(data.settings);
|
||||||
|
setSecrets(data.secrets);
|
||||||
|
})
|
||||||
|
.catch((err) => toast(err instanceof Error ? err.message : 'Laden fehlgeschlagen.', 'error'));
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
const save = async (changes: Parameters<typeof scannerApi.saveSettings>[0]) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const data = await scannerApi.saveSettings(changes);
|
||||||
|
setSchedule(data.settings);
|
||||||
|
setSecrets(data.secrets);
|
||||||
|
setKeys({ adzunaId: '', adzunaKey: '', joobleKey: '' });
|
||||||
|
toast('Gespeichert.', 'success');
|
||||||
|
} catch (err) {
|
||||||
|
toast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!schedule || !secrets) return <p className="py-16 text-center text-sm text-ink-400">Wird geladen …</p>;
|
||||||
|
|
||||||
|
const set = (changes: Partial<ScannerSchedule>) => setSchedule({ ...schedule, ...changes });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<section className="rounded-xl border border-ink-200 bg-surface p-5">
|
||||||
|
<h2 className="text-base font-semibold text-ink-900">Zeitplan</h2>
|
||||||
|
<p className="mt-1 max-w-3xl text-sm text-ink-500">
|
||||||
|
In diesem Fenster sucht der Scanner selbstständig. Außerhalb geht es nur über „Jetzt scannen“ im
|
||||||
|
Dashboard. Gerechnet wird in deutscher Zeit.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label className="mt-4 flex items-center gap-2 text-sm font-medium text-ink-800">
|
||||||
|
<input type="checkbox" checked={schedule.active} onChange={(event) => set({ active: event.target.checked })} />
|
||||||
|
Automatische Suche aktiv
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Field label="Wochentage" className="lg:col-span-2">
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{WEEKDAY_LABELS.map((label, index) => {
|
||||||
|
const day = index + 1;
|
||||||
|
const on = schedule.days.includes(day);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={label}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
set({ days: on ? schedule.days.filter((d) => d !== day) : [...schedule.days, day].sort() })
|
||||||
|
}
|
||||||
|
className={`h-9 w-11 rounded-lg border text-sm font-medium ${
|
||||||
|
on ? 'border-brand-500 bg-brand-50 text-brand-700' : 'border-ink-200 text-ink-500 hover:bg-ink-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<Field label="Von">
|
||||||
|
<input type="time" value={schedule.from} onChange={(event) => set({ from: event.target.value })} className={inputClass} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Bis" hint="Ein Lauf um genau diese Uhrzeit zählt noch.">
|
||||||
|
<input type="time" value={schedule.to} onChange={(event) => set({ to: event.target.value })} className={inputClass} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Intervall">
|
||||||
|
<select
|
||||||
|
value={schedule.interval}
|
||||||
|
onChange={(event) => set({ interval: Number(event.target.value) })}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
{Array.from(new Set([...INTERVALS, schedule.interval]))
|
||||||
|
.sort((a, b) => a - b)
|
||||||
|
.map((minutes) => (
|
||||||
|
<option key={minutes} value={minutes}>
|
||||||
|
{minutes < 60 ? `alle ${minutes} Minuten` : minutes === 60 ? 'stündlich' : `alle ${minutes / 60} Stunden`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Höchstes Alter einer Anzeige" hint="In Tagen. Älteres wird verworfen.">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={90}
|
||||||
|
value={schedule.maxAge}
|
||||||
|
onChange={(event) => set({ maxAge: Number(event.target.value) || 21 })}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PrimaryButton
|
||||||
|
type="button"
|
||||||
|
className="mt-5"
|
||||||
|
disabled={busy || schedule.days.length === 0}
|
||||||
|
onClick={() => void save(schedule)}
|
||||||
|
>
|
||||||
|
Zeitplan speichern
|
||||||
|
</PrimaryButton>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-ink-200 bg-surface p-5">
|
||||||
|
<h2 className="text-base font-semibold text-ink-900">API-Schlüssel</h2>
|
||||||
|
<p className="mt-1 max-w-3xl text-sm text-ink-500">
|
||||||
|
Agentur für Arbeit, StepStone, karriere.at und freelancermap brauchen nichts. Adzuna (einzige Quelle für
|
||||||
|
die Schweiz) und Jooble geben nach kostenloser Registrierung einen Schlüssel aus. Ohne Schlüssel werden
|
||||||
|
sie übersprungen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-4 lg:grid-cols-3">
|
||||||
|
<KeyField
|
||||||
|
label="Adzuna App ID"
|
||||||
|
current={secrets.adzunaId}
|
||||||
|
value={keys.adzunaId}
|
||||||
|
onChange={(adzunaId) => setKeys({ ...keys, adzunaId })}
|
||||||
|
hint="developer.adzuna.com → Register"
|
||||||
|
/>
|
||||||
|
<KeyField
|
||||||
|
label="Adzuna App Key"
|
||||||
|
current={secrets.adzunaKey}
|
||||||
|
value={keys.adzunaKey}
|
||||||
|
onChange={(adzunaKey) => setKeys({ ...keys, adzunaKey })}
|
||||||
|
/>
|
||||||
|
<KeyField
|
||||||
|
label="Jooble API-Key"
|
||||||
|
current={secrets.joobleKey}
|
||||||
|
value={keys.joobleKey}
|
||||||
|
onChange={(joobleKey) => setKeys({ ...keys, joobleKey })}
|
||||||
|
hint="jooble.org/api/about → Schlüssel anfordern"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PrimaryButton
|
||||||
|
type="button"
|
||||||
|
className="mt-5"
|
||||||
|
disabled={busy || !Object.values(keys).some((value) => value.trim())}
|
||||||
|
onClick={() =>
|
||||||
|
void save(Object.fromEntries(Object.entries(keys).filter(([, value]) => value.trim())))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Schlüssel speichern
|
||||||
|
</PrimaryButton>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const KeyField: React.FC<{
|
||||||
|
label: string;
|
||||||
|
current: string | null;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
hint?: string;
|
||||||
|
}> = ({ label, current, value, onChange, hint }) => (
|
||||||
|
<Field label={label} hint={current ? `Hinterlegt: ${current}${hint ? ` · ${hint}` : ''}` : hint ?? 'Nicht hinterlegt.'}>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={value}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
placeholder={current ? '•••••••• (ersetzen)' : ''}
|
||||||
|
autoComplete="off"
|
||||||
|
spellCheck={false}
|
||||||
|
className={`${inputClass} font-mono`}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
@@ -3,6 +3,7 @@ import { UserManagement } from './UserManagement';
|
|||||||
import { MasterData } from './MasterData';
|
import { MasterData } from './MasterData';
|
||||||
import { WhisperSettings } from './WhisperSettings';
|
import { WhisperSettings } from './WhisperSettings';
|
||||||
import { AiSettings } from './AiSettings';
|
import { AiSettings } from './AiSettings';
|
||||||
|
import { ScannerSettings } from './ScannerSettings';
|
||||||
import type { SettingsTab } from '../../lib/navigation';
|
import type { SettingsTab } from '../../lib/navigation';
|
||||||
import { useAuth } from '../../lib/auth';
|
import { useAuth } from '../../lib/auth';
|
||||||
import { useTheme } from '../../lib/theme';
|
import { useTheme } from '../../lib/theme';
|
||||||
@@ -97,6 +98,17 @@ export const Settings: React.FC<SettingsProps> = ({ initialTab, onTabConsumed })
|
|||||||
KI
|
KI
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setTab('scanner')}
|
||||||
|
className={`-mb-px border-b-2 px-3 py-2 text-sm font-medium ${
|
||||||
|
tab === 'scanner' ? 'border-brand-500 text-brand-700' : 'border-transparent text-ink-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Scanner
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTab('konto')}
|
onClick={() => setTab('konto')}
|
||||||
@@ -120,6 +132,8 @@ export const Settings: React.FC<SettingsProps> = ({ initialTab, onTabConsumed })
|
|||||||
|
|
||||||
{tab === 'ki' && isAdmin && <AiSettings />}
|
{tab === 'ki' && isAdmin && <AiSettings />}
|
||||||
|
|
||||||
|
{tab === 'scanner' && isAdmin && <ScannerSettings />}
|
||||||
|
|
||||||
{tab === 'diktat' && <WhisperSettings />}
|
{tab === 'diktat' && <WhisperSettings />}
|
||||||
|
|
||||||
{tab === 'benutzer' && isAdmin && <UserManagement />}
|
{tab === 'benutzer' && isAdmin && <UserManagement />}
|
||||||
|
|||||||
+102
@@ -432,3 +432,105 @@ export interface DashboardData {
|
|||||||
export const dashboardApi = {
|
export const dashboardApi = {
|
||||||
load: () => get<DashboardData>('/dashboard'),
|
load: () => get<DashboardData>('/dashboard'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- Scanner */
|
||||||
|
|
||||||
|
export type ScannerSourceId = 'ba' | 'stepstone' | 'karriere' | 'adzuna' | 'jooble' | 'freelancermap';
|
||||||
|
export type ScannerMatchStatus = 'neu' | 'gesehen' | 'in_bearbeitung' | 'verworfen';
|
||||||
|
|
||||||
|
export interface ScannerAgent {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
keywords: string[];
|
||||||
|
sources: ScannerSourceId[];
|
||||||
|
excludes: string[];
|
||||||
|
is_active: boolean;
|
||||||
|
matches: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScannerSchedule {
|
||||||
|
active: boolean;
|
||||||
|
/** ISO-Wochentage, 1 = Montag */
|
||||||
|
days: number[];
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
/** Minuten */
|
||||||
|
interval: number;
|
||||||
|
/** höchstes Alter einer Anzeige in Tagen */
|
||||||
|
maxAge: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScannerSource {
|
||||||
|
id: ScannerSourceId;
|
||||||
|
label: string;
|
||||||
|
countries: string;
|
||||||
|
ready: boolean;
|
||||||
|
needsKey: 'adzuna' | 'jooble' | null;
|
||||||
|
last: { ok: boolean; count: number; errors: string[] } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScannerOverview {
|
||||||
|
stats: { agents: number; jobs: number; matches: number; fresh: number; known: number };
|
||||||
|
sources: ScannerSource[];
|
||||||
|
schedule: ScannerSchedule;
|
||||||
|
lastScanAt: string | null;
|
||||||
|
lastRun: {
|
||||||
|
id: number;
|
||||||
|
trigger: 'auto' | 'manual';
|
||||||
|
started_at: string;
|
||||||
|
finished_at: string;
|
||||||
|
jobs_found: number;
|
||||||
|
matches_new: number;
|
||||||
|
} | null;
|
||||||
|
status: { running: boolean; since: string | null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScannerMatch {
|
||||||
|
id: number;
|
||||||
|
score: number;
|
||||||
|
hits: string[];
|
||||||
|
status: ScannerMatchStatus;
|
||||||
|
created_at: string;
|
||||||
|
agent_id: number;
|
||||||
|
agent_name: string;
|
||||||
|
job_id: number;
|
||||||
|
source: ScannerSourceId;
|
||||||
|
title: string;
|
||||||
|
company: string | null;
|
||||||
|
location: string | null;
|
||||||
|
country: string | null;
|
||||||
|
employment_type: string | null;
|
||||||
|
url: string;
|
||||||
|
description: string | null;
|
||||||
|
published_at: string | null;
|
||||||
|
company_id: number | null;
|
||||||
|
company_name: string | null;
|
||||||
|
is_customer: boolean | null;
|
||||||
|
known: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScannerSecrets {
|
||||||
|
adzunaId: string | null;
|
||||||
|
adzunaKey: string | null;
|
||||||
|
joobleKey: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ScannerAgentInput = Pick<ScannerAgent, 'name' | 'keywords' | 'sources' | 'excludes' | 'is_active'>;
|
||||||
|
|
||||||
|
const put = <T>(path: string, body: unknown) =>
|
||||||
|
apiRequest<T>(path, { method: 'PUT', body: JSON.stringify(body) });
|
||||||
|
|
||||||
|
export const scannerApi = {
|
||||||
|
overview: () => get<ScannerOverview>('/scanner/overview'),
|
||||||
|
agents: () => get<{ agents: ScannerAgent[] }>('/scanner/agents'),
|
||||||
|
createAgent: (agent: ScannerAgentInput) => post<{ agent: ScannerAgent }>('/scanner/agents', agent),
|
||||||
|
updateAgent: (id: number, agent: ScannerAgentInput) => put<{ agent: ScannerAgent }>(`/scanner/agents/${id}`, agent),
|
||||||
|
removeAgent: (id: number) => del<{ ok: true }>(`/scanner/agents/${id}`),
|
||||||
|
matches: (params: Record<string, unknown> = {}) =>
|
||||||
|
get<{ total: number; matches: ScannerMatch[] }>(`/scanner/matches${query(params)}`),
|
||||||
|
setStatus: (id: number, status: ScannerMatchStatus) => patch<{ ok: true }>(`/scanner/matches/${id}`, { status }),
|
||||||
|
scan: () => post<{ started: true }>('/scanner/scan'),
|
||||||
|
settings: () => get<{ settings: ScannerSchedule; secrets: ScannerSecrets }>('/scanner/settings'),
|
||||||
|
saveSettings: (changes: Partial<ScannerSchedule> & Partial<Record<keyof ScannerSecrets, string>>) =>
|
||||||
|
patch<{ settings: ScannerSchedule; secrets: ScannerSecrets }>('/scanner/settings', changes),
|
||||||
|
};
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import type { View } from '../types';
|
|||||||
export type OpenTarget = 'contact' | 'company' | 'tender' | 'project' | 'lead' | 'user';
|
export type OpenTarget = 'contact' | 'company' | 'tender' | 'project' | 'lead' | 'user';
|
||||||
|
|
||||||
/** Reiter der Einstellungen - auch von aussen ansteuerbar. */
|
/** Reiter der Einstellungen - auch von aussen ansteuerbar. */
|
||||||
export type SettingsTab = 'konto' | 'benutzer' | 'stammdaten' | 'ki' | 'diktat';
|
export type SettingsTab = 'konto' | 'benutzer' | 'stammdaten' | 'ki' | 'scanner' | 'diktat';
|
||||||
|
|
||||||
interface NavigationValue {
|
interface NavigationValue {
|
||||||
setView: (view: View) => void;
|
setView: (view: View) => void;
|
||||||
|
|||||||
@@ -384,6 +384,8 @@ export type View =
|
|||||||
| 'Live!'
|
| 'Live!'
|
||||||
| 'Ausschreibungen · Archiv'
|
| 'Ausschreibungen · Archiv'
|
||||||
| 'Laufende Projekte'
|
| 'Laufende Projekte'
|
||||||
|
| 'Scanner'
|
||||||
|
| 'Scanner · Suchagenten'
|
||||||
| 'Historie'
|
| 'Historie'
|
||||||
| 'Einstellungen';
|
| 'Einstellungen';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user