Add robust company slug for job-offer blacklist matching

Company matching relied on normText(), which kept legal-form suffixes,
spacing and umlaut spelling — so "Bosch GmbH", "Bosch AG" and "bosch gmbh"
did not match the same entry, making the blacklist unreliable.

Introduce firmaSlug(): transliterate German umlauts, strip diacritics and
trailing legal-form tokens (GmbH/AG/SE/KG/…), then kebab-join. Add a
firma_slug field to jobangebote_blacklist (schema + ALTER/backfill migration)
and match on it for typ firma/firma_stelle/auto, falling back to firma_norm
for legacy rows. POST /joboffers rejects blacklisted offers via matchBlacklist
automatically since offerSignature now carries the slug.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-06 22:25:59 +02:00
co-authored by Claude Opus 4.8
parent 4e5933890f
commit f935e00b53
3 changed files with 76 additions and 8 deletions
+55 -8
View File
@@ -8,9 +8,10 @@
// relevant to its typ are set: // relevant to its typ are set:
// 'url' -> url_norm (exact normalized URL) // 'url' -> url_norm (exact normalized URL)
// 'domain' -> domain (whole host, e.g. a job board) // 'domain' -> domain (whole host, e.g. a job board)
// 'firma' -> firma_norm (block a company entirely) // 'firma' -> firma_slug (block a company entirely; slug is a
// 'firma_stelle' -> firma_norm + stelle_norm (+ optional ort_norm) // legal-form/umlaut-robust key, falls back to firma_norm)
// 'auto' -> url_norm / quelle+external_id / firma_norm+stelle_norm // 'firma_stelle' -> firma_slug + stelle_norm (+ optional ort_norm)
// 'auto' -> url_norm / quelle+external_id / firma_slug+stelle_norm
// (created on delete; matches on ANY stored signature) // (created on delete; matches on ANY stored signature)
const TYPES = ['url', 'domain', 'firma', 'firma_stelle', 'auto']; const TYPES = ['url', 'domain', 'firma', 'firma_stelle', 'auto'];
@@ -72,6 +73,40 @@ function normalizeUrl(raw) {
return host + pathname + qs; return host + pathname + qs;
} }
// Legal-form / entity suffixes stripped from a company name before slugging,
// so "Bosch GmbH", "Bosch AG", "Bosch GmbH & Co. KG" and "Bosch" all collapse
// to the same slug. Matched as whole space-separated tokens at the end.
const LEGAL_FORM_TOKENS = new Set([
'gmbh', 'ggmbh', 'mbh', 'ug', 'haftungsbeschraenkt', 'ag', 'kg', 'kgaa',
'ohg', 'gbr', 'se', 'ek', 'eg', 'ev', 'partg', 'partmbb', 'co', 'cie',
'inc', 'incorporated', 'llc', 'ltd', 'limited', 'plc', 'corp', 'corporation',
'company', 'sa', 'sarl', 'sas', 'bv', 'nv', 'oy', 'ab', 'as', 'aps', 'srl', 'spa',
]);
// A robust matching key for a company name. Beyond normText it transliterates
// German umlauts (ä→ae …), strips diacritics, drops trailing legal-form tokens
// and joins the rest with hyphens. This is what the blacklist compares on so
// that entity form, spacing and umlaut spelling no longer cause misses.
function firmaSlug(s) {
let t = unescapeInput(s)
.toLowerCase()
.replace(/ä/g, 'ae').replace(/ö/g, 'oe').replace(/ü/g, 'ue').replace(/ß/g, 'ss')
.normalize("NFKD").replace(/[\u0300-\u036f]/g, "")
.replace(/\((?:[mwdfax](?:\s*\/\s*[mwdfax])*)\)/gi, ' ')
.replace(/[^a-z0-9]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (!t) return '';
const words = t.split(' ');
const kept = words.slice();
// Drop legal-form tokens from the end (e.g. "gmbh & co kg" → "gmbh co kg"),
// but never strip everything away.
while (kept.length > 1 && LEGAL_FORM_TOKENS.has(kept[kept.length - 1])) {
kept.pop();
}
return (kept.length ? kept : words).join('-');
}
// Normalise a company / job-title string for fuzzy identity matching: // Normalise a company / job-title string for fuzzy identity matching:
// unescape, lowercase, drop gender markers like "(m/w/d)", collapse anything // unescape, lowercase, drop gender markers like "(m/w/d)", collapse anything
// non-alphanumeric to single spaces. // non-alphanumeric to single spaces.
@@ -95,11 +130,19 @@ function offerSignature(offer) {
external_id: o.external_id != null && String(o.external_id).trim() !== '' external_id: o.external_id != null && String(o.external_id).trim() !== ''
? String(o.external_id).trim() : null, ? String(o.external_id).trim() : null,
firma_norm: normText(o.firma), firma_norm: normText(o.firma),
firma_slug: firmaSlug(o.firma),
stelle_norm: normText(o.stelle), stelle_norm: normText(o.stelle),
ort_norm: normText(o.ort), ort_norm: normText(o.ort),
}; };
} }
// Company identity match: prefer the robust slug, fall back to firma_norm for
// legacy rows written before firma_slug existed (or not yet backfilled).
function firmaMatch(row, sig) {
if (row.firma_slug && sig.firma_slug) return row.firma_slug === sig.firma_slug;
return !!row.firma_norm && row.firma_norm === sig.firma_norm;
}
// Does one blacklist row block the given signature? // Does one blacklist row block the given signature?
function rowMatches(row, sig) { function rowMatches(row, sig) {
switch (row.typ) { switch (row.typ) {
@@ -108,9 +151,9 @@ function rowMatches(row, sig) {
case 'domain': case 'domain':
return !!row.domain && row.domain === sig.domain; return !!row.domain && row.domain === sig.domain;
case 'firma': case 'firma':
return !!row.firma_norm && row.firma_norm === sig.firma_norm; return firmaMatch(row, sig);
case 'firma_stelle': case 'firma_stelle':
return !!row.firma_norm && row.firma_norm === sig.firma_norm return firmaMatch(row, sig)
&& !!row.stelle_norm && row.stelle_norm === sig.stelle_norm && !!row.stelle_norm && row.stelle_norm === sig.stelle_norm
&& (!row.ort_norm || row.ort_norm === sig.ort_norm); && (!row.ort_norm || row.ort_norm === sig.ort_norm);
case 'auto': case 'auto':
@@ -118,8 +161,8 @@ function rowMatches(row, sig) {
if (row.url_norm && sig.url_norm && row.url_norm === sig.url_norm) return true; if (row.url_norm && sig.url_norm && row.url_norm === sig.url_norm) return true;
if (row.quelle && row.external_id && sig.external_id if (row.quelle && row.external_id && sig.external_id
&& row.quelle === sig.quelle && row.external_id === sig.external_id) return true; && row.quelle === sig.quelle && row.external_id === sig.external_id) return true;
if (row.firma_norm && row.stelle_norm if ((row.firma_slug || row.firma_norm) && row.stelle_norm
&& row.firma_norm === sig.firma_norm && row.stelle_norm === sig.stelle_norm && firmaMatch(row, sig) && row.stelle_norm === sig.stelle_norm
&& (!row.ort_norm || row.ort_norm === sig.ort_norm)) return true; && (!row.ort_norm || row.ort_norm === sig.ort_norm)) return true;
return false; return false;
} }
@@ -137,7 +180,7 @@ function matchBlacklist(rows, offer) {
// Column set for INSERT — keeps server.js and api.js in lock-step. // Column set for INSERT — keeps server.js and api.js in lock-step.
const COLUMNS = [ const COLUMNS = [
'typ', 'url_norm', 'domain', 'quelle', 'external_id', 'typ', 'url_norm', 'domain', 'quelle', 'external_id',
'firma_norm', 'stelle_norm', 'ort_norm', 'firma', 'stelle', 'quelle_url', 'grund', 'firma_norm', 'firma_slug', 'stelle_norm', 'ort_norm', 'firma', 'stelle', 'quelle_url', 'grund',
]; ];
function emptyEntry() { function emptyEntry() {
@@ -170,6 +213,7 @@ function buildManualEntry(input) {
const w = wert || inp.firma || ''; const w = wert || inp.firma || '';
e.firma = w || null; e.firma = w || null;
e.firma_norm = normText(w); e.firma_norm = normText(w);
e.firma_slug = firmaSlug(w) || null;
if (!e.firma_norm) return null; if (!e.firma_norm) return null;
} else if (typ === 'firma_stelle') { } else if (typ === 'firma_stelle') {
const f = inp.firma || ''; const f = inp.firma || '';
@@ -177,6 +221,7 @@ function buildManualEntry(input) {
e.firma = f || null; e.firma = f || null;
e.stelle = s || null; e.stelle = s || null;
e.firma_norm = normText(f); e.firma_norm = normText(f);
e.firma_slug = firmaSlug(f) || null;
e.stelle_norm = normText(s); e.stelle_norm = normText(s);
e.ort_norm = inp.ort ? normText(inp.ort) : null; e.ort_norm = inp.ort ? normText(inp.ort) : null;
if (!e.firma_norm || !e.stelle_norm) return null; if (!e.firma_norm || !e.stelle_norm) return null;
@@ -196,6 +241,7 @@ function buildAutoEntry(offer, grund) {
e.quelle = sig.external_id ? sig.quelle : null; e.quelle = sig.external_id ? sig.quelle : null;
e.external_id = sig.external_id || null; e.external_id = sig.external_id || null;
e.firma_norm = sig.firma_norm || null; e.firma_norm = sig.firma_norm || null;
e.firma_slug = sig.firma_slug || null;
e.stelle_norm = sig.stelle_norm || null; e.stelle_norm = sig.stelle_norm || null;
e.ort_norm = sig.ort_norm || null; e.ort_norm = sig.ort_norm || null;
e.firma = offer && offer.firma ? offer.firma : null; e.firma = offer && offer.firma ? offer.firma : null;
@@ -210,6 +256,7 @@ module.exports = {
normalizeUrl, normalizeUrl,
urlDomain, urlDomain,
normText, normText,
firmaSlug,
offerSignature, offerSignature,
rowMatches, rowMatches,
matchBlacklist, matchBlacklist,
+1
View File
@@ -938,6 +938,7 @@ function buildOpenApiSpec(baseUrl = '') {
quelle: { type: 'string', nullable: true }, quelle: { type: 'string', nullable: true },
external_id: { type: 'string', nullable: true }, external_id: { type: 'string', nullable: true },
firma_norm: { type: 'string', nullable: true }, firma_norm: { type: 'string', nullable: true },
firma_slug: { type: 'string', nullable: true, description: 'Rechtsform-/Umlaut-robuster Firmen-Slug; primärer Abgleich für typ=firma/firma_stelle/auto.' },
stelle_norm: { type: 'string', nullable: true }, stelle_norm: { type: 'string', nullable: true },
ort_norm: { type: 'string', nullable: true }, ort_norm: { type: 'string', nullable: true },
firma: { type: 'string', nullable: true }, firma: { type: 'string', nullable: true },
+20
View File
@@ -815,6 +815,7 @@ function initializeDatabase() {
quelle TEXT, quelle TEXT,
external_id TEXT, external_id TEXT,
firma_norm TEXT, firma_norm TEXT,
firma_slug TEXT,
stelle_norm TEXT, stelle_norm TEXT,
ort_norm TEXT, ort_norm TEXT,
firma TEXT, firma TEXT,
@@ -824,6 +825,25 @@ function initializeDatabase() {
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
) )
`, () => {}); `, () => {});
// Add + backfill firma_slug on pre-existing installs. The slug is a
// legal-form/umlaut-robust company key that the blacklist now matches
// on; backfill from the stored original company name so old company
// entries start blocking reliably too.
db.run('ALTER TABLE jobangebote_blacklist ADD COLUMN firma_slug TEXT', () => {
db.all(
`SELECT id, firma, firma_norm FROM jobangebote_blacklist
WHERE firma_slug IS NULL AND (firma IS NOT NULL OR firma_norm IS NOT NULL)`,
(err, rows) => {
if (err || !rows) return;
for (const r of rows) {
const slug = blacklist.firmaSlug(r.firma || r.firma_norm || '');
if (slug) {
db.run('UPDATE jobangebote_blacklist SET firma_slug = ? WHERE id = ?', [slug, r.id], () => {});
}
}
}
);
});
// Remember the last recipient address per application (prefill). // Remember the last recipient address per application (prefill).
db.run('ALTER TABLE bewerbungen ADD COLUMN email_empfaenger TEXT', () => {}); db.run('ALTER TABLE bewerbungen ADD COLUMN email_empfaenger TEXT', () => {});