Scanner: Stellenanzeigen aus Jobbörsen (DACH, max. 21 Tage)

Neuer Menüpunkt Scanner mit Dashboard und Suchagenten, Zeitplan und
API-Schlüssel unter Einstellungen -> Scanner.

- Quellen: Agentur für Arbeit (API), StepStone, karriere.at,
  freelancermap (HTML) sowie Adzuna und Jooble (kostenlose Schlüssel)
- Quote = gefundene Schlagwörter, Titel zählt doppelt; keine KI
- Abgleich mit icon_companies, bekannte Firmen oben mit Abzeichen
- Zeitplan Vorgabe stündlich Mo-Fr 06-20 Uhr, einstellbar
- Sperre gegen parallele Läufe wie im perinco-Scanner

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
schakal00783
2026-09-29 10:49:44 +02:00
co-authored by Claude Opus 5.5
parent 6fe5b18c11
commit 1b82f580db
23 changed files with 2894 additions and 2 deletions
+92
View File
@@ -0,0 +1,92 @@
/*
* Bewertung einer Anzeige gegen einen Suchagenten - ohne KI.
*
* Wunsch vom 29.09.2026: kein Abgleich mit Lebenslaeufen, sondern "je mehr
* Schlagwoerter gefunden werden, desto besser der Treffer". Gezaehlt wird in
* Titel und Text; ein Schlagwort im Titel zaehlt doppelt, weil der Titel die
* Rolle benennt und der Text oft nur Umfeld beschreibt.
*
* score = Summe(Gewicht je gefundenem Schlagwort) / (2 * Anzahl Schlagwoerter)
* Gewicht 2 = im Titel, 1 = nur im Text
*/
const normalize = (text) => String(text ?? '').normalize('NFC').toLowerCase();
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/**
* Kurze Begriffe ("IT", "SAP", "HR") nur als ganzes Wort - sonst steckt "it" in
* jedem "Mitarbeiter". Laengere als Teilwort, damit "Projektleiter" auch in
* "IT-Projektleiterin" und "Projektleiterstelle" trifft.
*/
export function containsTerm(text, term) {
const needle = normalize(term).trim();
if (!needle) return false;
const haystack = normalize(text);
if (needle.length <= 3) {
return new RegExp(`(?<![\\p{L}\\p{N}])${escape(needle)}(?![\\p{L}\\p{N}])`, 'u').test(haystack);
}
return haystack.includes(needle);
}
/**
* @returns {{ score: number, hits: string[] } | null} null = ausgeschlossen
* oder kein einziges Schlagwort gefunden
*/
export function scoreJob(agent, job) {
const title = job.title ?? '';
const all = `${title}\n${job.company ?? ''}\n${job.description ?? ''}`;
if ((agent.excludes ?? []).some((term) => containsTerm(all, term))) return null;
const keywords = agent.keywords ?? [];
if (keywords.length === 0) return null;
let points = 0;
const hits = [];
for (const keyword of keywords) {
if (containsTerm(title, keyword)) {
points += 2;
hits.push(keyword);
} else if (containsTerm(all, keyword)) {
points += 1;
hits.push(keyword);
}
}
if (hits.length === 0) return null;
return { score: Math.round((100 * points) / (2 * keywords.length)), hits };
}
/*
* Firmennamen der Boersen und des CRM auf einen Nenner bringen:
* "Müller Holding GmbH & Co. KG" -> "müller holding".
*/
const LEGAL_FORMS =
/\b(gmbh|mbh|ag|se|kg|kgaa|ohg|gbr|ug|e\.?\s?v|e\.?\s?k|co|inc|ltd|llc|plc|sa|sarl|bv|nv|haftungsbeschränkt|gesellschaft mit beschränkter haftung|aktiengesellschaft)\b\.?/g;
export function normalizeCompany(name) {
return normalize(name)
.replace(/\(.*?\)/g, ' ')
.replace(/&/g, ' ')
.replace(LEGAL_FORMS, ' ')
.replace(/[^\p{L}\p{N}]+/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
}
/**
* Sucht das Unternehmen im CRM. Gleicher Name gewinnt; sonst darf der Name der
* Anzeige mit dem CRM-Namen beginnen ("Siemens" -> "Siemens Mobility"), aber
* nur ab fuenf Zeichen, sonst wird aus "ABC" jeder, der mit "ABC" anfaengt.
*
* @param {Array<{id:number, key:string}>} companies vorberechnet, laengste zuerst
*/
export function findCompany(companies, name) {
const key = normalizeCompany(name);
if (!key) return null;
const exact = companies.find((company) => company.key === key);
if (exact) return exact.id;
const prefix = companies.find((company) => company.key.length >= 5 && key.startsWith(`${company.key} `));
return prefix ? prefix.id : null;
}