// Per-user job-search profile ("Suchprofil") + run queue. // // Background: the job search used to be two host-side cron scripts with the // search criteria (seven cities, roles, buzzwords) hard-coded into the prompt — // one user's profile, baked into the shell script. On a multi-user platform each // user needs their own criteria, so the *what* now lives here, per user. // // Division of labour, forced by the deployment: the app runs in a container that // has neither `claude` nor `ollama` (both live on the host), so the app cannot // execute a search itself. Instead: // // app (container) -> writes a row into `suchlaeufe` (queued run) // runner (host) -> picks queued/due runs up, executes the headless search, // writes status + results back into the same table // // The runner talks to the tracker through the REST API using *that user's* API // key, so an imported Jobangebot lands in the right account and goes through the // same dedup/blacklist path as everything else. // // What the user configures (Städte, Modus, Zusatz-/Ausschlussbegriffe) and what // is derived: the role titles and technology buzzwords still come from the user's // own Lebenslauf (basis_dokumente, typ "Lebenslauf") — the profile steers *where* // and sets guard rails, the CV supplies *what*. const MODI = ['regional', 'remote', 'beides']; // Weekdays as ISO numbers (1 = Monday … 7 = Sunday), matching what the UI shows. const WOCHENTAGE = [ { nr: 1, kurz: 'Mo' }, { nr: 2, kurz: 'Di' }, { nr: 3, kurz: 'Mi' }, { nr: 4, kurz: 'Do' }, { nr: 5, kurz: 'Fr' }, { nr: 6, kurz: 'Sa' }, { nr: 7, kurz: 'So' }, ]; const DEFAULTS = { aktiv: 0, modus: 'regional', staedte: [], berufsbezeichnungen: '', zusatz_begriffe: '', ausschluesse: 'Zeitarbeit / Arbeitnehmerüberlassung, Personalvermittler / Headhunter', zeitplan_tage: '1,2,3,4,5', zeitplan_zeit: '17:00', }; // Cap the city list: each city multiplies the number of web queries a run makes, // and an unbounded list would turn one run into an all-night job. const MAX_STAEDTE = 12; function parseStaedte(raw) { if (Array.isArray(raw)) return normalizeStaedte(raw); if (typeof raw !== 'string' || !raw.trim()) return []; // Accept both the stored JSON array and free text from the form ("A, B\nC"). if (raw.trim().startsWith('[')) { try { return normalizeStaedte(JSON.parse(raw)); } catch (e) { /* fall through */ } } return normalizeStaedte(raw.split(/[,;\n]/)); } function normalizeStaedte(list) { const out = []; for (const s of list) { const v = String(s == null ? '' : s).trim().replace(/\s+/g, ' '); if (!v) continue; if (out.some((x) => x.toLowerCase() === v.toLowerCase())) continue; // case-insensitive dedup out.push(v); if (out.length >= MAX_STAEDTE) break; } return out; } function parseTage(raw) { const list = String(raw || '').split(',') .map((n) => parseInt(n, 10)) .filter((n) => Number.isInteger(n) && n >= 1 && n <= 7); return [...new Set(list)].sort((a, b) => a - b); } // "HH:MM", clamped to a valid time; anything unparseable falls back to the default. function parseZeit(raw) { const m = /^(\d{1,2}):(\d{2})$/.exec(String(raw || '').trim()); if (!m) return DEFAULTS.zeitplan_zeit; const h = Math.min(23, Math.max(0, parseInt(m[1], 10))); const min = Math.min(59, Math.max(0, parseInt(m[2], 10))); return `${String(h).padStart(2, '0')}:${String(min).padStart(2, '0')}`; } // A DB row (or nothing) -> the profile object the app and the runner work with. // A user without a row is a valid state: they simply have no search configured. function fromRow(row) { const r = row || {}; const modus = MODI.includes(r.modus) ? r.modus : DEFAULTS.modus; return { aktiv: r.aktiv ? 1 : 0, modus, staedte: parseStaedte(r.staedte), berufsbezeichnungen: r.berufsbezeichnungen || '', zusatz_begriffe: r.zusatz_begriffe || '', ausschluesse: r.ausschluesse != null ? r.ausschluesse : DEFAULTS.ausschluesse, zeitplan_tage: parseTage(r.zeitplan_tage != null ? r.zeitplan_tage : DEFAULTS.zeitplan_tage), zeitplan_zeit: parseZeit(r.zeitplan_zeit != null ? r.zeitplan_zeit : DEFAULTS.zeitplan_zeit), }; } // Form body -> storable values. `sanitize` is the app's XSS sanitizer, injected so // this module stays free of app dependencies. function fromForm(body, sanitize = (v) => v) { const b = body || {}; const modus = MODI.includes(b.modus) ? b.modus : DEFAULTS.modus; const tage = Array.isArray(b.tage) ? b.tage : (b.tage ? [b.tage] : []); return { aktiv: (b.aktiv === '1' || b.aktiv === 'on' || b.aktiv === true) ? 1 : 0, modus, staedte: parseStaedte(b.staedte).map((s) => sanitize(s)), berufsbezeichnungen: sanitize(String(b.berufsbezeichnungen || '').trim()), zusatz_begriffe: sanitize(String(b.zusatz_begriffe || '').trim()), ausschluesse: sanitize(String(b.ausschluesse || '').trim()), zeitplan_tage: parseTage(tage.join(',')), zeitplan_zeit: parseZeit(b.zeitplan_zeit), }; } // Values as they go into the DB (arrays serialized). function toRow(profil) { return { aktiv: profil.aktiv ? 1 : 0, modus: profil.modus, staedte: JSON.stringify(profil.staedte || []), berufsbezeichnungen: profil.berufsbezeichnungen || '', zusatz_begriffe: profil.zusatz_begriffe || '', ausschluesse: profil.ausschluesse || '', zeitplan_tage: (profil.zeitplan_tage || []).join(','), zeitplan_zeit: profil.zeitplan_zeit || DEFAULTS.zeitplan_zeit, }; } // Is the profile usable for a run? A regional search without a single city would // otherwise send the model off to search "everywhere", which is exactly the // unbounded run we want to avoid. function validate(profil) { const fehler = []; if (!MODI.includes(profil.modus)) fehler.push('Ungültiger Modus.'); if (profil.modus !== 'remote' && !(profil.staedte || []).length) { fehler.push('Für die regionale Suche muss mindestens eine Stadt angegeben sein.'); } if (profil.aktiv && !(profil.zeitplan_tage || []).length) { fehler.push('Für den Zeitplan muss mindestens ein Wochentag gewählt sein.'); } return fehler; } // Is a scheduled run due? `letzterLauf` is the ISO timestamp of the last run that // the schedule triggered (manual runs do not count, so a manual test does not // swallow the day's scheduled run). Due means: today is one of the chosen days, // the configured time has passed, and no scheduled run has happened since that // time today. function istFaellig(profil, jetzt, letzterLauf) { if (!profil.aktiv) return false; const tage = profil.zeitplan_tage || []; if (!tage.length) return false; const iso = jetzt.getDay() === 0 ? 7 : jetzt.getDay(); // JS: 0=So -> ISO 7 if (!tage.includes(iso)) return false; const [h, m] = profil.zeitplan_zeit.split(':').map(Number); const faelligAb = new Date(jetzt); faelligAb.setHours(h, m, 0, 0); if (jetzt < faelligAb) return false; // today's slot not reached yet if (!letzterLauf) return true; const letzte = new Date(letzterLauf); if (Number.isNaN(letzte.getTime())) return true; return letzte < faelligAb; // already ran for this slot? } // Build the instruction the headless search agent gets. Everything user-specific // is interpolated here, so the skill itself no longer carries anyone's cities. // `profilText` is the user's CV/profile text (from their Lebenslauf template) — // the roles and technologies to search for are derived from it, not configured. function buildPrompt(profil, profilText) { const teile = []; teile.push( 'Führe eine IT-/Fach-Stellensuche vollständig autonom im Hintergrund aus, ohne jede Rückfrage. ' + 'Nutze dafür den Stellensuche-Skill (Quellen-Politik, Dedup, Blacklist, Volltext-Beschreibung).' ); if (profil.modus === 'regional') { teile.push( `Suche AUSSCHLIESSLICH Stellen mit Arbeitsort in diesen Städten: ${profil.staedte.join(', ')} ` + `(Priorität in dieser Reihenfolge, die erste ist der Wohnort). Stellen außerhalb dieser Städte ` + `verwerfen — auch das übrige Umland und reines Homeoffice/Remote ohne Sitz in einer dieser Städte. ` + `Sitzt der Arbeitgeber in einer dieser Städte und bietet zusätzlich Homeoffice, ist die Stelle zulässig. ` + `Setze IMMER labels=["Regional"].` ); } else if (profil.modus === 'remote') { teile.push( 'Suche AUSSCHLIESSLICH Stellen, die 100 % REMOTE / vollständig im Homeoffice und deutschlandweit ' + 'ortsunabhängig ausübbar sind (Anstellung in Deutschland). Firmensitz egal. Verwirf Präsenz-, Hybrid- ' + 'und ortsgebundene Stellen sowie vages „Homeoffice möglich" ohne klare 100-%-Remote-Zusage; bestätige ' + 'die Remote-Regelung live im Anzeigentext per WebFetch. Setze IMMER labels=["Remote"].' ); } else { teile.push( `Suche in ZWEI Durchgängen: (1) regional mit Arbeitsort ausschließlich in ${profil.staedte.join(', ')} ` + `(Priorität in dieser Reihenfolge) — diese Treffer mit labels=["Regional"]; (2) deutschlandweit Stellen, ` + `die 100 % REMOTE / vollständig im Homeoffice ausübbar sind (live per WebFetch bestätigen) — diese ` + `Treffer mit labels=["Remote"]. Alles, was in keinen der beiden Durchgänge fällt, verwerfen.` ); } // Die Berufsbezeichnungen sind die eigentlichen Suchanfragen — sie entscheiden, // welche Anzeigen überhaupt gefunden werden. Sind sie gesetzt, sucht der Agent // danach, statt sich die Titel bei jedem Lauf neu aus dem Lebenslauf zu // improvisieren (was der Nutzer weder sieht noch steuern kann). Das Profil // liefert dann nur noch die Technologien zur Gewichtung. if (profil.berufsbezeichnungen) { teile.push( `Suche gezielt nach Stellen mit diesen Berufsbezeichnungen (das sind deine Suchanfragen, jede einzeln ` + `abfragen): ${profil.berufsbezeichnungen}. Nahe Schreibweisen und gängige Varianten desselben Titels ` + `(m/w/d, "IT-" davor, Singular/Plural, englische Entsprechung) darfst du ergänzen; auf fachfremde Rollen ` + `ausweichen darfst du NICHT.` ); } if (profilText && profilText.trim()) { teile.push( (profil.berufsbezeichnungen ? 'Technologie-Buzzwords und die Gewichtung der Treffer leitest du AUS DEM FOLGENDEN PROFIL DES ' + 'BEWERBERS ab (die Suchtitel stehen oben und gelten) — gewichte Treffer höher, je mehr aus dem ' + 'Profil passt:' : 'Leite Rollenbezeichnungen (Query-Varianten) und Technologie-Buzzwords AUS DEM FOLGENDEN PROFIL DES ' + 'BEWERBERS ab — fasse die Rolle breit (dieselbe Tätigkeit läuft je nach Firma unter vielen Titeln) und ' + 'gewichte Treffer höher, je mehr aus dem Profil passt:') + '\n\n--- PROFIL ---\n' + profilText.trim().slice(0, 6000) + '\n--- ENDE PROFIL ---' ); } else if (profil.berufsbezeichnungen) { teile.push( 'Es ist kein Lebenslauf/Profil hinterlegt — halte dich strikt an die oben genannten Berufsbezeichnungen.' ); } else { teile.push( 'ACHTUNG: Es ist kein Lebenslauf/Profil hinterlegt. Leite Rollen und Suchbegriffe aus den bereits ' + 'erfassten Bewerbungen des Benutzers ab (GET /applications) und halte die Suche eng an deren Stellentiteln.' ); } if (profil.zusatz_begriffe) { teile.push(`Zusätzlich ausdrücklich berücksichtigen: ${profil.zusatz_begriffe}.`); } if (profil.ausschluesse) { teile.push(`Ausschlusskriterien — solche Treffer verwerfen: ${profil.ausschluesse}.`); } teile.push( 'Fokussiere auf Original-Stellenanzeigen echter Arbeitgeber, bevorzugt direkt auf deren Karriereseite ' + '(Firmen recherchieren, Karriereseite per WebFetch prüfen) — nicht nur breit ausgeschriebene Board-Anzeigen.' ); teile.push( 'Pflege JEDEN neuen, live per WebFetch verifizierten Treffer sofort selbst als Jobangebot ein ' + '(POST /joboffers, external_id DETERMINISTISCH aus firma+stelle mit Präfix "stellensuche-", NICHT aus einer ' + 'flüchtigen Board-ID, damit der Server-Upsert statt einer Dublette greift; art=Firmenwebsite wo möglich; die ' + 'VOLLSTÄNDIGE Stellenausschreibung als beschreibung; kontakt_email nur wenn belegt). Beachte die Dedup gegen ' + 'bereits erfasste Bewerbungen/Jobangebote und die Blacklist. Sperre jeden echten Neuimport (action:created) ' + 'danach sofort per POST /joboffers/blacklist (typ=firma_stelle, ort falls bekannt); bei 409 oder bloßem ' + 'updated NICHT blacklisten und NICHT mit force erzwingen.' ); teile.push( 'Stelle am Ende KEINE Frage. Gib als LETZTE Zeile exakt diese Bilanz aus (nur Zahlen):\n' + 'ERGEBNIS neu= dubletten= verworfen=' ); return teile.join('\n\n'); } // The runner reports back "ERGEBNIS neu=1 dubletten=2 verworfen=3"; pull the // numbers out of whatever the agent printed. Missing counts read as 0. function parseErgebnis(text) { const m = /ERGEBNIS\s+neu=(\d+)\s+dubletten=(\d+)\s+verworfen=(\d+)/i.exec(String(text || '')); if (!m) return null; return { neu: Number(m[1]), dubletten: Number(m[2]), verworfen: Number(m[3]) }; } module.exports = { MODI, WOCHENTAGE, DEFAULTS, MAX_STAEDTE, fromRow, fromForm, toRow, validate, istFaellig, buildPrompt, parseErgebnis, parseStaedte, parseTage, parseZeit, };