// 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(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/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, };