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:
schakal00783
2026-09-29 10:49:44 +02:00
co-authored by Claude Opus 5.5
parent 6fe5b18c11
commit 1b82f580db
23 changed files with 2894 additions and 2 deletions
+23
View File
@@ -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
+222
View File
@@ -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`).
+1
View File
@@ -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).
+1
View File
@@ -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",
+2 -1
View File
@@ -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(
+347
View File
@@ -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;
+92
View File
@@ -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;
}
+235
View File
@@ -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;
}
+66
View File
@@ -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.');
}
+131
View File
@@ -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;
}
+340
View File
@@ -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(/&nbsp;/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);
}
+80
View File
@@ -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;
+5
View File
@@ -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);
+6
View File
@@ -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' && (
+7
View File
@@ -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',
+332
View File
@@ -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>
);
+548
View File
@@ -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>
);
+140
View File
@@ -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>
);
};
+197
View File
@@ -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>
);
+14
View File
@@ -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
View File
@@ -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),
};
+1 -1
View File
@@ -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;
+2
View File
@@ -384,6 +384,8 @@ export type View =
| 'Live!'
| 'Ausschreibungen · Archiv'
| 'Laufende Projekte'
| 'Scanner'
| 'Scanner · Suchagenten'
| 'Historie'
| 'Einstellungen';