Quelle, Schlüsselfeld in Einstellungen -> Scanner und Doku raus. Agenten, die jooble noch gespeichert haben, bekommen es nicht mehr angezeigt; die Pipeline überspringt unbekannte Quellen ohnehin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
349 lines
12 KiB
JavaScript
349 lines
12 KiB
JavaScript
import express from 'express';
|
||
import { pool } from '../db.js';
|
||
import { requireAdmin, requireAuth } from '../middleware/auth.js';
|
||
import { logHistory } from '../history.js';
|
||
import { SOURCES, SOURCE_IDS, sourceReady } from '../scanner/sources.js';
|
||
import { parseList, runScan, scanStatus } from '../scanner/pipeline.js';
|
||
import {
|
||
getLastScanAt,
|
||
loadScannerSecrets,
|
||
loadScannerSettings,
|
||
maskedScannerSecrets,
|
||
saveScannerSettings,
|
||
} from '../scanner/settings.js';
|
||
|
||
/*
|
||
* Scanner: Stellenanzeigen aus Jobboersen (siehe docs/12-scanner.md).
|
||
*
|
||
* Suchagenten und Treffer pflegt jeder Benutzer; Zeitplan und API-Schluessel
|
||
* nur Administratoren (Einstellungen -> Scanner).
|
||
*/
|
||
|
||
const router = express.Router();
|
||
router.use(requireAuth);
|
||
|
||
const STATUSES = ['neu', 'gesehen', 'in_bearbeitung', 'verworfen'];
|
||
|
||
const cleanList = (value) =>
|
||
[...new Set((Array.isArray(value) ? value : []).map((item) => String(item).trim()).filter(Boolean))].slice(0, 50);
|
||
|
||
const agentOut = (row) => ({
|
||
id: row.id,
|
||
name: row.name,
|
||
keywords: parseList(row.keywords),
|
||
// 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),
|
||
is_active: Boolean(row.is_active),
|
||
matches: Number(row.matches ?? 0),
|
||
created_at: row.created_at,
|
||
updated_at: row.updated_at,
|
||
});
|
||
|
||
/* ------------------------------------------------------------------------
|
||
* Uebersicht: Kennzahlen, Quellen, Zustand des Zeitplans
|
||
* ---------------------------------------------------------------------- */
|
||
router.get('/overview', async (req, res, next) => {
|
||
try {
|
||
const settings = await loadScannerSettings();
|
||
const secrets = await loadScannerSecrets();
|
||
const [[agents]] = await pool.query(
|
||
'SELECT COUNT(*) AS active FROM icon_scanner_agents WHERE is_active = 1'
|
||
);
|
||
const [[jobs]] = await pool.query('SELECT COUNT(*) AS total FROM icon_scanner_jobs');
|
||
const [[matches]] = await pool.query(
|
||
`SELECT COUNT(*) AS total,
|
||
SUM(m.status = 'neu') AS fresh,
|
||
SUM(j.company_id IS NOT NULL) AS known
|
||
FROM icon_scanner_matches m JOIN icon_scanner_jobs j ON j.id = m.job_id`
|
||
);
|
||
const [[lastRun]] = await pool.query(
|
||
'SELECT * FROM icon_scanner_runs WHERE finished_at IS NOT NULL ORDER BY id DESC LIMIT 1'
|
||
);
|
||
const perSource = lastRun?.per_source ? JSON.parse(lastRun.per_source) : {};
|
||
|
||
res.json({
|
||
stats: {
|
||
agents: Number(agents.active),
|
||
jobs: Number(jobs.total),
|
||
matches: Number(matches.total ?? 0),
|
||
fresh: Number(matches.fresh ?? 0),
|
||
known: Number(matches.known ?? 0),
|
||
},
|
||
sources: SOURCE_IDS.map((id) => ({
|
||
id,
|
||
label: SOURCES[id].label,
|
||
countries: SOURCES[id].countries,
|
||
ready: sourceReady(id, secrets),
|
||
needsKey: SOURCES[id].needsKey,
|
||
last: perSource[id] ?? null,
|
||
})),
|
||
schedule: settings,
|
||
lastScanAt: (await getLastScanAt())?.toISOString() ?? null,
|
||
lastRun: lastRun
|
||
? {
|
||
id: lastRun.id,
|
||
trigger: lastRun.trigger_kind,
|
||
started_at: lastRun.started_at,
|
||
finished_at: lastRun.finished_at,
|
||
jobs_found: lastRun.jobs_found,
|
||
matches_new: lastRun.matches_new,
|
||
}
|
||
: null,
|
||
status: scanStatus(),
|
||
});
|
||
} catch (err) {
|
||
next(err);
|
||
}
|
||
});
|
||
|
||
/* ------------------------------------------------------------------------
|
||
* Suchagenten
|
||
* ---------------------------------------------------------------------- */
|
||
router.get('/agents', async (req, res, next) => {
|
||
try {
|
||
const [rows] = await pool.query(
|
||
`SELECT a.*, (SELECT COUNT(*) FROM icon_scanner_matches m WHERE m.agent_id = a.id) AS matches
|
||
FROM icon_scanner_agents a ORDER BY a.name`
|
||
);
|
||
res.json({ agents: rows.map(agentOut) });
|
||
} catch (err) {
|
||
next(err);
|
||
}
|
||
});
|
||
|
||
function readAgent(body) {
|
||
const name = String(body?.name ?? '').trim().slice(0, 190);
|
||
const keywords = cleanList(body?.keywords);
|
||
const sources = cleanList(body?.sources).filter((id) => SOURCE_IDS.includes(id));
|
||
const excludes = cleanList(body?.excludes);
|
||
if (!name) return { error: 'Bitte einen Namen angeben.' };
|
||
if (keywords.length === 0) return { error: 'Mindestens ein Schlagwort angeben.' };
|
||
if (sources.length === 0) return { error: 'Mindestens eine Börse auswählen.' };
|
||
return { name, keywords, sources, excludes, is_active: body?.is_active !== false };
|
||
}
|
||
|
||
router.post('/agents', async (req, res, next) => {
|
||
const agent = readAgent(req.body);
|
||
if (agent.error) return res.status(400).json({ error: agent.error });
|
||
try {
|
||
const [result] = await pool.query(
|
||
`INSERT INTO icon_scanner_agents (name, keywords, sources, excludes, is_active, created_by)
|
||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||
[
|
||
agent.name,
|
||
JSON.stringify(agent.keywords),
|
||
JSON.stringify(agent.sources),
|
||
JSON.stringify(agent.excludes),
|
||
agent.is_active ? 1 : 0,
|
||
req.user.id,
|
||
]
|
||
);
|
||
await logHistory({
|
||
entityType: 'system',
|
||
entityLabel: 'Scanner',
|
||
action: 'created',
|
||
text: `Suchagent „${agent.name}" angelegt`,
|
||
userId: req.user.id,
|
||
});
|
||
const [[row]] = await pool.query('SELECT * FROM icon_scanner_agents WHERE id = ?', [result.insertId]);
|
||
res.status(201).json({ agent: agentOut(row) });
|
||
} catch (err) {
|
||
next(err);
|
||
}
|
||
});
|
||
|
||
router.put('/agents/:id', async (req, res, next) => {
|
||
const agent = readAgent(req.body);
|
||
if (agent.error) return res.status(400).json({ error: agent.error });
|
||
try {
|
||
const [result] = await pool.query(
|
||
`UPDATE icon_scanner_agents
|
||
SET name = ?, keywords = ?, sources = ?, excludes = ?, is_active = ?
|
||
WHERE id = ?`,
|
||
[
|
||
agent.name,
|
||
JSON.stringify(agent.keywords),
|
||
JSON.stringify(agent.sources),
|
||
JSON.stringify(agent.excludes),
|
||
agent.is_active ? 1 : 0,
|
||
Number(req.params.id),
|
||
]
|
||
);
|
||
if (result.affectedRows === 0) return res.status(404).json({ error: 'Suchagent nicht gefunden.' });
|
||
const [[row]] = await pool.query('SELECT * FROM icon_scanner_agents WHERE id = ?', [Number(req.params.id)]);
|
||
res.json({ agent: agentOut(row) });
|
||
} catch (err) {
|
||
next(err);
|
||
}
|
||
});
|
||
|
||
router.delete('/agents/:id', async (req, res, next) => {
|
||
try {
|
||
const [[row]] = await pool.query('SELECT name FROM icon_scanner_agents WHERE id = ?', [Number(req.params.id)]);
|
||
if (!row) return res.status(404).json({ error: 'Suchagent nicht gefunden.' });
|
||
// Treffer gehen per ON DELETE CASCADE mit.
|
||
await pool.query('DELETE FROM icon_scanner_agents WHERE id = ?', [Number(req.params.id)]);
|
||
await logHistory({
|
||
entityType: 'system',
|
||
entityLabel: 'Scanner',
|
||
action: 'deleted',
|
||
text: `Suchagent „${row.name}" gelöscht`,
|
||
userId: req.user.id,
|
||
});
|
||
res.json({ ok: true });
|
||
} catch (err) {
|
||
next(err);
|
||
}
|
||
});
|
||
|
||
/* ------------------------------------------------------------------------
|
||
* Treffer
|
||
* ---------------------------------------------------------------------- */
|
||
router.get('/matches', async (req, res, next) => {
|
||
const where = [];
|
||
const params = [];
|
||
|
||
const hours = Number(req.query.hours);
|
||
if (Number.isFinite(hours) && hours > 0) {
|
||
where.push('m.created_at >= NOW() - INTERVAL ? HOUR');
|
||
params.push(Math.min(hours, 24 * 90));
|
||
}
|
||
if (req.query.agentId) {
|
||
where.push('m.agent_id = ?');
|
||
params.push(Number(req.query.agentId));
|
||
}
|
||
if (req.query.source && SOURCE_IDS.includes(String(req.query.source))) {
|
||
where.push('j.source = ?');
|
||
params.push(String(req.query.source));
|
||
}
|
||
if (req.query.known === '1') where.push('j.company_id IS NOT NULL');
|
||
if (req.query.status && STATUSES.includes(String(req.query.status))) {
|
||
where.push('m.status = ?');
|
||
params.push(String(req.query.status));
|
||
} else if (req.query.status !== 'alle') {
|
||
// Verworfenes nur auf ausdruecklichen Wunsch.
|
||
where.push("m.status <> 'verworfen'");
|
||
}
|
||
const minScore = Number(req.query.minScore);
|
||
if (Number.isFinite(minScore) && minScore > 0) {
|
||
where.push('m.score >= ?');
|
||
params.push(minScore);
|
||
}
|
||
const q = String(req.query.q ?? '').trim();
|
||
if (q) {
|
||
where.push('(j.title LIKE ? OR j.company LIKE ? OR j.location LIKE ?)');
|
||
params.push(`%${q}%`, `%${q}%`, `%${q}%`);
|
||
}
|
||
|
||
const order =
|
||
req.query.sort === 'score'
|
||
? 'm.score DESC, m.created_at DESC'
|
||
: req.query.sort === 'published'
|
||
? 'j.published_at IS NULL, j.published_at DESC'
|
||
: // bekannte Firmen zuerst, dann neueste
|
||
'known DESC, m.created_at DESC';
|
||
|
||
const limit = Math.min(Math.max(Number(req.query.limit) || 300, 1), 1000);
|
||
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||
|
||
try {
|
||
const [rows] = await pool.query(
|
||
`SELECT m.id, m.score, m.hits, m.status, m.created_at,
|
||
a.id AS agent_id, a.name AS agent_name,
|
||
j.id AS job_id, j.source, j.title, j.company, j.location, j.country,
|
||
j.employment_type, j.url, j.description, j.published_at,
|
||
j.company_id, c.name AS company_name, c.is_customer,
|
||
(j.company_id IS NOT NULL) AS known
|
||
FROM icon_scanner_matches m
|
||
JOIN icon_scanner_agents a ON a.id = m.agent_id
|
||
JOIN icon_scanner_jobs j ON j.id = m.job_id
|
||
LEFT JOIN icon_companies c ON c.id = j.company_id
|
||
${whereSql}
|
||
ORDER BY ${order}
|
||
LIMIT ${limit}`,
|
||
params
|
||
);
|
||
const [[count]] = await pool.query(
|
||
`SELECT COUNT(*) AS total
|
||
FROM icon_scanner_matches m JOIN icon_scanner_jobs j ON j.id = m.job_id
|
||
${whereSql}`,
|
||
params
|
||
);
|
||
res.json({
|
||
total: Number(count.total),
|
||
matches: rows.map((row) => ({
|
||
...row,
|
||
hits: parseList(row.hits),
|
||
known: Boolean(row.known),
|
||
is_customer: row.is_customer == null ? null : Boolean(row.is_customer),
|
||
})),
|
||
});
|
||
} catch (err) {
|
||
next(err);
|
||
}
|
||
});
|
||
|
||
router.patch('/matches/:id', async (req, res, next) => {
|
||
const status = String(req.body?.status ?? '');
|
||
if (!STATUSES.includes(status)) return res.status(400).json({ error: 'Unbekannter Status.' });
|
||
try {
|
||
const [result] = await pool.query('UPDATE icon_scanner_matches SET status = ? WHERE id = ?', [
|
||
status,
|
||
Number(req.params.id),
|
||
]);
|
||
if (result.affectedRows === 0) return res.status(404).json({ error: 'Treffer nicht gefunden.' });
|
||
res.json({ ok: true });
|
||
} catch (err) {
|
||
next(err);
|
||
}
|
||
});
|
||
|
||
/* ------------------------------------------------------------------------
|
||
* Jetzt scannen - laeuft im Hintergrund, die Oberflaeche fragt /overview ab.
|
||
* Ein Lauf dauert je nach Zahl der Schlagwoerter Minuten; darauf zu warten
|
||
* hiesse, gegen das Zeitlimit des Proxys zu laufen.
|
||
* ---------------------------------------------------------------------- */
|
||
router.post('/scan', (req, res) => {
|
||
if (scanStatus().running) return res.status(409).json({ error: 'Es läuft bereits ein Suchlauf.' });
|
||
runScan('manual').catch((err) => console.error('[Scanner] Manueller Lauf:', err));
|
||
res.status(202).json({ started: true });
|
||
});
|
||
|
||
/* ------------------------------------------------------------------------
|
||
* Einstellungen (nur Administratoren)
|
||
* ---------------------------------------------------------------------- */
|
||
router.get('/settings', requireAdmin, async (req, res, next) => {
|
||
try {
|
||
res.json({ settings: await loadScannerSettings(), secrets: await maskedScannerSecrets() });
|
||
} catch (err) {
|
||
next(err);
|
||
}
|
||
});
|
||
|
||
router.patch('/settings', requireAdmin, async (req, res, next) => {
|
||
try {
|
||
const settings = await saveScannerSettings(req.body ?? {});
|
||
const touchedKeys = ['adzunaId', 'adzunaKey'].filter((key) => req.body?.[key] !== undefined);
|
||
await logHistory({
|
||
entityType: 'system',
|
||
entityLabel: 'Scanner',
|
||
action: 'updated',
|
||
// Nie die Schluessel selbst - nur, dass sie geaendert wurden.
|
||
text: [
|
||
`Zeitplan: ${settings.active ? 'an' : 'aus'}, alle ${settings.interval} min, ${settings.from}–${settings.to} Uhr`,
|
||
touchedKeys.length ? 'API-Schlüssel geändert' : null,
|
||
]
|
||
.filter(Boolean)
|
||
.join(', '),
|
||
userId: req.user.id,
|
||
});
|
||
res.json({ settings, secrets: await maskedScannerSecrets() });
|
||
} catch (err) {
|
||
if (err?.status === 400) return res.status(400).json({ error: err.message });
|
||
next(err);
|
||
}
|
||
});
|
||
|
||
export default router;
|