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
|
||||
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
|
||||
|
||||
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 |
|
||||
| [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 |
|
||||
| [12 Scanner](12-scanner.md) | Stellenanzeigen aus Jobbörsen: Quellen, Bewertung, Zeitplan, Endpunkte |
|
||||
|
||||
Die Designentscheidungen stehen im [README des Projekts](../README.md).
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cheerio": "^1.2.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"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_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 = 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(
|
||||
|
||||
@@ -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,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) 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 connectionRoutes from './routes/connections.js';
|
||||
import dashboardRoutes from './routes/dashboard.js';
|
||||
import scannerRoutes from './routes/scanner.js';
|
||||
import { startScheduler } from './scanner/scheduler.js';
|
||||
|
||||
const app = express();
|
||||
const PORT = Number(process.env.PORT || 5007);
|
||||
@@ -71,6 +73,8 @@ app.use('/api/search', searchRoutes);
|
||||
app.use('/api/network', networkRoutes);
|
||||
app.use('/api/connections', connectionRoutes);
|
||||
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.' }));
|
||||
|
||||
@@ -109,6 +113,7 @@ try {
|
||||
app.listen(PORT, HOST, () => {
|
||||
console.log(`[ICON CRM] Backend läuft auf http://${HOST}:${PORT}`);
|
||||
});
|
||||
startScheduler();
|
||||
} catch (err) {
|
||||
console.error('[ICON CRM] Start fehlgeschlagen:', err);
|
||||
process.exit(1);
|
||||
|
||||
@@ -15,6 +15,8 @@ import { Tenders } from './components/tenders/Tenders';
|
||||
import { LiveBoard } from './components/tenders/LiveBoard';
|
||||
import { Projects } from './components/projects/Projects';
|
||||
import { History } from './components/history/History';
|
||||
import { ScannerDashboard } from './components/scanner/ScannerDashboard';
|
||||
import { ScannerAgents } from './components/scanner/ScannerAgents';
|
||||
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 === 'Einstellungen' && (
|
||||
|
||||
@@ -71,6 +71,13 @@ export const Sidebar: React.FC<SidebarProps> = ({ view, onSelect, counts, collap
|
||||
badge: 'projects_active',
|
||||
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',
|
||||
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 { WhisperSettings } from './WhisperSettings';
|
||||
import { AiSettings } from './AiSettings';
|
||||
import { ScannerSettings } from './ScannerSettings';
|
||||
import type { SettingsTab } from '../../lib/navigation';
|
||||
import { useAuth } from '../../lib/auth';
|
||||
import { useTheme } from '../../lib/theme';
|
||||
@@ -97,6 +98,17 @@ export const Settings: React.FC<SettingsProps> = ({ initialTab, onTabConsumed })
|
||||
KI
|
||||
</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
|
||||
type="button"
|
||||
onClick={() => setTab('konto')}
|
||||
@@ -120,6 +132,8 @@ export const Settings: React.FC<SettingsProps> = ({ initialTab, onTabConsumed })
|
||||
|
||||
{tab === 'ki' && isAdmin && <AiSettings />}
|
||||
|
||||
{tab === 'scanner' && isAdmin && <ScannerSettings />}
|
||||
|
||||
{tab === 'diktat' && <WhisperSettings />}
|
||||
|
||||
{tab === 'benutzer' && isAdmin && <UserManagement />}
|
||||
|
||||
+102
@@ -432,3 +432,105 @@ export interface DashboardData {
|
||||
export const dashboardApi = {
|
||||
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';
|
||||
|
||||
/** 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 {
|
||||
setView: (view: View) => void;
|
||||
|
||||
@@ -384,6 +384,8 @@ export type View =
|
||||
| 'Live!'
|
||||
| 'Ausschreibungen · Archiv'
|
||||
| 'Laufende Projekte'
|
||||
| 'Scanner'
|
||||
| 'Scanner · Suchagenten'
|
||||
| 'Historie'
|
||||
| 'Einstellungen';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user