Scanner: ohne Altersgrenze suchen, Filter Anzeigenalter, Alarm-Schlagwörter
- Suche ohne 21-Tage-Grenze (BA ohne veroeffentlichtseit, Adzuna ohne max_days_old); Einstellung Höchstalter entfällt - Dashboard: Filter Anzeigenalter (bis/älter als 21 Tage), Sortierung älteste Anzeige, Filter nur Alarm, Zeitraum heißt jetzt Gefunden - Suchagent: Schlagwörter mit Alarm; Treffer mit alarm = 1 melden sich über den AlarmWatcher (App-weit, jede Minute) mit Ton und Pop-up, Quittieren gilt für alle Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b34a1968df
commit
1ad774fdd2
@@ -212,7 +212,8 @@ Kontakt (*Kontakt → Stundensatz vor Ort / remote*).
|
||||
## Scanner
|
||||
|
||||
Sucht in Jobbörsen (Agentur für Arbeit, StepStone, karriere.at, Adzuna,
|
||||
freelancermap) nach offenen Stellen in DACH, höchstens 21 Tage alt. Technik
|
||||
freelancermap) nach offenen Stellen in DACH – jedes Alter; im Dashboard filtert
|
||||
*Anzeigenalter* nach „bis 21 Tage“ oder „älter als 21 Tage“. Technik
|
||||
siehe [12 Scanner](12-scanner.md).
|
||||
|
||||
1. **Scanner → Suchagenten → + Neuer Suchagent.** Name, Schlagwörter (Enter
|
||||
@@ -228,7 +229,11 @@ siehe [12 Scanner](12-scanner.md).
|
||||
zur Börse. Status setzen: *In Bearbeitung*, wenn sich jemand kümmert,
|
||||
*Verworfen*, wenn es nicht passt.
|
||||
|
||||
Zeitplan (Wochentage, Uhrzeit, Intervall, Höchstalter) und den kostenlosen
|
||||
**Schlagwörter mit Alarm** im Suchagenten: wird eines gefunden, gibt es einen
|
||||
kurzen Ton und ein Pop-up – egal, wo du im CRM gerade bist. *Gesehen*
|
||||
quittiert für alle.
|
||||
|
||||
Zeitplan (Wochentage, Uhrzeit, Intervall) und den kostenlosen
|
||||
Schlüssel für Adzuna pflegt ein Administrator unter
|
||||
*Einstellungen → Scanner*.
|
||||
|
||||
|
||||
+44
-10
@@ -1,8 +1,12 @@
|
||||
# 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.
|
||||
Festanstellungen ebenso wie Projekte. Region immer DACH. Eingeführt am 29.09.2026.
|
||||
|
||||
Gesucht wird **ohne Altersgrenze** (Änderung vom 29.09.2026, vorher höchstens
|
||||
21 Tage): auch ältere Anzeigen kommen herein. Im Dashboard filtert
|
||||
**Anzeigenalter** nach „bis 21 Tage" bzw. „älter als 21 Tage", die Sortierung
|
||||
kennt „älteste Anzeige".
|
||||
|
||||
Menü: **Scanner** (Dashboard) mit dem Unterpunkt **Suchagenten**; Zeitplan und
|
||||
API-Schlüssel unter **Einstellungen → Scanner** (nur Administratoren).
|
||||
@@ -16,10 +20,10 @@ 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`) |
|
||||
| `ba` | Agentur für Arbeit | DE | offizielle Jobsuche-API v6, JSON, 100 Anzeigen je Begriff | 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 |
|
||||
| `adzuna` | Adzuna | DE · AT · CH | REST-API, neueste zuerst | kostenlos, App ID + Key |
|
||||
| `freelancermap` | freelancermap | DE | HTML, Selektoren wie im perinco-Scanner | keiner |
|
||||
|
||||
Adzuna ist die einzige Quelle für die Schweiz (jobs.ch blockt mit CAPTCHA).
|
||||
@@ -59,7 +63,6 @@ sequenceDiagram
|
||||
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
|
||||
@@ -101,6 +104,34 @@ mit „SAP FI" im Text → (2 + 1 + 0) / 6 = **50 %**.
|
||||
Das kostet nichts und ist nachvollziehbar; OpenAI wird vom Scanner nicht
|
||||
aufgerufen.
|
||||
|
||||
## Schlagwörter mit Alarm
|
||||
|
||||
Neben den Schlagwörtern trägt ein Agent **Schlagwörter mit Alarm**
|
||||
(`alarm_keywords`). Sie werden genauso gesucht und für die Quote gezählt; steht
|
||||
eines in einer **neu gefundenen** Anzeige, bekommt der Treffer `alarm = 1`.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant P as pipeline.js
|
||||
participant DB as icon_scanner_matches
|
||||
participant W as AlarmWatcher (jede Ansicht, jede Minute)
|
||||
P->>DB: Treffer mit alarm = 1, alarm_ack = 0
|
||||
W->>DB: GET /api/scanner/alarms
|
||||
DB-->>W: unquittierte Alarme
|
||||
W->>W: neuer Alarm? Zweiklang (WebAudio) + Pop-up
|
||||
W->>DB: Gesehen / Zum Scanner → POST /alarms/ack
|
||||
```
|
||||
|
||||
- Der Wächter hängt in `App.tsx`, nicht im Dashboard – der Alarm kommt also in
|
||||
jeder Ansicht (beim perinco-Scanner hörte man den Ton nur im Dashboard).
|
||||
- **Quittieren gilt für alle Benutzer.** Sonst klingelte derselbe Fund bei jedem
|
||||
einzeln.
|
||||
- Der Ton braucht einen vorherigen Klick auf der Seite (Autoplay-Regel der
|
||||
Browser). Ohne bleibt es still, das Pop-up kommt trotzdem.
|
||||
- Schon gespeicherte Treffer werden nicht nachträglich zu Alarmen, wenn man ein
|
||||
Alarm-Wort später ergänzt (Treffer sind eindeutig je Agent und Anzeige).
|
||||
- Im Dashboard tragen Alarm-Treffer eine rote Glocke; Filter **nur Alarm**.
|
||||
|
||||
## Bekannte Unternehmen
|
||||
|
||||
Jede Anzeige wird mit `icon_companies` abgeglichen. Beide Namen werden
|
||||
@@ -118,8 +149,7 @@ 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.
|
||||
Einstellungen → Scanner. Vorgabe: **stündlich, Mo–Fr, 06:00–20:00 Uhr**.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
@@ -151,7 +181,6 @@ Liste neu, sobald er fertig ist.
|
||||
| `scanner_days` | `1,2,3,4,5` | ISO-Wochentage, 1 = Montag |
|
||||
| `scanner_from` / `scanner_to` | `06:00` / `20:00` | Fenster, deutsche Zeit |
|
||||
| `scanner_interval` | `60` | Minuten, 15 bis 1440 |
|
||||
| `scanner_max_age` | `21` | Tage, 1 bis 90 |
|
||||
| `scanner_adzuna_id`, `scanner_adzuna_key` | leer | gehen nie im Klartext zum Browser |
|
||||
| `scanner_last_scan_at` | – | Beginn des letzten Laufs (ISO) |
|
||||
|
||||
@@ -168,6 +197,7 @@ erDiagram
|
||||
text keywords "JSON-Array"
|
||||
text sources "JSON-Array der Quellen-Ids"
|
||||
text excludes "JSON-Array"
|
||||
text alarm_keywords "JSON-Array, Ton + Pop-up"
|
||||
tinyint is_active
|
||||
}
|
||||
icon_scanner_jobs {
|
||||
@@ -189,6 +219,8 @@ erDiagram
|
||||
tinyint score "0-100"
|
||||
text hits "gefundene Schlagwörter"
|
||||
enum status "neu gesehen in_bearbeitung verworfen"
|
||||
tinyint alarm "Alarm-Schlagwort getroffen"
|
||||
tinyint alarm_ack "Pop-up quittiert (für alle)"
|
||||
}
|
||||
icon_scanner_runs {
|
||||
int id
|
||||
@@ -220,10 +252,12 @@ Alle unter `/api/scanner`, alle hinter `requireAuth`.
|
||||
| --- | --- | --- | --- |
|
||||
| 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 }` |
|
||||
| POST | `/agents` | alle | anlegen `{ name, keywords[], alarm_keywords[], sources[], excludes[], is_active }` – Schlagwort oder Alarm-Schlagwort Pflicht |
|
||||
| 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` |
|
||||
| GET | `/matches` | alle | Filter `hours` (gefunden), `age` (`recent`/`old`, Grenze 21 Tage ab Veröffentlichung), `agentId`, `source`, `known=1`, `alarm=1`, `status` (`alle` zeigt auch Verworfene), `minScore`, `q`, `sort` (`known`/`score`/`published`/`oldest`), `limit` |
|
||||
| GET | `/alarms` | alle | unquittierte Alarm-Treffer (höchstens 50) |
|
||||
| POST | `/alarms/ack` | alle | `{ ids: [] }` – quittiert für alle Benutzer |
|
||||
| PATCH | `/matches/:id` | alle | `{ status }` |
|
||||
| POST | `/scan` | alle | Lauf starten, `202`; `409`, wenn schon einer läuft |
|
||||
| GET | `/settings` | Admin | Zeitplan, Schlüssel verkürzt |
|
||||
|
||||
@@ -185,6 +185,19 @@ async function migrateColumns() {
|
||||
'email',
|
||||
'ALTER TABLE icon_suspects ADD COLUMN email VARCHAR(190) NULL AFTER company',
|
||||
],
|
||||
// Scanner: Schlagwoerter mit Alarm (Ton + Pop-up beim Fund), 29.09.2026.
|
||||
[
|
||||
'icon_scanner_agents',
|
||||
'alarm_keywords',
|
||||
'ALTER TABLE icon_scanner_agents ADD COLUMN alarm_keywords TEXT NULL AFTER excludes',
|
||||
],
|
||||
[
|
||||
'icon_scanner_matches',
|
||||
'alarm',
|
||||
`ALTER TABLE icon_scanner_matches
|
||||
ADD COLUMN alarm TINYINT(1) NOT NULL DEFAULT 0 AFTER status,
|
||||
ADD COLUMN alarm_ack TINYINT(1) NOT NULL DEFAULT 0 AFTER alarm`,
|
||||
],
|
||||
[
|
||||
'icon_cv_imports',
|
||||
'photo',
|
||||
|
||||
@@ -24,6 +24,8 @@ const router = express.Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
const STATUSES = ['neu', 'gesehen', 'in_bearbeitung', 'verworfen'];
|
||||
/** Grenze des Filters "Anzeigenalter" im Dashboard. */
|
||||
const AGE_DAYS = 21;
|
||||
|
||||
const cleanList = (value) =>
|
||||
[...new Set((Array.isArray(value) ? value : []).map(stripQuotes).filter(Boolean))].slice(0, 50);
|
||||
@@ -35,6 +37,7 @@ const agentOut = (row) => ({
|
||||
// Nur Quellen, die es noch gibt (Jooble ist am 29.09.2026 entfallen).
|
||||
sources: parseList(row.sources).filter((id) => SOURCE_IDS.includes(id)),
|
||||
excludes: parseList(row.excludes),
|
||||
alarm_keywords: parseList(row.alarm_keywords),
|
||||
is_active: Boolean(row.is_active),
|
||||
matches: Number(row.matches ?? 0),
|
||||
created_at: row.created_at,
|
||||
@@ -118,27 +121,33 @@ function readAgent(body) {
|
||||
const keywords = cleanList(body?.keywords);
|
||||
const sources = cleanList(body?.sources).filter((id) => SOURCE_IDS.includes(id));
|
||||
const excludes = cleanList(body?.excludes);
|
||||
const alarmKeywords = cleanList(body?.alarm_keywords);
|
||||
if (!name) return { error: 'Bitte einen Namen angeben.' };
|
||||
if (keywords.length === 0) return { error: 'Mindestens ein Schlagwort angeben.' };
|
||||
if (keywords.length === 0 && alarmKeywords.length === 0) {
|
||||
return { error: 'Mindestens ein Schlagwort oder Alarm-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 };
|
||||
return { name, keywords, alarmKeywords, sources, excludes, is_active: body?.is_active !== false };
|
||||
}
|
||||
|
||||
/** Spaltenwerte in der Reihenfolge name, keywords, alarm_keywords, sources, excludes, is_active. */
|
||||
const agentValues = (agent) => [
|
||||
agent.name,
|
||||
JSON.stringify(agent.keywords),
|
||||
JSON.stringify(agent.alarmKeywords),
|
||||
JSON.stringify(agent.sources),
|
||||
JSON.stringify(agent.excludes),
|
||||
agent.is_active ? 1 : 0,
|
||||
];
|
||||
|
||||
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,
|
||||
]
|
||||
`INSERT INTO icon_scanner_agents (name, keywords, alarm_keywords, sources, excludes, is_active, created_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[...agentValues(agent), req.user.id]
|
||||
);
|
||||
await logHistory({
|
||||
entityType: 'system',
|
||||
@@ -160,16 +169,9 @@ router.put('/agents/:id', async (req, res, next) => {
|
||||
try {
|
||||
const [result] = await pool.query(
|
||||
`UPDATE icon_scanner_agents
|
||||
SET name = ?, keywords = ?, sources = ?, excludes = ?, is_active = ?
|
||||
SET name = ?, keywords = ?, alarm_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),
|
||||
]
|
||||
[...agentValues(agent), 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)]);
|
||||
@@ -236,21 +238,37 @@ router.get('/matches', async (req, res, next) => {
|
||||
where.push('(j.title LIKE ? OR j.company LIKE ? OR j.location LIKE ?)');
|
||||
params.push(`%${q}%`, `%${q}%`, `%${q}%`);
|
||||
}
|
||||
/*
|
||||
* Alter der Anzeige (nicht des Fundes). Gesucht wird seit 29.09.2026 ohne
|
||||
* Altersgrenze; die 21 Tage sind nur noch Filter. Anzeigen ohne Datum
|
||||
* (manche Boersen nennen keins) fallen bei beiden Filtern heraus.
|
||||
*/
|
||||
if (req.query.age === 'recent' || req.query.age === 'old') {
|
||||
where.push(
|
||||
req.query.age === 'recent'
|
||||
? 'j.published_at >= NOW() - INTERVAL ? DAY'
|
||||
: 'j.published_at < NOW() - INTERVAL ? DAY'
|
||||
);
|
||||
params.push(AGE_DAYS);
|
||||
}
|
||||
if (req.query.alarm === '1') where.push('m.alarm = 1');
|
||||
|
||||
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';
|
||||
: req.query.sort === 'oldest'
|
||||
? 'j.published_at IS NULL, j.published_at ASC'
|
||||
: // 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,
|
||||
`SELECT m.id, m.score, m.hits, m.status, m.alarm, 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,
|
||||
@@ -276,6 +294,7 @@ router.get('/matches', async (req, res, next) => {
|
||||
matches: rows.map((row) => ({
|
||||
...row,
|
||||
hits: parseList(row.hits),
|
||||
alarm: Boolean(row.alarm),
|
||||
known: Boolean(row.known),
|
||||
is_customer: row.is_customer == null ? null : Boolean(row.is_customer),
|
||||
})),
|
||||
@@ -300,6 +319,43 @@ router.patch('/matches/:id', async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------------
|
||||
* Alarme - Treffer mit Alarm-Schlagwort, die noch niemand quittiert hat.
|
||||
*
|
||||
* Der AlarmWatcher der Oberflaeche fragt jede Minute, egal welche Ansicht
|
||||
* offen ist, spielt einen Ton und zeigt ein Pop-up. Quittiert wird fuer alle:
|
||||
* wer das Pop-up schliesst, nimmt es auch den Kollegen ab - sonst klingelte
|
||||
* derselbe Fund bei jedem einzeln.
|
||||
* ---------------------------------------------------------------------- */
|
||||
router.get('/alarms', async (req, res, next) => {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT m.id, m.score, m.hits, m.created_at, a.name AS agent_name,
|
||||
j.source, j.title, j.company, j.location, j.url, j.published_at, j.company_id
|
||||
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
|
||||
WHERE m.alarm = 1 AND m.alarm_ack = 0 AND m.status <> 'verworfen'
|
||||
ORDER BY m.id DESC
|
||||
LIMIT 50`
|
||||
);
|
||||
res.json({ alarms: rows.map((row) => ({ ...row, hits: parseList(row.hits) })) });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/alarms/ack', async (req, res, next) => {
|
||||
const ids = (Array.isArray(req.body?.ids) ? req.body.ids : []).map(Number).filter(Number.isInteger).slice(0, 200);
|
||||
if (ids.length === 0) return res.status(400).json({ error: 'Keine Treffer übergeben.' });
|
||||
try {
|
||||
await pool.query('UPDATE icon_scanner_matches SET alarm_ack = 1 WHERE id IN (?)', [ids]);
|
||||
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
|
||||
|
||||
@@ -41,8 +41,23 @@ export function containsTerm(text, term) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {{ score: number, hits: string[] } | null} null = ausgeschlossen
|
||||
* oder kein einziges Schlagwort gefunden
|
||||
* Alle Suchbegriffe eines Agenten: Schlagwoerter plus Alarm-Schlagwoerter,
|
||||
* ohne Doppelte (Gross/klein egal).
|
||||
*/
|
||||
export function agentTerms(agent) {
|
||||
const seen = new Set();
|
||||
return [...(agent.keywords ?? []), ...(agent.alarmKeywords ?? [])].filter((term) => {
|
||||
const key = term.toLowerCase();
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {{ score: number, hits: string[], alarm: boolean } | null}
|
||||
* null = ausgeschlossen oder kein einziges Schlagwort gefunden.
|
||||
* `alarm` = mindestens ein Alarm-Schlagwort steht in der Anzeige.
|
||||
*/
|
||||
export function scoreJob(agent, job) {
|
||||
const title = job.title ?? '';
|
||||
@@ -50,7 +65,7 @@ export function scoreJob(agent, job) {
|
||||
|
||||
if ((agent.excludes ?? []).some((term) => containsTerm(all, term))) return null;
|
||||
|
||||
const keywords = agent.keywords ?? [];
|
||||
const keywords = agentTerms(agent);
|
||||
if (keywords.length === 0) return null;
|
||||
|
||||
let points = 0;
|
||||
@@ -66,7 +81,12 @@ export function scoreJob(agent, job) {
|
||||
}
|
||||
if (hits.length === 0) return null;
|
||||
|
||||
return { score: Math.round((100 * points) / (2 * keywords.length)), hits };
|
||||
const alarmSet = new Set((agent.alarmKeywords ?? []).map((term) => term.toLowerCase()));
|
||||
return {
|
||||
score: Math.round((100 * points) / (2 * keywords.length)),
|
||||
hits,
|
||||
alarm: hits.some((hit) => alarmSet.has(hit.toLowerCase())),
|
||||
};
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { pool } from '../db.js';
|
||||
import { SOURCES, sourceReady } from './sources.js';
|
||||
import { findCompany, normalizeCompany, scoreJob, stripQuotes } from './matching.js';
|
||||
import { loadScannerSecrets, loadScannerSettings, setLastScanAt } from './settings.js';
|
||||
import { agentTerms, findCompany, normalizeCompany, scoreJob, stripQuotes } from './matching.js';
|
||||
import { loadScannerSecrets, setLastScanAt } from './settings.js';
|
||||
|
||||
/*
|
||||
* Ein Suchlauf.
|
||||
@@ -9,7 +9,7 @@ import { loadScannerSecrets, loadScannerSettings, setLastScanAt } from './settin
|
||||
* 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
|
||||
* 3. Anzeigen abrufen - jeden Alters (das Dashboard filtert nach Alter)
|
||||
* 4. Anzeigen speichern, Firma gegen das CRM abgleichen
|
||||
* 5. jede Anzeige gegen jeden Agenten bewerten, der diese Boerse durchsucht
|
||||
*
|
||||
@@ -56,24 +56,22 @@ async function execute(trigger) {
|
||||
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)
|
||||
// Boerse -> Schlagwoerter inkl. Alarm-Schlagwoerter (ohne Doppelte)
|
||||
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);
|
||||
for (const keyword of agentTerms(agent)) 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: [] };
|
||||
@@ -82,11 +80,10 @@ async function execute(trigger) {
|
||||
if (!first) await sleep(REQUEST_GAP_MS);
|
||||
first = false;
|
||||
try {
|
||||
const found = await SOURCES[source].fetch(keyword, { maxAge: settings.maxAge, secrets });
|
||||
// Seit 29.09.2026 ohne Altersgrenze: auch aeltere Anzeigen kommen
|
||||
// herein, gefiltert wird im Dashboard ("Anzeigenalter").
|
||||
const found = await SOURCES[source].fetch(keyword, { 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;
|
||||
}
|
||||
@@ -111,8 +108,8 @@ async function execute(trigger) {
|
||||
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)]
|
||||
`INSERT IGNORE INTO icon_scanner_matches (agent_id, job_id, score, hits, alarm) VALUES (?, ?, ?, ?, ?)`,
|
||||
[agent.id, jobId, result.score, JSON.stringify(result.hits), result.alarm ? 1 : 0]
|
||||
);
|
||||
matchesNew += insert.affectedRows;
|
||||
}
|
||||
@@ -153,6 +150,7 @@ async function loadActiveAgents() {
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
keywords: parseList(row.keywords),
|
||||
alarmKeywords: parseList(row.alarm_keywords),
|
||||
sources: parseList(row.sources),
|
||||
excludes: parseList(row.excludes),
|
||||
}));
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getSetting, setSetting } from '../db.js';
|
||||
*
|
||||
* 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.
|
||||
* ohne Altersgrenze (seit 29.09.2026 - das Alter filtert das Dashboard).
|
||||
*/
|
||||
|
||||
export const DEFAULTS = {
|
||||
@@ -15,7 +15,6 @@ export const DEFAULTS = {
|
||||
from: '06:00',
|
||||
to: '20:00',
|
||||
interval: 60,
|
||||
maxAge: 21,
|
||||
};
|
||||
|
||||
/** Zugangsdaten der kostenlosen APIs - gehen nie im Klartext zum Browser. */
|
||||
@@ -27,13 +26,12 @@ const SECRET_KEYS = {
|
||||
const TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
export async function loadScannerSettings() {
|
||||
const [active, days, from, to, interval, maxAge] = await Promise.all([
|
||||
const [active, days, from, to, interval] = 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 ?? '')
|
||||
@@ -47,7 +45,6 @@ export async function loadScannerSettings() {
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,7 +66,6 @@ export async function saveScannerSettings(input) {
|
||||
}
|
||||
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)),
|
||||
@@ -77,7 +73,6 @@ export async function saveScannerSettings(input) {
|
||||
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
|
||||
|
||||
@@ -129,10 +129,10 @@ const slug = (keyword) =>
|
||||
* Bundesagentur fuer Arbeit - offizielle Jobsuche-API (JSON, ohne Anmeldung;
|
||||
* der Schluessel "jobboerse-jobsuche" ist der oeffentliche der Jobsuche-App).
|
||||
* ---------------------------------------------------------------------- */
|
||||
async function fetchBa(keyword, { maxAge }) {
|
||||
async function fetchBa(keyword) {
|
||||
const url =
|
||||
'https://rest.arbeitsagentur.de/jobboerse/jobsuche-service/pc/v6/jobs?' +
|
||||
new URLSearchParams({ was: keyword, veroeffentlichtseit: String(maxAge), size: '100', page: '1' });
|
||||
new URLSearchParams({ was: keyword, size: '100', page: '1' });
|
||||
const data = await request(url, { json: true, headers: { 'X-API-Key': 'jobboerse-jobsuche' } });
|
||||
const list = data?.ergebnisliste ?? data?.stellenangebote ?? [];
|
||||
|
||||
@@ -264,7 +264,7 @@ async function fetchKarriere(keyword) {
|
||||
* 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 }) {
|
||||
async function fetchAdzuna(keyword, { secrets }) {
|
||||
const jobs = [];
|
||||
for (const country of ['de', 'at', 'ch']) {
|
||||
const url =
|
||||
@@ -273,7 +273,6 @@ async function fetchAdzuna(keyword, { maxAge, secrets }) {
|
||||
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',
|
||||
|
||||
@@ -457,7 +457,7 @@ CREATE TABLE IF NOT EXISTS icon_settings (
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Scanner: Stellenanzeigen aus Jobbörsen (DACH, höchstens 21 Tage alt)
|
||||
-- Scanner: Stellenanzeigen aus Jobbörsen (DACH, jedes Alter)
|
||||
--
|
||||
-- Ein Suchagent ist eine Liste von Schlagwörtern plus der Börsen, in denen
|
||||
-- gesucht wird, plus Ausschlussbegriffen. Jede Anzeige liegt genau einmal in
|
||||
@@ -474,6 +474,9 @@ CREATE TABLE IF NOT EXISTS icon_scanner_agents (
|
||||
keywords TEXT NOT NULL,
|
||||
sources TEXT NOT NULL,
|
||||
excludes TEXT NULL,
|
||||
-- Schlagwoerter mit Alarm: werden wie `keywords` gesucht und gezaehlt,
|
||||
-- ein Fund meldet sich zusaetzlich mit Ton und Pop-up.
|
||||
alarm_keywords TEXT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_by INT UNSIGNED NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
@@ -513,6 +516,9 @@ CREATE TABLE IF NOT EXISTS icon_scanner_matches (
|
||||
-- JSON-Array der gefundenen Schlagwörter
|
||||
hits TEXT NULL,
|
||||
status ENUM('neu','gesehen','in_bearbeitung','verworfen') NOT NULL DEFAULT 'neu',
|
||||
-- Alarm-Schlagwort getroffen; alarm_ack = jemand hat das Pop-up quittiert.
|
||||
alarm TINYINT(1) NOT NULL DEFAULT 0,
|
||||
alarm_ack TINYINT(1) NOT NULL DEFAULT 0,
|
||||
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),
|
||||
|
||||
@@ -18,6 +18,7 @@ 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 { AlarmWatcher } from './components/scanner/AlarmWatcher';
|
||||
import { Settings } from './components/settings/Settings';
|
||||
|
||||
/*
|
||||
@@ -197,6 +198,8 @@ export const App: React.FC = () => {
|
||||
{/* Faellige und liegengebliebene Leads melden sich von selbst -
|
||||
unabhaengig davon, welche Ansicht gerade offen ist. */}
|
||||
<ReminderWatcher onChanged={loadShared} />
|
||||
{/* Alarm-Schlagwörter des Scanners: Ton + Pop-up, in jeder Ansicht. */}
|
||||
<AlarmWatcher />
|
||||
</div>
|
||||
</div>
|
||||
</NavigationProvider>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { scannerApi, type ScannerAlarm } from '../../lib/api';
|
||||
import { useNavigation } from '../../lib/navigation';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { PrimaryButton, SecondaryButton } from '../ui/form';
|
||||
import { useToast } from '../Toast';
|
||||
import { AlarmBell, SOURCE_LABELS, formatAge, playAlarmSound } from './scannerUi';
|
||||
|
||||
/*
|
||||
* Alarm des Scanners: Treffer mit Alarm-Schlagwort melden sich mit Ton und
|
||||
* Pop-up - egal, welche Ansicht offen ist (deshalb hängt der Wächter in App,
|
||||
* nicht im Scanner-Dashboard; beim perinco-Scanner hörte man den Ton nur dort).
|
||||
*
|
||||
* Abgefragt wird jede Minute. Der Scanner läuft stündlich, ein Fund meldet
|
||||
* sich also spätestens eine Minute nach dem Lauf. Quittieren gilt für alle
|
||||
* Benutzer (serverseitig `alarm_ack`).
|
||||
*/
|
||||
|
||||
const POLL_MS = 60_000;
|
||||
|
||||
export const AlarmWatcher: React.FC = () => {
|
||||
const { toast } = useToast();
|
||||
const { setView } = useNavigation();
|
||||
const [alarms, setAlarms] = useState<ScannerAlarm[]>([]);
|
||||
/** Welche Alarme schon geklingelt haben - neue klingeln erneut. */
|
||||
const rung = useRef(new Set<number>());
|
||||
|
||||
const check = useCallback(async () => {
|
||||
try {
|
||||
const { alarms: list } = await scannerApi.alarms();
|
||||
setAlarms(list);
|
||||
if (list.some((alarm) => !rung.current.has(alarm.id))) {
|
||||
list.forEach((alarm) => rung.current.add(alarm.id));
|
||||
playAlarmSound();
|
||||
}
|
||||
} catch {
|
||||
/* kein Netz oder Sitzung abgelaufen - beim nächsten Mal */
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void check();
|
||||
const timer = setInterval(() => void check(), POLL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [check]);
|
||||
|
||||
if (alarms.length === 0) return null;
|
||||
|
||||
const acknowledge = async (goToScanner = false) => {
|
||||
const ids = alarms.map((alarm) => alarm.id);
|
||||
setAlarms([]);
|
||||
try {
|
||||
await scannerApi.ackAlarms(ids);
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Quittieren fehlgeschlagen.', 'error');
|
||||
}
|
||||
if (goToScanner) setView('Scanner');
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
size="md"
|
||||
onClose={() => void acknowledge()}
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<AlarmBell className="h-5 w-5" />
|
||||
{alarms.length === 1 ? 'Alarm-Treffer im Scanner' : `${alarms.length} Alarm-Treffer im Scanner`}
|
||||
</span>
|
||||
}
|
||||
footer={
|
||||
<>
|
||||
<SecondaryButton type="button" onClick={() => void acknowledge()}>
|
||||
Gesehen
|
||||
</SecondaryButton>
|
||||
<PrimaryButton type="button" onClick={() => void acknowledge(true)}>
|
||||
Zum Scanner
|
||||
</PrimaryButton>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<ul className="max-h-[50vh] space-y-2 overflow-y-auto">
|
||||
{alarms.map((alarm) => (
|
||||
<li key={alarm.id} className="rounded-lg border border-red-200 bg-red-50/50 p-3">
|
||||
<a
|
||||
href={alarm.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-semibold text-ink-900 hover:text-brand-700"
|
||||
>
|
||||
{alarm.title} ↗
|
||||
</a>
|
||||
<p className="mt-0.5 text-xs text-ink-500">
|
||||
{[alarm.company, alarm.location, SOURCE_LABELS[alarm.source]].filter(Boolean).join(' · ')} ·{' '}
|
||||
{formatAge(alarm.published_at)}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-ink-600">
|
||||
Agent <strong>{alarm.agent_name}</strong> · gefunden:{' '}
|
||||
{alarm.hits.map((hit) => (
|
||||
<span key={hit} className="mr-1 rounded-full border border-red-200 bg-surface px-1.5 py-0.5 text-red-700">
|
||||
{hit}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -11,7 +11,7 @@ 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';
|
||||
import { AlarmBell, SOURCE_LABELS, SOURCE_ORDER, TermInput } from './scannerUi';
|
||||
|
||||
/*
|
||||
* Scanner -> Suchagenten.
|
||||
@@ -26,6 +26,7 @@ const EMPTY: ScannerAgentInput = {
|
||||
keywords: [],
|
||||
sources: ['ba', 'stepstone', 'karriere'],
|
||||
excludes: [],
|
||||
alarm_keywords: [],
|
||||
is_active: true,
|
||||
};
|
||||
|
||||
@@ -145,6 +146,7 @@ export const ScannerAgents: React.FC = () => {
|
||||
keywords: agent.keywords,
|
||||
sources: agent.sources,
|
||||
excludes: agent.excludes,
|
||||
alarm_keywords: agent.alarm_keywords,
|
||||
is_active: agent.is_active,
|
||||
},
|
||||
})
|
||||
@@ -169,6 +171,19 @@ export const ScannerAgents: React.FC = () => {
|
||||
</span>
|
||||
))}
|
||||
</Row>
|
||||
{agent.alarm_keywords.length > 0 && (
|
||||
<Row label="Schlagwörter mit Alarm">
|
||||
{agent.alarm_keywords.map((term) => (
|
||||
<span
|
||||
key={term}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-amber-300 bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-800"
|
||||
>
|
||||
<AlarmBell className="h-3 w-3" />
|
||||
{term}
|
||||
</span>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
<Row label="Börsen">
|
||||
{agent.sources.map((id) => (
|
||||
<span
|
||||
@@ -209,7 +224,7 @@ export const ScannerAgents: React.FC = () => {
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
disabled={busy || !value?.name.trim() || !value?.keywords.length || !value?.sources.length}
|
||||
disabled={busy || !value?.name.trim() || !(value?.keywords.length || value?.alarm_keywords.length) || !value?.sources.length}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
Speichern
|
||||
@@ -236,6 +251,18 @@ export const ScannerAgents: React.FC = () => {
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Schlagwörter mit Alarm"
|
||||
hint="Werden gesucht und gezählt wie Schlagwörter. Wird eines gefunden, gibt es kurz einen Ton und ein Pop-up – in jeder Ansicht des CRM."
|
||||
>
|
||||
<TermInput
|
||||
value={value.alarm_keywords}
|
||||
onChange={(alarm_keywords) => patch({ alarm_keywords })}
|
||||
placeholder="z. B. Interim CFO"
|
||||
tone="alarm"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Ausschlussbegriffe" hint="Kommt einer davon in Titel oder Text vor, wird die Anzeige ignoriert.">
|
||||
<TermInput
|
||||
value={value.excludes}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useToast } from '../Toast';
|
||||
import { Modal } from '../ui/Modal';
|
||||
import { PrimaryButton, SecondaryButton, inputClass } from '../ui/form';
|
||||
import {
|
||||
AlarmBell,
|
||||
SOURCE_LABELS,
|
||||
SOURCE_ORDER,
|
||||
STATUS_LABELS,
|
||||
@@ -63,7 +64,10 @@ export const ScannerDashboard: React.FC = () => {
|
||||
const [knownOnly, setKnownOnly] = useState(false);
|
||||
const [showDiscarded, setShowDiscarded] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [sort, setSort] = useState<'known' | 'score' | 'published'>('known');
|
||||
const [sort, setSort] = useState<'known' | 'score' | 'published' | 'oldest'>('known');
|
||||
/** Alter der Anzeige: alle | bis 21 Tage | älter als 21 Tage */
|
||||
const [age, setAge] = useState<'' | 'recent' | 'old'>('');
|
||||
const [alarmOnly, setAlarmOnly] = useState(false);
|
||||
|
||||
const wasRunning = useRef(false);
|
||||
|
||||
@@ -77,6 +81,8 @@ export const ScannerDashboard: React.FC = () => {
|
||||
known: knownOnly ? 1 : undefined,
|
||||
status: showDiscarded ? 'alle' : undefined,
|
||||
q: search.trim() || undefined,
|
||||
age: age || undefined,
|
||||
alarm: alarmOnly ? 1 : undefined,
|
||||
sort,
|
||||
});
|
||||
setMatches(data.matches);
|
||||
@@ -84,7 +90,7 @@ export const ScannerDashboard: React.FC = () => {
|
||||
} catch (err) {
|
||||
toast(err instanceof Error ? err.message : 'Treffer nicht ladbar.', 'error');
|
||||
}
|
||||
}, [hours, agentId, source, minScore, knownOnly, showDiscarded, search, sort, toast]);
|
||||
}, [hours, agentId, source, minScore, knownOnly, showDiscarded, search, age, alarmOnly, sort, toast]);
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
try {
|
||||
@@ -168,7 +174,7 @@ export const ScannerDashboard: React.FC = () => {
|
||||
<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.
|
||||
Offene Stellen aus Jobbörsen in DACH – jedes Alter, gefiltert wird unten nach „Anzeigenalter“.
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
|
||||
<span
|
||||
@@ -236,7 +242,7 @@ export const ScannerDashboard: React.FC = () => {
|
||||
<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" className="w-44">
|
||||
<Filter label="Gefunden" className="w-44">
|
||||
<select value={hours} onChange={(event) => setHours(Number(event.target.value))} className={inputClass}>
|
||||
{PERIODS.map((period) => (
|
||||
<option key={period.value} value={period.value}>
|
||||
@@ -265,6 +271,13 @@ export const ScannerDashboard: React.FC = () => {
|
||||
))}
|
||||
</select>
|
||||
</Filter>
|
||||
<Filter label="Anzeigenalter" className="w-44">
|
||||
<select value={age} onChange={(event) => setAge(event.target.value as typeof age)} className={inputClass}>
|
||||
<option value="">alle</option>
|
||||
<option value="recent">bis 21 Tage</option>
|
||||
<option value="old">älter als 21 Tage</option>
|
||||
</select>
|
||||
</Filter>
|
||||
<Filter label="Ab %" className="w-20">
|
||||
<input
|
||||
type="number"
|
||||
@@ -281,6 +294,7 @@ export const ScannerDashboard: React.FC = () => {
|
||||
<option value="known">bekannte Firmen zuerst</option>
|
||||
<option value="score">beste Quote</option>
|
||||
<option value="published">neueste Anzeige</option>
|
||||
<option value="oldest">älteste Anzeige</option>
|
||||
</select>
|
||||
</Filter>
|
||||
<Filter label="Suche" className="w-44">
|
||||
@@ -296,6 +310,10 @@ export const ScannerDashboard: React.FC = () => {
|
||||
<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={alarmOnly} onChange={(event) => setAlarmOnly(event.target.checked)} />
|
||||
nur Alarm
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" checked={showDiscarded} onChange={(event) => setShowDiscarded(event.target.checked)} />
|
||||
verworfene zeigen
|
||||
@@ -340,7 +358,10 @@ export const ScannerDashboard: React.FC = () => {
|
||||
} ${match.status === 'verworfen' ? 'opacity-60' : ''}`}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<ScoreBox score={match.score} />
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ScoreBox score={match.score} />
|
||||
{match.alarm && <AlarmBell />}
|
||||
</div>
|
||||
</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">
|
||||
|
||||
@@ -78,12 +78,58 @@ export const formatAge = (value: string | null) => {
|
||||
|
||||
export const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
|
||||
const BELL_ICON =
|
||||
'M12 22a2.5 2.5 0 0 0 2.5-2.5h-5A2.5 2.5 0 0 0 12 22Zm7-5V11a7 7 0 0 0-5.5-6.8V3a1.5 1.5 0 0 0-3 0v1.2A7 7 0 0 0 5 11v6l-2 2v1h18v-1l-2-2Z';
|
||||
|
||||
/** Glocke für Treffer mit Alarm-Schlagwort. */
|
||||
export const AlarmBell: React.FC<{ className?: string }> = ({ className = 'h-4 w-4' }) => (
|
||||
<span title="Alarm-Schlagwort" className="inline-flex text-red-600">
|
||||
<svg viewBox="0 0 24 24" className={className} fill="currentColor" aria-hidden="true">
|
||||
<path d={BELL_ICON} />
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
|
||||
/**
|
||||
* Kurzer Zweiklang über WebAudio - ohne Audiodatei.
|
||||
*
|
||||
* Browser spielen Ton erst, nachdem auf der Seite einmal geklickt oder getippt
|
||||
* wurde (Autoplay-Regel). Klappt es nicht, bleibt es still; das Pop-up kommt
|
||||
* trotzdem.
|
||||
*/
|
||||
export function playAlarmSound() {
|
||||
try {
|
||||
const AudioContextClass =
|
||||
window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
|
||||
if (!AudioContextClass) return;
|
||||
const context = new AudioContextClass();
|
||||
const tone = (frequency: number, start: number) => {
|
||||
const oscillator = context.createOscillator();
|
||||
const gain = context.createGain();
|
||||
oscillator.type = 'sine';
|
||||
oscillator.frequency.value = frequency;
|
||||
gain.gain.setValueAtTime(0.0001, context.currentTime + start);
|
||||
gain.gain.exponentialRampToValueAtTime(0.3, context.currentTime + start + 0.02);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, context.currentTime + start + 0.35);
|
||||
oscillator.connect(gain).connect(context.destination);
|
||||
oscillator.start(context.currentTime + start);
|
||||
oscillator.stop(context.currentTime + start + 0.4);
|
||||
};
|
||||
tone(880, 0);
|
||||
tone(1175, 0.18);
|
||||
// Nicht geschlossene Kontexte zählen gegen das Browserlimit.
|
||||
setTimeout(() => void context.close().catch(() => {}), 1000);
|
||||
} catch {
|
||||
/* kein Ton möglich - das Pop-up reicht */
|
||||
}
|
||||
}
|
||||
|
||||
/** 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';
|
||||
tone?: 'brand' | 'red' | 'alarm';
|
||||
}> = ({ value, onChange, placeholder, tone = 'brand' }) => {
|
||||
const [draft, setDraft] = React.useState('');
|
||||
|
||||
@@ -99,12 +145,17 @@ export const TermInput: React.FC<{
|
||||
};
|
||||
|
||||
const chip =
|
||||
tone === 'red' ? 'border-red-200 bg-red-50 text-red-700' : 'border-brand-200 bg-brand-50 text-brand-700';
|
||||
tone === 'red'
|
||||
? 'border-red-200 bg-red-50 text-red-700'
|
||||
: tone === 'alarm'
|
||||
? 'border-amber-300 bg-amber-50 text-amber-800'
|
||||
: '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}`}>
|
||||
{tone === 'alarm' && <AlarmBell className="h-3 w-3" />}
|
||||
{term}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -7,8 +7,8 @@ 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 der kostenlose API-Schlüssel für Adzuna. Die
|
||||
* Wann und wie oft der Scanner selbst sucht, und der kostenlose API-Schlüssel
|
||||
* für Adzuna. Die
|
||||
* Schlüssel kommen - wie der OpenAI-Schlüssel - nie zurück zum Browser.
|
||||
*/
|
||||
|
||||
@@ -108,17 +108,10 @@ export const ScannerSettings: React.FC = () => {
|
||||
))}
|
||||
</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>
|
||||
<p className="mt-3 text-xs text-ink-400">
|
||||
Gesucht wird ohne Altersgrenze – das Alter der Anzeigen filtert das Dashboard („Anzeigenalter“).
|
||||
</p>
|
||||
|
||||
<PrimaryButton
|
||||
type="button"
|
||||
|
||||
+25
-3
@@ -479,6 +479,8 @@ export interface ScannerAgent {
|
||||
keywords: string[];
|
||||
sources: ScannerSourceId[];
|
||||
excludes: string[];
|
||||
/** Schlagwörter, deren Fund mit Ton und Pop-up gemeldet wird. */
|
||||
alarm_keywords: string[];
|
||||
is_active: boolean;
|
||||
matches: number;
|
||||
}
|
||||
@@ -491,8 +493,21 @@ export interface ScannerSchedule {
|
||||
to: string;
|
||||
/** Minuten */
|
||||
interval: number;
|
||||
/** höchstes Alter einer Anzeige in Tagen */
|
||||
maxAge: number;
|
||||
}
|
||||
|
||||
export interface ScannerAlarm {
|
||||
id: number;
|
||||
score: number;
|
||||
hits: string[];
|
||||
created_at: string;
|
||||
agent_name: string;
|
||||
source: ScannerSourceId;
|
||||
title: string;
|
||||
company: string | null;
|
||||
location: string | null;
|
||||
url: string;
|
||||
published_at: string | null;
|
||||
company_id: number | null;
|
||||
}
|
||||
|
||||
export interface ScannerSource {
|
||||
@@ -525,6 +540,8 @@ export interface ScannerMatch {
|
||||
score: number;
|
||||
hits: string[];
|
||||
status: ScannerMatchStatus;
|
||||
/** Alarm-Schlagwort getroffen */
|
||||
alarm: boolean;
|
||||
created_at: string;
|
||||
agent_id: number;
|
||||
agent_name: string;
|
||||
@@ -549,7 +566,10 @@ export interface ScannerSecrets {
|
||||
adzunaKey: string | null;
|
||||
}
|
||||
|
||||
export type ScannerAgentInput = Pick<ScannerAgent, 'name' | 'keywords' | 'sources' | 'excludes' | 'is_active'>;
|
||||
export type ScannerAgentInput = Pick<
|
||||
ScannerAgent,
|
||||
'name' | 'keywords' | 'alarm_keywords' | 'sources' | 'excludes' | 'is_active'
|
||||
>;
|
||||
|
||||
const put = <T>(path: string, body: unknown) =>
|
||||
apiRequest<T>(path, { method: 'PUT', body: JSON.stringify(body) });
|
||||
@@ -563,6 +583,8 @@ export const scannerApi = {
|
||||
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 }),
|
||||
alarms: () => get<{ alarms: ScannerAlarm[] }>('/scanner/alarms'),
|
||||
ackAlarms: (ids: number[]) => post<{ ok: true }>('/scanner/alarms/ack', { ids }),
|
||||
scan: () => post<{ started: true }>('/scanner/scan'),
|
||||
settings: () => get<{ settings: ScannerSchedule; secrets: ScannerSecrets }>('/scanner/settings'),
|
||||
saveSettings: (changes: Partial<ScannerSchedule> & Partial<Record<keyof ScannerSecrets, string>>) =>
|
||||
|
||||
Reference in New Issue
Block a user