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:
schakal00783
2026-09-29 13:28:29 +02:00
co-authored by Claude Opus 5.5
parent b34a1968df
commit 1ad774fdd2
16 changed files with 978 additions and 625 deletions
+7 -2
View File
@@ -212,7 +212,8 @@ Kontakt (*Kontakt → Stundensatz vor Ort / remote*).
## Scanner ## Scanner
Sucht in Jobbörsen (Agentur für Arbeit, StepStone, karriere.at, Adzuna, 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). siehe [12 Scanner](12-scanner.md).
1. **Scanner → Suchagenten → + Neuer Suchagent.** Name, Schlagwörter (Enter 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, zur Börse. Status setzen: *In Bearbeitung*, wenn sich jemand kümmert,
*Verworfen*, wenn es nicht passt. *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 Schlüssel für Adzuna pflegt ein Administrator unter
*Einstellungen → Scanner*. *Einstellungen → Scanner*.
+44 -10
View File
@@ -1,8 +1,12 @@
# 12 Scanner # 12 Scanner
Der Scanner durchsucht Jobbörsen nach offenen Stellen, die ICON besetzen kann – Der Scanner durchsucht Jobbörsen nach offenen Stellen, die ICON besetzen kann –
Festanstellungen ebenso wie Projekte. Region immer DACH, Anzeigen höchstens Festanstellungen ebenso wie Projekte. Region immer DACH. Eingeführt am 29.09.2026.
21 Tage alt (einstellbar). 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 Menü: **Scanner** (Dashboard) mit dem Unterpunkt **Suchagenten**; Zeitplan und
API-Schlüssel unter **Einstellungen → Scanner** (nur Administratoren). 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 | | 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 | | `stepstone` | StepStone.de | DE | HTML der Trefferliste, `data-at`-Attribute | keiner |
| `karriere` | karriere.at | AT | HTML der Trefferliste, `.m-jobsListItem` | 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 | | `freelancermap` | freelancermap | DE | HTML, Selektoren wie im perinco-Scanner | keiner |
Adzuna ist die einzige Quelle für die Schweiz (jobs.ch blockt mit CAPTCHA). 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) loop je Börse × Schlagwort (1,5 s Pause)
P->>B: Suche P->>B: Suche
B-->>P: Anzeigen B-->>P: Anzeigen
P->>P: älter als maxAge? verwerfen
end end
loop je Anzeige loop je Anzeige
P->>DB: speichern (eindeutig je Börse + Kennung), Firma abgleichen 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 Das kostet nichts und ist nachvollziehbar; OpenAI wird vom Scanner nicht
aufgerufen. 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 ## Bekannte Unternehmen
Jede Anzeige wird mit `icon_companies` abgeglichen. Beide Namen werden 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 ## Zeitplan
Einstellungen → Scanner. Vorgabe: **stündlich, Mo–Fr, 06:00–20:00 Uhr**, Einstellungen → Scanner. Vorgabe: **stündlich, Mo–Fr, 06:00–20:00 Uhr**.
Anzeigen höchstens **21 Tage** alt.
```mermaid ```mermaid
flowchart TD flowchart TD
@@ -151,7 +181,6 @@ Liste neu, sobald er fertig ist.
| `scanner_days` | `1,2,3,4,5` | ISO-Wochentage, 1 = Montag | | `scanner_days` | `1,2,3,4,5` | ISO-Wochentage, 1 = Montag |
| `scanner_from` / `scanner_to` | `06:00` / `20:00` | Fenster, deutsche Zeit | | `scanner_from` / `scanner_to` | `06:00` / `20:00` | Fenster, deutsche Zeit |
| `scanner_interval` | `60` | Minuten, 15 bis 1440 | | `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_adzuna_id`, `scanner_adzuna_key` | leer | gehen nie im Klartext zum Browser |
| `scanner_last_scan_at` | – | Beginn des letzten Laufs (ISO) | | `scanner_last_scan_at` | – | Beginn des letzten Laufs (ISO) |
@@ -168,6 +197,7 @@ erDiagram
text keywords "JSON-Array" text keywords "JSON-Array"
text sources "JSON-Array der Quellen-Ids" text sources "JSON-Array der Quellen-Ids"
text excludes "JSON-Array" text excludes "JSON-Array"
text alarm_keywords "JSON-Array, Ton + Pop-up"
tinyint is_active tinyint is_active
} }
icon_scanner_jobs { icon_scanner_jobs {
@@ -189,6 +219,8 @@ erDiagram
tinyint score "0-100" tinyint score "0-100"
text hits "gefundene Schlagwörter" text hits "gefundene Schlagwörter"
enum status "neu gesehen in_bearbeitung verworfen" enum status "neu gesehen in_bearbeitung verworfen"
tinyint alarm "Alarm-Schlagwort getroffen"
tinyint alarm_ack "Pop-up quittiert (für alle)"
} }
icon_scanner_runs { icon_scanner_runs {
int id 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 | `/overview` | alle | Kennzahlen, Quellen samt Ergebnis des letzten Laufs, Zeitplan, läuft gerade ein Lauf |
| GET | `/agents` | alle | Suchagenten mit Trefferzahl | | 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) | | PUT | `/agents/:id` | alle | ändern (gleicher Körper) |
| DELETE | `/agents/:id` | alle | löschen, Treffer gehen mit | | 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 }` | | PATCH | `/matches/:id` | alle | `{ status }` |
| POST | `/scan` | alle | Lauf starten, `202`; `409`, wenn schon einer läuft | | POST | `/scan` | alle | Lauf starten, `202`; `409`, wenn schon einer läuft |
| GET | `/settings` | Admin | Zeitplan, Schlüssel verkürzt | | GET | `/settings` | Admin | Zeitplan, Schlüssel verkürzt |
+13
View File
@@ -185,6 +185,19 @@ async function migrateColumns() {
'email', 'email',
'ALTER TABLE icon_suspects ADD COLUMN email VARCHAR(190) NULL AFTER company', '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', 'icon_cv_imports',
'photo', 'photo',
+80 -24
View File
@@ -24,6 +24,8 @@ const router = express.Router();
router.use(requireAuth); router.use(requireAuth);
const STATUSES = ['neu', 'gesehen', 'in_bearbeitung', 'verworfen']; const STATUSES = ['neu', 'gesehen', 'in_bearbeitung', 'verworfen'];
/** Grenze des Filters "Anzeigenalter" im Dashboard. */
const AGE_DAYS = 21;
const cleanList = (value) => const cleanList = (value) =>
[...new Set((Array.isArray(value) ? value : []).map(stripQuotes).filter(Boolean))].slice(0, 50); [...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). // Nur Quellen, die es noch gibt (Jooble ist am 29.09.2026 entfallen).
sources: parseList(row.sources).filter((id) => SOURCE_IDS.includes(id)), sources: parseList(row.sources).filter((id) => SOURCE_IDS.includes(id)),
excludes: parseList(row.excludes), excludes: parseList(row.excludes),
alarm_keywords: parseList(row.alarm_keywords),
is_active: Boolean(row.is_active), is_active: Boolean(row.is_active),
matches: Number(row.matches ?? 0), matches: Number(row.matches ?? 0),
created_at: row.created_at, created_at: row.created_at,
@@ -118,27 +121,33 @@ function readAgent(body) {
const keywords = cleanList(body?.keywords); const keywords = cleanList(body?.keywords);
const sources = cleanList(body?.sources).filter((id) => SOURCE_IDS.includes(id)); const sources = cleanList(body?.sources).filter((id) => SOURCE_IDS.includes(id));
const excludes = cleanList(body?.excludes); const excludes = cleanList(body?.excludes);
const alarmKeywords = cleanList(body?.alarm_keywords);
if (!name) return { error: 'Bitte einen Namen angeben.' }; 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.' }; 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) => { router.post('/agents', async (req, res, next) => {
const agent = readAgent(req.body); const agent = readAgent(req.body);
if (agent.error) return res.status(400).json({ error: agent.error }); if (agent.error) return res.status(400).json({ error: agent.error });
try { try {
const [result] = await pool.query( const [result] = await pool.query(
`INSERT INTO icon_scanner_agents (name, keywords, sources, excludes, is_active, created_by) `INSERT INTO icon_scanner_agents (name, keywords, alarm_keywords, sources, excludes, is_active, created_by)
VALUES (?, ?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?, ?, ?)`,
[ [...agentValues(agent), req.user.id]
agent.name,
JSON.stringify(agent.keywords),
JSON.stringify(agent.sources),
JSON.stringify(agent.excludes),
agent.is_active ? 1 : 0,
req.user.id,
]
); );
await logHistory({ await logHistory({
entityType: 'system', entityType: 'system',
@@ -160,16 +169,9 @@ router.put('/agents/:id', async (req, res, next) => {
try { try {
const [result] = await pool.query( const [result] = await pool.query(
`UPDATE icon_scanner_agents `UPDATE icon_scanner_agents
SET name = ?, keywords = ?, sources = ?, excludes = ?, is_active = ? SET name = ?, keywords = ?, alarm_keywords = ?, sources = ?, excludes = ?, is_active = ?
WHERE id = ?`, WHERE id = ?`,
[ [...agentValues(agent), Number(req.params.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.' }); 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)]); 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 ?)'); where.push('(j.title LIKE ? OR j.company LIKE ? OR j.location LIKE ?)');
params.push(`%${q}%`, `%${q}%`, `%${q}%`); 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 = const order =
req.query.sort === 'score' req.query.sort === 'score'
? 'm.score DESC, m.created_at DESC' ? 'm.score DESC, m.created_at DESC'
: req.query.sort === 'published' : req.query.sort === 'published'
? 'j.published_at IS NULL, j.published_at DESC' ? 'j.published_at IS NULL, j.published_at DESC'
: // bekannte Firmen zuerst, dann neueste : req.query.sort === 'oldest'
'known DESC, m.created_at DESC'; ? '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 limit = Math.min(Math.max(Number(req.query.limit) || 300, 1), 1000);
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : ''; const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
try { try {
const [rows] = await pool.query( 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, 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.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.employment_type, j.url, j.description, j.published_at,
@@ -276,6 +294,7 @@ router.get('/matches', async (req, res, next) => {
matches: rows.map((row) => ({ matches: rows.map((row) => ({
...row, ...row,
hits: parseList(row.hits), hits: parseList(row.hits),
alarm: Boolean(row.alarm),
known: Boolean(row.known), known: Boolean(row.known),
is_customer: row.is_customer == null ? null : Boolean(row.is_customer), 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. * Jetzt scannen - laeuft im Hintergrund, die Oberflaeche fragt /overview ab.
* Ein Lauf dauert je nach Zahl der Schlagwoerter Minuten; darauf zu warten * Ein Lauf dauert je nach Zahl der Schlagwoerter Minuten; darauf zu warten
+24 -4
View File
@@ -41,8 +41,23 @@ export function containsTerm(text, term) {
} }
/** /**
* @returns {{ score: number, hits: string[] } | null} null = ausgeschlossen * Alle Suchbegriffe eines Agenten: Schlagwoerter plus Alarm-Schlagwoerter,
* oder kein einziges Schlagwort gefunden * 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) { export function scoreJob(agent, job) {
const title = job.title ?? ''; const title = job.title ?? '';
@@ -50,7 +65,7 @@ export function scoreJob(agent, job) {
if ((agent.excludes ?? []).some((term) => containsTerm(all, term))) return null; if ((agent.excludes ?? []).some((term) => containsTerm(all, term))) return null;
const keywords = agent.keywords ?? []; const keywords = agentTerms(agent);
if (keywords.length === 0) return null; if (keywords.length === 0) return null;
let points = 0; let points = 0;
@@ -66,7 +81,12 @@ export function scoreJob(agent, job) {
} }
if (hits.length === 0) return null; 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())),
};
} }
/* /*
+11 -13
View File
@@ -1,7 +1,7 @@
import { pool } from '../db.js'; import { pool } from '../db.js';
import { SOURCES, sourceReady } from './sources.js'; import { SOURCES, sourceReady } from './sources.js';
import { findCompany, normalizeCompany, scoreJob, stripQuotes } from './matching.js'; import { agentTerms, findCompany, normalizeCompany, scoreJob, stripQuotes } from './matching.js';
import { loadScannerSecrets, loadScannerSettings, setLastScanAt } from './settings.js'; import { loadScannerSecrets, setLastScanAt } from './settings.js';
/* /*
* Ein Suchlauf. * Ein Suchlauf.
@@ -9,7 +9,7 @@ import { loadScannerSecrets, loadScannerSettings, setLastScanAt } from './settin
* 1. aktive Agenten laden * 1. aktive Agenten laden
* 2. je Boerse die Schlagwoerter aller Agenten zusammenfuehren - dasselbe * 2. je Boerse die Schlagwoerter aller Agenten zusammenfuehren - dasselbe
* Wort wird nur einmal abgefragt, egal wie viele Agenten es tragen * 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 * 4. Anzeigen speichern, Firma gegen das CRM abgleichen
* 5. jede Anzeige gegen jeden Agenten bewerten, der diese Boerse durchsucht * 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 [runResult] = await pool.query('INSERT INTO icon_scanner_runs (trigger_kind) VALUES (?)', [trigger]);
const runId = runResult.insertId; const runId = runResult.insertId;
const settings = await loadScannerSettings();
const secrets = await loadScannerSecrets(); const secrets = await loadScannerSecrets();
const agents = await loadActiveAgents(); const agents = await loadActiveAgents();
// Boerse -> Schlagwoerter (ohne Doppelte, Gross/klein egal) // Boerse -> Schlagwoerter inkl. Alarm-Schlagwoerter (ohne Doppelte)
const plan = new Map(); const plan = new Map();
for (const agent of agents) { for (const agent of agents) {
for (const source of agent.sources) { for (const source of agent.sources) {
if (!SOURCES[source] || !sourceReady(source, secrets)) continue; if (!SOURCES[source] || !sourceReady(source, secrets)) continue;
const words = plan.get(source) ?? new Map(); 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); plan.set(source, words);
} }
} }
const perSource = {}; const perSource = {};
const jobs = new Map(); const jobs = new Map();
const oldest = Date.now() - settings.maxAge * 24 * 3_600_000;
for (const [source, words] of plan) { for (const [source, words] of plan) {
const stats = { ok: true, count: 0, errors: [] }; const stats = { ok: true, count: 0, errors: [] };
@@ -82,11 +80,10 @@ async function execute(trigger) {
if (!first) await sleep(REQUEST_GAP_MS); if (!first) await sleep(REQUEST_GAP_MS);
first = false; first = false;
try { 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) { 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); jobs.set(`${job.source}:${job.externalId}`, job);
stats.count += 1; stats.count += 1;
} }
@@ -111,8 +108,8 @@ async function execute(trigger) {
const result = scoreJob(agent, job); const result = scoreJob(agent, job);
if (!result) continue; if (!result) continue;
const [insert] = await pool.query( const [insert] = await pool.query(
`INSERT IGNORE INTO icon_scanner_matches (agent_id, job_id, score, hits) VALUES (?, ?, ?, ?)`, `INSERT IGNORE INTO icon_scanner_matches (agent_id, job_id, score, hits, alarm) VALUES (?, ?, ?, ?, ?)`,
[agent.id, jobId, result.score, JSON.stringify(result.hits)] [agent.id, jobId, result.score, JSON.stringify(result.hits), result.alarm ? 1 : 0]
); );
matchesNew += insert.affectedRows; matchesNew += insert.affectedRows;
} }
@@ -153,6 +150,7 @@ async function loadActiveAgents() {
return rows.map((row) => ({ return rows.map((row) => ({
id: row.id, id: row.id,
keywords: parseList(row.keywords), keywords: parseList(row.keywords),
alarmKeywords: parseList(row.alarm_keywords),
sources: parseList(row.sources), sources: parseList(row.sources),
excludes: parseList(row.excludes), excludes: parseList(row.excludes),
})); }));
+2 -7
View File
@@ -5,7 +5,7 @@ import { getSetting, setSetting } from '../db.js';
* *
* Alles liegt in icon_settings unter dem Praefix `scanner_`. Die Vorgaben * Alles liegt in icon_settings unter dem Praefix `scanner_`. Die Vorgaben
* entsprechen dem Wunsch vom 29.09.2026: stuendlich, Mo-Fr, 06-20 Uhr, * 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 = { export const DEFAULTS = {
@@ -15,7 +15,6 @@ export const DEFAULTS = {
from: '06:00', from: '06:00',
to: '20:00', to: '20:00',
interval: 60, interval: 60,
maxAge: 21,
}; };
/** Zugangsdaten der kostenlosen APIs - gehen nie im Klartext zum Browser. */ /** 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$/; const TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
export async function loadScannerSettings() { 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_active'),
getSetting('scanner_days'), getSetting('scanner_days'),
getSetting('scanner_from'), getSetting('scanner_from'),
getSetting('scanner_to'), getSetting('scanner_to'),
getSetting('scanner_interval'), getSetting('scanner_interval'),
getSetting('scanner_max_age'),
]); ]);
const parsedDays = (days ?? '') const parsedDays = (days ?? '')
@@ -47,7 +45,6 @@ export async function loadScannerSettings() {
from: TIME.test(from ?? '') ? from : DEFAULTS.from, from: TIME.test(from ?? '') ? from : DEFAULTS.from,
to: TIME.test(to ?? '') ? to : DEFAULTS.to, to: TIME.test(to ?? '') ? to : DEFAULTS.to,
interval: clampInt(interval, 15, 24 * 60, DEFAULTS.interval), 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 (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.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([ await Promise.all([
setSetting('scanner_active', String(next.active)), setSetting('scanner_active', String(next.active)),
@@ -77,7 +73,6 @@ export async function saveScannerSettings(input) {
setSetting('scanner_from', next.from), setSetting('scanner_from', next.from),
setSetting('scanner_to', next.to), setSetting('scanner_to', next.to),
setSetting('scanner_interval', String(next.interval)), setSetting('scanner_interval', String(next.interval)),
setSetting('scanner_max_age', String(next.maxAge)),
]); ]);
// Schluessel: undefined = unveraendert, '' = entfernen // Schluessel: undefined = unveraendert, '' = entfernen
+3 -4
View File
@@ -129,10 +129,10 @@ const slug = (keyword) =>
* Bundesagentur fuer Arbeit - offizielle Jobsuche-API (JSON, ohne Anmeldung; * Bundesagentur fuer Arbeit - offizielle Jobsuche-API (JSON, ohne Anmeldung;
* der Schluessel "jobboerse-jobsuche" ist der oeffentliche der Jobsuche-App). * der Schluessel "jobboerse-jobsuche" ist der oeffentliche der Jobsuche-App).
* ---------------------------------------------------------------------- */ * ---------------------------------------------------------------------- */
async function fetchBa(keyword, { maxAge }) { async function fetchBa(keyword) {
const url = const url =
'https://rest.arbeitsagentur.de/jobboerse/jobsuche-service/pc/v6/jobs?' + '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 data = await request(url, { json: true, headers: { 'X-API-Key': 'jobboerse-jobsuche' } });
const list = data?.ergebnisliste ?? data?.stellenangebote ?? []; const list = data?.ergebnisliste ?? data?.stellenangebote ?? [];
@@ -264,7 +264,7 @@ async function fetchKarriere(keyword) {
* Adzuna - kostenloser API-Schluessel (developer.adzuna.com), sammelt Anzeigen * Adzuna - kostenloser API-Schluessel (developer.adzuna.com), sammelt Anzeigen
* vieler Boersen fuer DE, AT und CH. Die einzige Quelle fuer die Schweiz. * 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 = []; const jobs = [];
for (const country of ['de', 'at', 'ch']) { for (const country of ['de', 'at', 'ch']) {
const url = const url =
@@ -273,7 +273,6 @@ async function fetchAdzuna(keyword, { maxAge, secrets }) {
app_id: secrets.adzunaId, app_id: secrets.adzunaId,
app_key: secrets.adzunaKey, app_key: secrets.adzunaKey,
what_phrase: keyword, what_phrase: keyword,
max_days_old: String(maxAge),
results_per_page: '50', results_per_page: '50',
sort_by: 'date', sort_by: 'date',
'content-type': 'application/json', 'content-type': 'application/json',
+7 -1
View File
@@ -457,7 +457,7 @@ CREATE TABLE IF NOT EXISTS icon_settings (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- --------------------------------------------------------------------------- -- ---------------------------------------------------------------------------
-- Scanner: Stellenanzeigen aus Jobbörsen (DACH, höchstens 21 Tage alt) -- Scanner: Stellenanzeigen aus Jobbörsen (DACH, jedes Alter)
-- --
-- Ein Suchagent ist eine Liste von Schlagwörtern plus der Börsen, in denen -- Ein Suchagent ist eine Liste von Schlagwörtern plus der Börsen, in denen
-- gesucht wird, plus Ausschlussbegriffen. Jede Anzeige liegt genau einmal in -- 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, keywords TEXT NOT NULL,
sources TEXT NOT NULL, sources TEXT NOT NULL,
excludes TEXT 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, is_active TINYINT(1) NOT NULL DEFAULT 1,
created_by INT UNSIGNED NULL, created_by INT UNSIGNED NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, 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 -- JSON-Array der gefundenen Schlagwörter
hits TEXT NULL, hits TEXT NULL,
status ENUM('neu','gesehen','in_bearbeitung','verworfen') NOT NULL DEFAULT 'neu', 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, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_icon_scanner_matches (agent_id, job_id), UNIQUE KEY uq_icon_scanner_matches (agent_id, job_id),
+3
View File
@@ -18,6 +18,7 @@ import { Projects } from './components/projects/Projects';
import { History } from './components/history/History'; import { History } from './components/history/History';
import { ScannerDashboard } from './components/scanner/ScannerDashboard'; import { ScannerDashboard } from './components/scanner/ScannerDashboard';
import { ScannerAgents } from './components/scanner/ScannerAgents'; import { ScannerAgents } from './components/scanner/ScannerAgents';
import { AlarmWatcher } from './components/scanner/AlarmWatcher';
import { Settings } from './components/settings/Settings'; import { Settings } from './components/settings/Settings';
/* /*
@@ -197,6 +198,8 @@ export const App: React.FC = () => {
{/* Faellige und liegengebliebene Leads melden sich von selbst - {/* Faellige und liegengebliebene Leads melden sich von selbst -
unabhaengig davon, welche Ansicht gerade offen ist. */} unabhaengig davon, welche Ansicht gerade offen ist. */}
<ReminderWatcher onChanged={loadShared} /> <ReminderWatcher onChanged={loadShared} />
{/* Alarm-Schlagwörter des Scanners: Ton + Pop-up, in jeder Ansicht. */}
<AlarmWatcher />
</div> </div>
</div> </div>
</NavigationProvider> </NavigationProvider>
+110
View File
@@ -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>
);
};
+29 -2
View File
@@ -11,7 +11,7 @@ import { useNavigation } from '../../lib/navigation';
import { useToast } from '../Toast'; import { useToast } from '../Toast';
import { ConfirmDialog, Modal } from '../ui/Modal'; import { ConfirmDialog, Modal } from '../ui/Modal';
import { Field, PrimaryButton, SecondaryButton, inputClass } from '../ui/form'; 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. * Scanner -> Suchagenten.
@@ -26,6 +26,7 @@ const EMPTY: ScannerAgentInput = {
keywords: [], keywords: [],
sources: ['ba', 'stepstone', 'karriere'], sources: ['ba', 'stepstone', 'karriere'],
excludes: [], excludes: [],
alarm_keywords: [],
is_active: true, is_active: true,
}; };
@@ -145,6 +146,7 @@ export const ScannerAgents: React.FC = () => {
keywords: agent.keywords, keywords: agent.keywords,
sources: agent.sources, sources: agent.sources,
excludes: agent.excludes, excludes: agent.excludes,
alarm_keywords: agent.alarm_keywords,
is_active: agent.is_active, is_active: agent.is_active,
}, },
}) })
@@ -169,6 +171,19 @@ export const ScannerAgents: React.FC = () => {
</span> </span>
))} ))}
</Row> </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"> <Row label="Börsen">
{agent.sources.map((id) => ( {agent.sources.map((id) => (
<span <span
@@ -209,7 +224,7 @@ export const ScannerAgents: React.FC = () => {
</SecondaryButton> </SecondaryButton>
<PrimaryButton <PrimaryButton
type="button" 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()} onClick={() => void save()}
> >
Speichern Speichern
@@ -236,6 +251,18 @@ export const ScannerAgents: React.FC = () => {
/> />
</Field> </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."> <Field label="Ausschlussbegriffe" hint="Kommt einer davon in Titel oder Text vor, wird die Anzeige ignoriert.">
<TermInput <TermInput
value={value.excludes} value={value.excludes}
+26 -5
View File
@@ -12,6 +12,7 @@ import { useToast } from '../Toast';
import { Modal } from '../ui/Modal'; import { Modal } from '../ui/Modal';
import { PrimaryButton, SecondaryButton, inputClass } from '../ui/form'; import { PrimaryButton, SecondaryButton, inputClass } from '../ui/form';
import { import {
AlarmBell,
SOURCE_LABELS, SOURCE_LABELS,
SOURCE_ORDER, SOURCE_ORDER,
STATUS_LABELS, STATUS_LABELS,
@@ -63,7 +64,10 @@ export const ScannerDashboard: React.FC = () => {
const [knownOnly, setKnownOnly] = useState(false); const [knownOnly, setKnownOnly] = useState(false);
const [showDiscarded, setShowDiscarded] = useState(false); const [showDiscarded, setShowDiscarded] = useState(false);
const [search, setSearch] = useState(''); 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); const wasRunning = useRef(false);
@@ -77,6 +81,8 @@ export const ScannerDashboard: React.FC = () => {
known: knownOnly ? 1 : undefined, known: knownOnly ? 1 : undefined,
status: showDiscarded ? 'alle' : undefined, status: showDiscarded ? 'alle' : undefined,
q: search.trim() || undefined, q: search.trim() || undefined,
age: age || undefined,
alarm: alarmOnly ? 1 : undefined,
sort, sort,
}); });
setMatches(data.matches); setMatches(data.matches);
@@ -84,7 +90,7 @@ export const ScannerDashboard: React.FC = () => {
} catch (err) { } catch (err) {
toast(err instanceof Error ? err.message : 'Treffer nicht ladbar.', 'error'); 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 () => { const loadOverview = useCallback(async () => {
try { try {
@@ -168,7 +174,7 @@ export const ScannerDashboard: React.FC = () => {
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<p className="text-sm text-ink-500"> <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> </p>
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs"> <div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<span <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"> <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> <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}> <select value={hours} onChange={(event) => setHours(Number(event.target.value))} className={inputClass}>
{PERIODS.map((period) => ( {PERIODS.map((period) => (
<option key={period.value} value={period.value}> <option key={period.value} value={period.value}>
@@ -265,6 +271,13 @@ export const ScannerDashboard: React.FC = () => {
))} ))}
</select> </select>
</Filter> </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"> <Filter label="Ab %" className="w-20">
<input <input
type="number" type="number"
@@ -281,6 +294,7 @@ export const ScannerDashboard: React.FC = () => {
<option value="known">bekannte Firmen zuerst</option> <option value="known">bekannte Firmen zuerst</option>
<option value="score">beste Quote</option> <option value="score">beste Quote</option>
<option value="published">neueste Anzeige</option> <option value="published">neueste Anzeige</option>
<option value="oldest">älteste Anzeige</option>
</select> </select>
</Filter> </Filter>
<Filter label="Suche" className="w-44"> <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)} /> <input type="checkbox" checked={knownOnly} onChange={(event) => setKnownOnly(event.target.checked)} />
nur bekannte Firmen nur bekannte Firmen
</label> </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"> <label className="flex items-center gap-2">
<input type="checkbox" checked={showDiscarded} onChange={(event) => setShowDiscarded(event.target.checked)} /> <input type="checkbox" checked={showDiscarded} onChange={(event) => setShowDiscarded(event.target.checked)} />
verworfene zeigen verworfene zeigen
@@ -340,7 +358,10 @@ export const ScannerDashboard: React.FC = () => {
} ${match.status === 'verworfen' ? 'opacity-60' : ''}`} } ${match.status === 'verworfen' ? 'opacity-60' : ''}`}
> >
<td className="px-4 py-3"> <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>
<td className="px-4 py-3 font-semibold text-ink-900">{match.agent_name}</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"> <td className="max-w-md px-4 py-3">
+53 -2
View File
@@ -78,12 +78,58 @@ export const formatAge = (value: string | null) => {
export const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']; 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. */ /** Eingabe von Begriffen als Chips: Enter oder Komma fügt hinzu. */
export const TermInput: React.FC<{ export const TermInput: React.FC<{
value: string[]; value: string[];
onChange: (value: string[]) => void; onChange: (value: string[]) => void;
placeholder?: string; placeholder?: string;
tone?: 'brand' | 'red'; tone?: 'brand' | 'red' | 'alarm';
}> = ({ value, onChange, placeholder, tone = 'brand' }) => { }> = ({ value, onChange, placeholder, tone = 'brand' }) => {
const [draft, setDraft] = React.useState(''); const [draft, setDraft] = React.useState('');
@@ -99,12 +145,17 @@ export const TermInput: React.FC<{
}; };
const chip = 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 ( 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"> <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) => ( {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}`}> <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} {term}
<button <button
type="button" type="button"
+5 -12
View File
@@ -7,8 +7,8 @@ import { WEEKDAY_LABELS } from '../scanner/scannerUi';
/* /*
* Reiter "Scanner" in den Einstellungen (nur Administratoren). * Reiter "Scanner" in den Einstellungen (nur Administratoren).
* *
* Wann und wie oft der Scanner selbst sucht, wie alt Anzeigen höchstens sein * Wann und wie oft der Scanner selbst sucht, und der kostenlose API-Schlüssel
* dürfen, und der kostenlose API-Schlüssel für Adzuna. Die * für Adzuna. Die
* Schlüssel kommen - wie der OpenAI-Schlüssel - nie zurück zum Browser. * Schlüssel kommen - wie der OpenAI-Schlüssel - nie zurück zum Browser.
*/ */
@@ -108,17 +108,10 @@ export const ScannerSettings: React.FC = () => {
))} ))}
</select> </select>
</Field> </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> </div>
<p className="mt-3 text-xs text-ink-400">
Gesucht wird ohne Altersgrenze – das Alter der Anzeigen filtert das Dashboard („Anzeigenalter“).
</p>
<PrimaryButton <PrimaryButton
type="button" type="button"
+25 -3
View File
@@ -479,6 +479,8 @@ export interface ScannerAgent {
keywords: string[]; keywords: string[];
sources: ScannerSourceId[]; sources: ScannerSourceId[];
excludes: string[]; excludes: string[];
/** Schlagwörter, deren Fund mit Ton und Pop-up gemeldet wird. */
alarm_keywords: string[];
is_active: boolean; is_active: boolean;
matches: number; matches: number;
} }
@@ -491,8 +493,21 @@ export interface ScannerSchedule {
to: string; to: string;
/** Minuten */ /** Minuten */
interval: number; 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 { export interface ScannerSource {
@@ -525,6 +540,8 @@ export interface ScannerMatch {
score: number; score: number;
hits: string[]; hits: string[];
status: ScannerMatchStatus; status: ScannerMatchStatus;
/** Alarm-Schlagwort getroffen */
alarm: boolean;
created_at: string; created_at: string;
agent_id: number; agent_id: number;
agent_name: string; agent_name: string;
@@ -549,7 +566,10 @@ export interface ScannerSecrets {
adzunaKey: string | null; 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) => const put = <T>(path: string, body: unknown) =>
apiRequest<T>(path, { method: 'PUT', body: JSON.stringify(body) }); apiRequest<T>(path, { method: 'PUT', body: JSON.stringify(body) });
@@ -563,6 +583,8 @@ export const scannerApi = {
matches: (params: Record<string, unknown> = {}) => matches: (params: Record<string, unknown> = {}) =>
get<{ total: number; matches: ScannerMatch[] }>(`/scanner/matches${query(params)}`), get<{ total: number; matches: ScannerMatch[] }>(`/scanner/matches${query(params)}`),
setStatus: (id: number, status: ScannerMatchStatus) => patch<{ ok: true }>(`/scanner/matches/${id}`, { status }), 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'), scan: () => post<{ started: true }>('/scanner/scan'),
settings: () => get<{ settings: ScannerSchedule; secrets: ScannerSecrets }>('/scanner/settings'), settings: () => get<{ settings: ScannerSchedule; secrets: ScannerSecrets }>('/scanner/settings'),
saveSettings: (changes: Partial<ScannerSchedule> & Partial<Record<keyof ScannerSecrets, string>>) => saveSettings: (changes: Partial<ScannerSchedule> & Partial<Record<keyof ScannerSecrets, string>>) =>