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:
+140
-5
@@ -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: [] }],
|
||||
|
||||
Reference in New Issue
Block a user