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:
2026-07-04 13:33:12 +02:00
co-authored by Claude Opus 4.8
parent 1655427b4c
commit dae077486f
7 changed files with 799 additions and 28 deletions
+115 -16
View File
@@ -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' });
+218
View File
@@ -0,0 +1,218 @@
// Job-offer blacklist: normalization + matching helpers.
//
// Shared by the web routes (server.js) and the REST API (lib/api.js) so both
// use identical rules. A blacklisted offer is never ingested again, and every
// deleted offer is auto-blacklisted so it can never reappear in the list.
//
// A blacklist row has a `typ` and a set of normalized match keys; only the keys
// relevant to its typ are set:
// 'url' -> url_norm (exact normalized URL)
// 'domain' -> domain (whole host, e.g. a job board)
// 'firma' -> firma_norm (block a company entirely)
// 'firma_stelle' -> firma_norm + stelle_norm (+ optional ort_norm)
// 'auto' -> url_norm / quelle+external_id / firma_norm+stelle_norm
// (created on delete; matches on ANY stored signature)
const TYPES = ['url', 'domain', 'firma', 'firma_stelle', 'auto'];
// Query params that carry no identity — stripped before comparing URLs so the
// same posting with different tracking/referrer params still matches.
const TRACKING_PARAMS = new Set([
'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'utm_id',
'gclid', 'fbclid', 'msclkid', 'mc_cid', 'mc_eid', 'yclid', 'igshid', 'gh_src',
'ref', 'referrer', 'source', 'src', 'from', 'campaignid', 'cmpid', 'cmp',
'vjk', 'tk', 'rgtk', 'hl', 'spa', 'sp', 'advn', 'adid', 'trk', 'trkid',
]);
// Reverse the web layer's HTML sanitisation (sanitizeInput encodes < > " ')
// so a value stored via the UI normalises to the same thing as the raw input.
function unescapeInput(s) {
return String(s == null ? '' : s)
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'");
}
const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:\/\//i;
function parseUrl(raw) {
const s = unescapeInput(raw).trim();
if (!s) return null;
try {
return new URL(HAS_SCHEME.test(s) ? s : 'https://' + s);
} catch (e) {
return null;
}
}
// Host without a leading "www.", lowercased.
function urlDomain(raw) {
const u = parseUrl(raw);
return u ? u.hostname.toLowerCase().replace(/^www\./, '') : '';
}
// A stable identity string for a URL: lowercased host (no www), path without a
// trailing slash, and the meaningful query params sorted (tracking dropped).
// Path/query case is preserved because job IDs can be case-sensitive.
function normalizeUrl(raw) {
const u = parseUrl(raw);
if (!u) return unescapeInput(raw).trim().toLowerCase();
const host = u.hostname.toLowerCase().replace(/^www\./, '');
const params = [];
for (const [k, v] of u.searchParams.entries()) {
if (TRACKING_PARAMS.has(k.toLowerCase())) continue;
params.push([k, v]);
}
params.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
const pathname = u.pathname.replace(/\/+$/, '');
const qs = params.length
? '?' + params.map(([k, v]) => (v === '' ? k : `${k}=${v}`)).join('&')
: '';
return host + pathname + qs;
}
// Normalise a company / job-title string for fuzzy identity matching:
// unescape, lowercase, drop gender markers like "(m/w/d)", collapse anything
// non-alphanumeric to single spaces.
function normText(s) {
return unescapeInput(s)
.toLowerCase()
.replace(/\((?:[mwdfax](?:\s*\/\s*[mwdfax])*)\)/gi, ' ')
.replace(/\b[mwfd](?:\s*\/\s*[mwfd]){1,2}\b/gi, ' ')
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
}
// Normalised signature of an offer (raw or DB row), used for matching.
function offerSignature(offer) {
const o = offer || {};
return {
url_norm: normalizeUrl(o.quelle_url),
domain: urlDomain(o.quelle_url),
quelle: (o.quelle && String(o.quelle).trim()) || 'drittanbieter',
external_id: o.external_id != null && String(o.external_id).trim() !== ''
? String(o.external_id).trim() : null,
firma_norm: normText(o.firma),
stelle_norm: normText(o.stelle),
ort_norm: normText(o.ort),
};
}
// Does one blacklist row block the given signature?
function rowMatches(row, sig) {
switch (row.typ) {
case 'url':
return !!row.url_norm && row.url_norm === sig.url_norm;
case 'domain':
return !!row.domain && row.domain === sig.domain;
case 'firma':
return !!row.firma_norm && row.firma_norm === sig.firma_norm;
case 'firma_stelle':
return !!row.firma_norm && row.firma_norm === sig.firma_norm
&& !!row.stelle_norm && row.stelle_norm === sig.stelle_norm
&& (!row.ort_norm || row.ort_norm === sig.ort_norm);
case 'auto':
default:
if (row.url_norm && sig.url_norm && row.url_norm === sig.url_norm) return true;
if (row.quelle && row.external_id && sig.external_id
&& row.quelle === sig.quelle && row.external_id === sig.external_id) return true;
if (row.firma_norm && row.stelle_norm
&& row.firma_norm === sig.firma_norm && row.stelle_norm === sig.stelle_norm
&& (!row.ort_norm || row.ort_norm === sig.ort_norm)) return true;
return false;
}
}
// First blacklist row that blocks this offer, or null.
function matchBlacklist(rows, offer) {
const sig = offerSignature(offer);
for (const row of rows || []) {
if (rowMatches(row, sig)) return row;
}
return null;
}
// Column set for INSERT — keeps server.js and api.js in lock-step.
const COLUMNS = [
'typ', 'url_norm', 'domain', 'quelle', 'external_id',
'firma_norm', 'stelle_norm', 'ort_norm', 'firma', 'stelle', 'quelle_url', 'grund',
];
function emptyEntry() {
const e = {};
COLUMNS.forEach((c) => { e[c] = null; });
return e;
}
// Build a blacklist row from a manual UI/API request. Returns null if the
// chosen typ has no usable value.
function buildManualEntry(input) {
const inp = input || {};
const typ = TYPES.includes(inp.typ) ? inp.typ : 'url';
const e = emptyEntry();
e.typ = typ;
e.grund = inp.grund ? String(inp.grund) : null;
const wert = inp.wert != null ? String(inp.wert) : '';
if (typ === 'url') {
const w = wert || inp.quelle_url || '';
e.url_norm = normalizeUrl(w);
e.domain = urlDomain(w) || null;
e.quelle_url = w || null;
if (!e.url_norm) return null;
} else if (typ === 'domain') {
const w = wert || inp.domain || '';
e.domain = urlDomain(w) || normText(w).replace(/\s+/g, '') || null;
if (!e.domain) return null;
} else if (typ === 'firma') {
const w = wert || inp.firma || '';
e.firma = w || null;
e.firma_norm = normText(w);
if (!e.firma_norm) return null;
} else if (typ === 'firma_stelle') {
const f = inp.firma || '';
const s = inp.stelle || wert || '';
e.firma = f || null;
e.stelle = s || null;
e.firma_norm = normText(f);
e.stelle_norm = normText(s);
e.ort_norm = inp.ort ? normText(inp.ort) : null;
if (!e.firma_norm || !e.stelle_norm) return null;
}
return e;
}
// Build the automatic blacklist row created when an offer is deleted: it stores
// every available signature so the offer can never be ingested again.
function buildAutoEntry(offer, grund) {
const sig = offerSignature(offer);
const e = emptyEntry();
e.typ = 'auto';
e.grund = grund || null;
e.url_norm = sig.url_norm || null;
e.domain = sig.domain || null;
e.quelle = sig.external_id ? sig.quelle : null;
e.external_id = sig.external_id || null;
e.firma_norm = sig.firma_norm || null;
e.stelle_norm = sig.stelle_norm || null;
e.ort_norm = sig.ort_norm || null;
e.firma = offer && offer.firma ? offer.firma : null;
e.stelle = offer && offer.stelle ? offer.stelle : null;
e.quelle_url = offer && offer.quelle_url ? offer.quelle_url : null;
return e;
}
module.exports = {
TYPES,
COLUMNS,
normalizeUrl,
urlDomain,
normText,
offerSignature,
rowMatches,
matchBlacklist,
buildManualEntry,
buildAutoEntry,
};
+140 -5
View File
@@ -486,9 +486,11 @@ function buildOpenApiSpec(baseUrl = '') {
summary: 'Jobangebot einspielen (Upsert)',
description:
'Spielt ein Jobangebot ein. Wird von Drittanbietersoftware genutzt, um ' +
'Stellen in den Tracker zu übernehmen. Upsert über (quelle, external_id): ' +
'wiederholtes Senden desselben Angebots aktualisiert es statt ein Duplikat ' +
'anzulegen. Wird 201 (created) oder 200 (updated) zurückgegeben.',
'Stellen in den Tracker zu übernehmen. De-Dup: Upsert über (quelle, ' +
'external_id) oder falls keine external_id passt über die normalisierte ' +
'URL (quelle_url). Dieselbe Stelle landet damit nie doppelt. Steht das ' +
'Angebot auf der Blacklist (siehe /joboffers/blacklist), wird es mit 409 ' +
'abgelehnt und NICHT aufgenommen. Rückgabe 201 (created) oder 200 (updated).',
requestBody: {
required: true,
content: { 'application/json': { schema: { $ref: '#/components/schemas/JobOfferCreate' } } },
@@ -504,6 +506,66 @@ function buildOpenApiSpec(baseUrl = '') {
},
'400': errorResponse,
'401': errorResponse,
'409': {
description: 'Angebot steht auf der Blacklist und wurde nicht aufgenommen',
content: { 'application/json': { schema: { $ref: '#/components/schemas/BlacklistConflict' } } },
},
'500': errorResponse,
},
},
},
// NOTE: registered before /joboffers/{id} so "blacklist" is not read as an id.
'/joboffers/blacklist': {
get: {
tags: ['JobOffers'],
summary: 'Blacklist auflisten',
description: 'Alle Blacklist-Einträge (neueste zuerst). Blockierte Angebote werden nie eingespielt.',
responses: {
'200': {
description: 'Blacklist-Einträge',
content: {
'application/json': {
schema: { type: 'array', items: { $ref: '#/components/schemas/BlacklistEntry' } },
},
},
},
'401': errorResponse,
'500': errorResponse,
},
},
post: {
tags: ['JobOffers'],
summary: 'Blacklist-Eintrag anlegen',
description:
'Blockiert Angebote anhand einer URL, einer ganzen Domain, einer Firma ' +
'oder einer Firma + Stelle. Passende Angebote werden danach nicht mehr eingespielt.',
requestBody: {
required: true,
content: { 'application/json': { schema: { $ref: '#/components/schemas/BlacklistEntryCreate' } } },
},
responses: {
'201': {
description: 'Eintrag angelegt',
content: { 'application/json': { schema: { $ref: '#/components/schemas/BlacklistEntryResult' } } },
},
'400': errorResponse,
'401': errorResponse,
'500': errorResponse,
},
},
},
'/joboffers/blacklist/{id}': {
delete: {
tags: ['JobOffers'],
summary: 'Blacklist-Eintrag entfernen',
description: 'Entfernt einen Block. Betroffene Angebote können danach wieder eingespielt werden.',
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }],
responses: {
'200': { description: 'Entfernt', content: { 'application/json': { schema: { $ref: '#/components/schemas/Ok' } } } },
'401': errorResponse,
'404': errorResponse,
'500': errorResponse,
},
},
@@ -527,9 +589,22 @@ function buildOpenApiSpec(baseUrl = '') {
delete: {
tags: ['JobOffers'],
summary: 'Jobangebot löschen',
parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } }],
description:
'Löscht ein Jobangebot. Standardmäßig wird es zuvor automatisch auf die ' +
'Blacklist gesetzt, damit dieselbe Stelle nie erneut eingespielt wird. Mit ' +
'?blacklist=false wird ohne Blockierung hart gelöscht.',
parameters: [
{ name: 'id', in: 'path', required: true, schema: { type: 'integer' } },
{
name: 'blacklist',
in: 'query',
required: false,
schema: { type: 'boolean', default: true },
description: 'false = löschen ohne Blacklisting (Standard: true).',
},
],
responses: {
'200': { description: 'Gelöscht', content: { 'application/json': { schema: { $ref: '#/components/schemas/Ok' } } } },
'200': { description: 'Gelöscht', content: { 'application/json': { schema: { $ref: '#/components/schemas/JobOfferDeleteResult' } } } },
'401': errorResponse,
'404': errorResponse,
'500': errorResponse,
@@ -788,6 +863,7 @@ function buildOpenApiSpec(baseUrl = '') {
kontakt_email: { type: 'string', format: 'email', nullable: true, description: 'Kontakt-E-Mail-Adresse der Stelle' },
status: { type: 'string', enum: ['offen', 'uebernommen', 'abgelehnt'] },
verknuepfte_bewerbung_id: { type: 'integer', nullable: true },
url_norm: { type: 'string', nullable: true, description: 'Normalisierte URL für die De-Duplizierung (serverseitig gesetzt)' },
created_at: { type: 'string', format: 'date-time' },
updated_at: { type: 'string', format: 'date-time' },
},
@@ -818,6 +894,65 @@ function buildOpenApiSpec(baseUrl = '') {
joboffer: { $ref: '#/components/schemas/JobOffer' },
},
},
JobOfferDeleteResult: {
type: 'object',
properties: {
success: { type: 'boolean', example: true },
blacklisted: { type: 'boolean', description: 'true, wenn das Angebot beim Löschen auf die Blacklist gesetzt wurde' },
},
},
BlacklistEntry: {
type: 'object',
description: 'Ein Blacklist-Eintrag. Nur die zum typ passenden Felder sind gesetzt.',
properties: {
id: { type: 'integer' },
typ: { type: 'string', enum: ['url', 'domain', 'firma', 'firma_stelle', 'auto'] },
url_norm: { type: 'string', nullable: true, description: 'Normalisierte URL (Tracking-Parameter entfernt)' },
domain: { type: 'string', nullable: true },
quelle: { type: 'string', nullable: true },
external_id: { type: 'string', nullable: true },
firma_norm: { type: 'string', nullable: true },
stelle_norm: { type: 'string', nullable: true },
ort_norm: { type: 'string', nullable: true },
firma: { type: 'string', nullable: true },
stelle: { type: 'string', nullable: true },
quelle_url: { type: 'string', nullable: true },
grund: { type: 'string', nullable: true },
created_at: { type: 'string', format: 'date-time' },
},
},
BlacklistEntryCreate: {
type: 'object',
required: ['typ'],
properties: {
typ: {
type: 'string',
enum: ['url', 'domain', 'firma', 'firma_stelle'],
description: 'url/domain/firma nutzen "wert"; firma_stelle nutzt "firma"+"stelle" (+optional "ort").',
},
wert: { type: 'string', description: 'URL (typ=url), Domain (typ=domain) oder Firmenname (typ=firma).' },
firma: { type: 'string', description: 'Nur bei typ=firma_stelle.' },
stelle: { type: 'string', description: 'Nur bei typ=firma_stelle.' },
ort: { type: 'string', description: 'Optional bei typ=firma_stelle.' },
grund: { type: 'string', description: 'Optionaler Freitext-Grund.' },
},
},
BlacklistEntryResult: {
type: 'object',
properties: {
success: { type: 'boolean', example: true },
entry: { $ref: '#/components/schemas/BlacklistEntry' },
},
},
BlacklistConflict: {
type: 'object',
properties: {
blacklisted: { type: 'boolean', example: true },
matched_by: { type: 'string', description: 'typ des greifenden Blacklist-Eintrags' },
blacklist_entry: { $ref: '#/components/schemas/BlacklistEntry' },
error: { type: 'string' },
},
},
},
},
security: [{ ApiKeyAuth: [] }],