Add a job-offer blacklist with URL-based de-duplication
Offers are now de-duplicated by normalized URL (tracking params stripped)
in addition to (quelle, external_id), so the same posting never lands
twice — even re-scraped under a new id. Deleting an offer (web or API)
auto-blacklists it, so it can never reappear.
lib/blacklist.js provides shared normalization + matching. Manual entries
can block a URL, a whole domain, a company, or a company+title posting
(gender-marker tolerant). New /blacklist page lists and manages entries.
REST API: GET/POST /joboffers/blacklist, DELETE /joboffers/blacklist/{id};
POST /joboffers returns 409 when blacklisted; DELETE /joboffers/{id}
auto-blacklists (opt out with ?blacklist=false). Swagger updated with the
new paths and schemas.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+115
-16
@@ -8,6 +8,7 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const blacklist = require('./blacklist');
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
'Entwurf', 'Gesendet', 'Eingangsbestätigung', 'Vorstellungsgespräch',
|
||||
@@ -517,6 +518,62 @@ function createExternalApi(deps) {
|
||||
}
|
||||
});
|
||||
|
||||
// --- Blacklist (must be registered BEFORE /joboffers/:id) ----------
|
||||
// Blocks offers from ever (re)appearing. See lib/blacklist for the match
|
||||
// rules. A deleted offer is auto-blacklisted so it can never return.
|
||||
router.get('/joboffers/blacklist', async (req, res) => {
|
||||
try {
|
||||
const rows = await dbAll(
|
||||
'SELECT * FROM jobangebote_blacklist ORDER BY created_at DESC, id DESC'
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (error) {
|
||||
console.error('API list blacklist error:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/joboffers/blacklist', async (req, res) => {
|
||||
try {
|
||||
const b = req.body || {};
|
||||
const entry = blacklist.buildManualEntry({
|
||||
typ: b.typ,
|
||||
wert: b.wert != null ? sanitizeInput(String(b.wert)) : '',
|
||||
firma: sanitizeInput(b.firma || ''),
|
||||
stelle: sanitizeInput(b.stelle || ''),
|
||||
ort: sanitizeInput(b.ort || ''),
|
||||
grund: sanitizeInput(b.grund || ''),
|
||||
});
|
||||
if (!entry) {
|
||||
return res.status(400).json({
|
||||
error: `Ungültiger Blacklist-Eintrag. typ muss eine von ${blacklist.TYPES.join(', ')} sein und einen passenden Wert haben.`,
|
||||
});
|
||||
}
|
||||
const cols = blacklist.COLUMNS;
|
||||
const result = await dbRun(
|
||||
`INSERT INTO jobangebote_blacklist (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`,
|
||||
cols.map((c) => (entry[c] === undefined ? null : entry[c]))
|
||||
);
|
||||
const row = await dbGet('SELECT * FROM jobangebote_blacklist WHERE id = ?', [result.lastID]);
|
||||
res.status(201).json({ success: true, entry: row });
|
||||
} catch (error) {
|
||||
console.error('API create blacklist entry error:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/joboffers/blacklist/:id', async (req, res) => {
|
||||
try {
|
||||
const row = await dbGet('SELECT id FROM jobangebote_blacklist WHERE id = ?', [req.params.id]);
|
||||
if (!row) return res.status(404).json({ error: 'Blacklist-Eintrag nicht gefunden' });
|
||||
await dbRun('DELETE FROM jobangebote_blacklist WHERE id = ?', [req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('API delete blacklist entry error:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/joboffers/:id', async (req, res) => {
|
||||
try {
|
||||
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [req.params.id]);
|
||||
@@ -534,10 +591,25 @@ function createExternalApi(deps) {
|
||||
if (!b.firma || !b.stelle) {
|
||||
return res.status(400).json({ error: 'firma und stelle sind erforderlich.' });
|
||||
}
|
||||
|
||||
// Reject blacklisted offers outright — they must never reappear.
|
||||
const blacklistRows = await dbAll('SELECT * FROM jobangebote_blacklist');
|
||||
const blocked = blacklist.matchBlacklist(blacklistRows, b);
|
||||
if (blocked) {
|
||||
return res.status(409).json({
|
||||
blacklisted: true,
|
||||
matched_by: blocked.typ,
|
||||
blacklist_entry: blocked,
|
||||
error: 'Dieses Jobangebot steht auf der Blacklist und wird nicht (erneut) aufgenommen.',
|
||||
});
|
||||
}
|
||||
|
||||
const quelle = sanitizeInput(b.quelle || 'drittanbieter');
|
||||
const externalId = b.external_id != null ? sanitizeInput(String(b.external_id)) : null;
|
||||
const urlNorm = blacklist.normalizeUrl(b.quelle_url || '') || null;
|
||||
const anzeigeDatum = sanitizeInput(b.anzeige_datum || '');
|
||||
const kontaktEmail = sanitizeInput(b.kontakt_email || '');
|
||||
// Column order matches the UPDATE/INSERT statements below (url_norm last).
|
||||
const fields = [
|
||||
sanitizeInput(b.firma),
|
||||
sanitizeInput(b.stelle),
|
||||
@@ -549,30 +621,40 @@ function createExternalApi(deps) {
|
||||
anzeigeDatum,
|
||||
kontaktEmail,
|
||||
sanitizeInput(b.status || 'offen'),
|
||||
urlNorm,
|
||||
];
|
||||
|
||||
// Upsert: a matching (quelle, external_id) row is updated, else inserted.
|
||||
// De-dup: prefer a (quelle, external_id) match, else the same normalized
|
||||
// URL — so the same posting never lands twice, even with a new id.
|
||||
let existing = null;
|
||||
if (externalId) {
|
||||
const existing = await dbGet(
|
||||
existing = await dbGet(
|
||||
'SELECT id FROM jobangebote WHERE quelle = ? AND external_id = ?',
|
||||
[quelle, externalId]
|
||||
);
|
||||
if (existing) {
|
||||
await dbRun(
|
||||
`UPDATE jobangebote SET firma = ?, stelle = ?, ort = ?, gehalt = ?,
|
||||
beschreibung = ?, quelle_url = ?, art = ?, anzeige_datum = ?, kontakt_email = ?, status = ?,
|
||||
updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
[...fields, existing.id]
|
||||
);
|
||||
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [existing.id]);
|
||||
return res.json({ success: true, action: 'updated', joboffer: row });
|
||||
}
|
||||
}
|
||||
if (!existing && urlNorm) {
|
||||
existing = await dbGet(
|
||||
'SELECT id FROM jobangebote WHERE url_norm = ? ORDER BY id ASC LIMIT 1',
|
||||
[urlNorm]
|
||||
);
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await dbRun(
|
||||
`UPDATE jobangebote SET firma = ?, stelle = ?, ort = ?, gehalt = ?,
|
||||
beschreibung = ?, quelle_url = ?, art = ?, anzeige_datum = ?, kontakt_email = ?, status = ?,
|
||||
url_norm = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`,
|
||||
[...fields, existing.id]
|
||||
);
|
||||
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [existing.id]);
|
||||
return res.json({ success: true, action: 'updated', joboffer: row });
|
||||
}
|
||||
|
||||
const result = await dbRun(
|
||||
`INSERT INTO jobangebote
|
||||
(external_id, quelle, firma, stelle, ort, gehalt, beschreibung, quelle_url, art, anzeige_datum, kontakt_email, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
(external_id, quelle, firma, stelle, ort, gehalt, beschreibung, quelle_url, art, anzeige_datum, kontakt_email, status, url_norm)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[externalId, quelle, ...fields]
|
||||
);
|
||||
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [result.lastID]);
|
||||
@@ -583,12 +665,29 @@ function createExternalApi(deps) {
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a job offer. By default it is auto-blacklisted first so it can never
|
||||
// be ingested again; pass ?blacklist=false to hard-delete without blocking.
|
||||
router.delete('/joboffers/:id', async (req, res) => {
|
||||
try {
|
||||
const row = await dbGet('SELECT id FROM jobangebote WHERE id = ?', [req.params.id]);
|
||||
const row = await dbGet('SELECT * FROM jobangebote WHERE id = ?', [req.params.id]);
|
||||
if (!row) return res.status(404).json({ error: 'Jobangebot nicht gefunden' });
|
||||
|
||||
const skipBlacklist = req.query.blacklist === 'false' || req.query.blacklist === '0';
|
||||
let blacklisted = false;
|
||||
if (!skipBlacklist) {
|
||||
const rows = await dbAll('SELECT * FROM jobangebote_blacklist');
|
||||
if (!blacklist.matchBlacklist(rows, row)) {
|
||||
const cols = blacklist.COLUMNS;
|
||||
const entry = blacklist.buildAutoEntry(row, 'Jobangebot gelöscht (REST-API)');
|
||||
await dbRun(
|
||||
`INSERT INTO jobangebote_blacklist (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`,
|
||||
cols.map((c) => (entry[c] === undefined ? null : entry[c]))
|
||||
);
|
||||
}
|
||||
blacklisted = true;
|
||||
}
|
||||
await dbRun('DELETE FROM jobangebote WHERE id = ?', [req.params.id]);
|
||||
res.json({ success: true });
|
||||
res.json({ success: true, blacklisted });
|
||||
} catch (error) {
|
||||
console.error('API delete job offer error:', error);
|
||||
res.status(500).json({ error: 'Serverfehler' });
|
||||
|
||||
Reference in New Issue
Block a user