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:
+55
-8
@@ -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,
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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', () => {});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user