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:
|
||||
// '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
|
||||
// 'firma' -> firma_slug (block a company entirely; slug is a
|
||||
// legal-form/umlaut-robust key, falls back to firma_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)
|
||||
|
||||
const TYPES = ['url', 'domain', 'firma', 'firma_stelle', 'auto'];
|
||||
@@ -72,6 +73,40 @@ function normalizeUrl(raw) {
|
||||
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:
|
||||
// unescape, lowercase, drop gender markers like "(m/w/d)", collapse anything
|
||||
// non-alphanumeric to single spaces.
|
||||
@@ -95,11 +130,19 @@ function offerSignature(offer) {
|
||||
external_id: o.external_id != null && String(o.external_id).trim() !== ''
|
||||
? String(o.external_id).trim() : null,
|
||||
firma_norm: normText(o.firma),
|
||||
firma_slug: firmaSlug(o.firma),
|
||||
stelle_norm: normText(o.stelle),
|
||||
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?
|
||||
function rowMatches(row, sig) {
|
||||
switch (row.typ) {
|
||||
@@ -108,9 +151,9 @@ function rowMatches(row, sig) {
|
||||
case 'domain':
|
||||
return !!row.domain && row.domain === sig.domain;
|
||||
case 'firma':
|
||||
return !!row.firma_norm && row.firma_norm === sig.firma_norm;
|
||||
return firmaMatch(row, sig);
|
||||
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.ort_norm || row.ort_norm === sig.ort_norm);
|
||||
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.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
|
||||
if ((row.firma_slug || row.firma_norm) && row.stelle_norm
|
||||
&& firmaMatch(row, sig) && row.stelle_norm === sig.stelle_norm
|
||||
&& (!row.ort_norm || row.ort_norm === sig.ort_norm)) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -137,7 +180,7 @@ function matchBlacklist(rows, offer) {
|
||||
// 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',
|
||||
'firma_norm', 'firma_slug', 'stelle_norm', 'ort_norm', 'firma', 'stelle', 'quelle_url', 'grund',
|
||||
];
|
||||
|
||||
function emptyEntry() {
|
||||
@@ -170,6 +213,7 @@ function buildManualEntry(input) {
|
||||
const w = wert || inp.firma || '';
|
||||
e.firma = w || null;
|
||||
e.firma_norm = normText(w);
|
||||
e.firma_slug = firmaSlug(w) || null;
|
||||
if (!e.firma_norm) return null;
|
||||
} else if (typ === 'firma_stelle') {
|
||||
const f = inp.firma || '';
|
||||
@@ -177,6 +221,7 @@ function buildManualEntry(input) {
|
||||
e.firma = f || null;
|
||||
e.stelle = s || null;
|
||||
e.firma_norm = normText(f);
|
||||
e.firma_slug = firmaSlug(f) || null;
|
||||
e.stelle_norm = normText(s);
|
||||
e.ort_norm = inp.ort ? normText(inp.ort) : 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.external_id = sig.external_id || null;
|
||||
e.firma_norm = sig.firma_norm || null;
|
||||
e.firma_slug = sig.firma_slug || null;
|
||||
e.stelle_norm = sig.stelle_norm || null;
|
||||
e.ort_norm = sig.ort_norm || null;
|
||||
e.firma = offer && offer.firma ? offer.firma : null;
|
||||
@@ -210,6 +256,7 @@ module.exports = {
|
||||
normalizeUrl,
|
||||
urlDomain,
|
||||
normText,
|
||||
firmaSlug,
|
||||
offerSignature,
|
||||
rowMatches,
|
||||
matchBlacklist,
|
||||
|
||||
@@ -938,6 +938,7 @@ function buildOpenApiSpec(baseUrl = '') {
|
||||
quelle: { type: 'string', nullable: true },
|
||||
external_id: { 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 },
|
||||
ort_norm: { type: 'string', nullable: true },
|
||||
firma: { type: 'string', nullable: true },
|
||||
|
||||
Reference in New Issue
Block a user