From 9418750061e157e204e4a9806c551f4119005af4 Mon Sep 17 00:00:00 2001 From: Thomas Hackner Date: Mon, 13 Jul 2026 23:28:56 +0200 Subject: [PATCH] Jobsuche als Feature der App: Suchprofil pro Benutzer statt Cron-Prompt Die Jobsuche lag in zwei Host-Skripten (jobsuche-cron.sh, jobsuche-remote-cron.sh) aus der Ein-Benutzer-Zeit: Die Suchkriterien (sieben Staedte, Rollen, Buzzwords) standen fest im Prompt, und importiert wurde mit EINEM globalen API-Token aus der .env - der zufaellig dem Admin gehoerte. Auf der Multi-User-Plattform ist beides hinfaellig. Jetzt legt jeder Benutzer sein Suchprofil selbst fest (/jobsuche): - Modus: regional / 100 % Remote / beides - Staedte (die erste gilt als Wohnort und wird hoechstpriorisiert, max. 12) - Feinschliff: zusaetzliche Begriffe, Ausschluesse - Zeitplan: Wochentage + Uhrzeit, plus Button "Jetzt suchen" Rollen und Technologien bleiben abgeleitet - aus dem Lebenslauf des Benutzers (basis_dokumente), nicht aus einer gepflegten Liste. Admins koennen Profil und Zeitplan eines Benutzers ueber /jobsuche?user= mitpflegen (Link im Admin-Panel). Aufteilung App/Host: Der Container hat weder claude noch ollama. Die App reiht Laeufe daher nur in die Warteschlange ein (Tabelle suchlaeufe); der neue Runner auf dem Host (scripts/jobsuche-runner.js, Cron alle 5 Min) arbeitet sie ab, baut den Prompt je Benutzer aus dessen Profil + Lebenslauf und laeuft mit DESSEN Zugangsdaten: - BEWERBUNG_API_KEY = eigener API-Token des Benutzers (wird beim ersten Lauf automatisch erzeugt), damit Treffer im richtigen Konto landen und durch dieselbe Dedup-/Blacklist-Logik gehen, - ANTHROPIC_BASE_URL/AUTH_TOKEN = eigener Ollama-Key des Benutzers ueber die Anthropic-kompatible Schnittstelle von Ollama Cloud (https://ollama.com/v1/messages, verifiziert: gueltiger Key -> 200, ungueltiger -> 401). Damit zahlt jeder seine eigene Suche, statt alles ueber die Host-Subscription zu buchen (JOBSUCHE_KI_AUTH=host stellt das alte Verhalten wieder her). Ohne eigenen Ollama-Key oder ohne Lebenslauf bricht der Lauf mit klarer Meldung ab statt still nichts zu tun; die Oberflaeche warnt vorab. Kein Stapeln: solange ein Lauf offen ist, erzeugt ein weiterer Klick keinen zweiten. Verwaiste Laeufe (Prozess weg) werden nach Zeitlimit als Fehler freigegeben. Verifiziert mit zwei Benutzern: Zugriffsschutz (fremdes Profil -> 403), Speichern, Warteschlange, automatische Key-Erzeugung, Prompt-Aufbau aus Profil + CV, Ergebnis-Ruecklauf in die Oberflaeche, Fehlerpfade und die Faelligkeitslogik des Zeitplans (Tag/Uhrzeit/bereits gelaufen). Co-Authored-By: Claude Opus 4.8 --- lib/suchprofil.js | 263 +++++++++++++++++++++++++++ scripts/jobsuche-runner.js | 327 ++++++++++++++++++++++++++++++++++ scripts/jobsuche-runner.sh | 58 ++++++ server.js | 198 ++++++++++++++++++++ views/admin.ejs | 3 + views/jobsuche.ejs | 228 ++++++++++++++++++++++++ views/partials/header.ejs | 10 ++ views/partials/suchlaeufe.ejs | 54 ++++++ 8 files changed, 1141 insertions(+) create mode 100644 lib/suchprofil.js create mode 100644 scripts/jobsuche-runner.js create mode 100755 scripts/jobsuche-runner.sh create mode 100644 views/jobsuche.ejs create mode 100644 views/partials/suchlaeufe.ejs diff --git a/lib/suchprofil.js b/lib/suchprofil.js new file mode 100644 index 0000000..5eeb5de --- /dev/null +++ b/lib/suchprofil.js @@ -0,0 +1,263 @@ +// 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: [], + 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), + 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)), + 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 || []), + 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.` + ); + } + + if (profilText && profilText.trim()) { + teile.push( + '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 { + 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, +}; diff --git a/scripts/jobsuche-runner.js b/scripts/jobsuche-runner.js new file mode 100644 index 0000000..6c5ea6f --- /dev/null +++ b/scripts/jobsuche-runner.js @@ -0,0 +1,327 @@ +#!/usr/bin/env node +// Host-side runner for the per-user job search. +// +// Why it lives on the host and not in the app: the app container has neither +// `claude` nor `ollama`. The app therefore only *queues* runs (table suchlaeufe); +// this script — started by cron every few minutes — picks them up and executes +// them. It replaces the two old single-user scripts (jobsuche-cron.sh and +// jobsuche-remote-cron.sh), whose search criteria were hard-coded into the prompt. +// +// Per run it: +// 1. enqueues scheduled runs that are due (per user's Zeitplan), +// 2. claims one queued run at a time (status angefordert -> laeuft), +// 3. builds the prompt from that user's Suchprofil + their Lebenslauf, +// 4. runs the headless agent with THAT user's credentials: +// - BEWERBUNG_API_KEY = the user's API token (created if missing), so every +// imported Jobangebot lands in their account and passes the same +// dedup/blacklist path as the rest of the app, +// - the model is driven by the user's own Ollama key (see KI_AUTH below), +// 5. parses the agent's final "ERGEBNIS neu=… dubletten=… verworfen=…" line and +// writes status + counts back, so the UI can show what happened. +// +// Everything is per user; nothing here reads a global .env any more. + +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const { spawn } = require('child_process'); +const sqlite3 = require('sqlite3'); + +const suchprofil = require('../lib/suchprofil'); + +const DB_PATH = process.env.JOBSUCHE_DB || '/opt/jobbi-bewerbung/data/bewerbungen.db'; +const LOG_DIR = process.env.JOBSUCHE_LOG_DIR || '/opt/jobbi-bewerbung/logs'; +const API_URL = process.env.JOBSUCHE_API_URL || 'http://localhost:4327/api/v1'; +// Swappable so the pipeline can be exercised end-to-end without burning tokens. +const CLAUDE_BIN = process.env.JOBSUCHE_CLAUDE_BIN || 'claude'; +// 'benutzer' = each run uses that user's own Ollama key (they pay for their own +// search). 'host' = fall back to the machine's ollama login via `ollama launch`. +const KI_AUTH = process.env.JOBSUCHE_KI_AUTH || 'benutzer'; +const RUN_TIMEOUT_MS = (Number(process.env.JOBSUCHE_TIMEOUT) || 2700) * 1000; +// A run that has been 'laeuft' for longer than this lost its process (reboot, +// OOM); it would otherwise block the user's queue forever. +const STALE_MS = RUN_TIMEOUT_MS + 10 * 60 * 1000; +const LOG_RETENTION_DAYS = Number(process.env.JOBSUCHE_LOG_RETENTION_DAYS) || 30; +const CFG = 'cfg:'; + +const db = new sqlite3.Database(DB_PATH); +const all = (sql, p = []) => new Promise((res, rej) => db.all(sql, p, (e, r) => (e ? rej(e) : res(r)))); +const get = (sql, p = []) => new Promise((res, rej) => db.get(sql, p, (e, r) => (e ? rej(e) : res(r)))); +const run = (sql, p = []) => new Promise((res, rej) => db.run(sql, p, function (e) { e ? rej(e) : res(this); })); + +function log(msg) { + console.log(`${new Date().toISOString()} ${msg}`); +} + +async function cfgWert(userId, key) { + const row = await get('SELECT value FROM app_state WHERE user_id = ? AND key = ?', [userId, CFG + key]); + return row && row.value ? row.value : ''; +} + +// The user's API token, created on first use. Without it the agent could not +// import anything; making the user generate one by hand first would just be a +// trap ("profile active, nothing happens"). +async function ensureApiKey(userId) { + const vorhanden = await cfgWert(userId, 'API_TOKEN'); + if (vorhanden) return vorhanden; + const token = crypto.randomBytes(32).toString('hex'); + await run( + 'INSERT INTO app_state (user_id, key, value) VALUES (?, ?, ?) ' + + 'ON CONFLICT(user_id, key) DO UPDATE SET value = excluded.value', + [userId, CFG + 'API_TOKEN', token] + ); + log(` API-Key für Benutzer ${userId} automatisch erzeugt.`); + return token; +} + +// The CV text the search profile is derived from. Prefer the Lebenslauf, fall +// back to a Profil/Kurzprofil — the point is to give the model the user's actual +// roles and technologies rather than a hard-coded list. +async function profilText(userId) { + const rows = await all( + `SELECT typ, inhalt FROM basis_dokumente + WHERE user_id = ? AND typ IN ('Lebenslauf', 'Profil/Kurzprofil') + AND inhalt IS NOT NULL AND inhalt != '' + ORDER BY CASE typ WHEN 'Lebenslauf' THEN 0 ELSE 1 END, id ASC`, + [userId] + ); + return rows.map((r) => (r.inhalt || '').trim()).filter(Boolean).join('\n\n---\n\n'); +} + +// 1. Queue scheduled runs that are due. +async function faelligeEinreihen() { + const jetzt = new Date(); + const users = await all('SELECT user_id FROM suchprofil WHERE aktiv = 1'); + for (const { user_id: userId } of users) { + const profil = suchprofil.fromRow(await get('SELECT * FROM suchprofil WHERE user_id = ?', [userId])); + if (suchprofil.validate(profil).length) continue; // unbrauchbares Profil -> kein Zeitplan-Lauf + + // Only scheduled runs count here: a manual test run must not swallow the + // day's automatic run. + const letzter = await get( + "SELECT angefordert_at FROM suchlaeufe WHERE user_id = ? AND ausloeser = 'zeitplan' ORDER BY id DESC LIMIT 1", + [userId] + ); + const letzterAt = letzter ? `${letzter.angefordert_at}Z`.replace(' ', 'T') : null; + if (!suchprofil.istFaellig(profil, jetzt, letzterAt)) continue; + + const offen = await get( + "SELECT id FROM suchlaeufe WHERE user_id = ? AND status IN ('angefordert','laeuft') LIMIT 1", + [userId] + ); + if (offen) continue; // läuft schon etwas für diesen Benutzer + + await run("INSERT INTO suchlaeufe (user_id, status, ausloeser) VALUES (?, 'angefordert', 'zeitplan')", [userId]); + log(`Zeitplan: Lauf für Benutzer ${userId} eingereiht.`); + } +} + +// Runs whose process died: release them so the user is not stuck. +async function verwaisteAufraeumen() { + const res = await run( + `UPDATE suchlaeufe SET status = 'fehler', beendet_at = CURRENT_TIMESTAMP, + fehler = 'Lauf abgebrochen (Prozess nicht mehr vorhanden).' + WHERE status = 'laeuft' + AND gestartet_at IS NOT NULL + AND (julianday('now') - julianday(gestartet_at)) * 86400000 > ?`, + [STALE_MS] + ); + if (res.changes) log(`${res.changes} verwaiste(n) Lauf/Läufe als Fehler markiert.`); +} + +// Claim the oldest queued run. The conditional UPDATE is the claim: a second +// runner starting concurrently gets changes = 0 and moves on. +async function naechstenLaufClaimen() { + const kandidat = await get("SELECT * FROM suchlaeufe WHERE status = 'angefordert' ORDER BY id ASC LIMIT 1"); + if (!kandidat) return null; + const res = await run( + "UPDATE suchlaeufe SET status = 'laeuft', gestartet_at = CURRENT_TIMESTAMP WHERE id = ? AND status = 'angefordert'", + [kandidat.id] + ); + if (!res.changes) return null; + return get('SELECT * FROM suchlaeufe WHERE id = ?', [kandidat.id]); +} + +async function laufAbschliessen(id, status, felder = {}) { + await run( + `UPDATE suchlaeufe SET status = ?, beendet_at = CURRENT_TIMESTAMP, + neu = ?, dubletten = ?, verworfen = ?, fehler = ?, log_datei = ? + WHERE id = ?`, + [status, felder.neu ?? null, felder.dubletten ?? null, felder.verworfen ?? null, + felder.fehler || null, felder.log || null, id] + ); +} + +// Spawn the headless agent and collect its output. +function agentStarten(prompt, env, logStream) { + return new Promise((resolve) => { + let argv; + if (KI_AUTH === 'host') { + // Legacy path: the machine's ollama login pays for the run. + argv = ['launch', 'claude', '--model', env.JOBSUCHE_MODEL, '--', + '--dangerously-skip-permissions', '-p', prompt]; + argv = { cmd: 'ollama', args: argv }; + } else { + // Per-user path: Claude Code talks to Ollama's Anthropic-compatible API with + // *this user's* key, so the search is billed to whoever requested it. + argv = { cmd: CLAUDE_BIN, args: ['--dangerously-skip-permissions', '-p', prompt] }; + } + + const kind = spawn(argv.cmd, argv.args, { + env, + cwd: '/opt/jobbi-bewerbung', + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let ausgabe = ''; + const sammeln = (buf) => { + const s = buf.toString(); + ausgabe += s; + logStream.write(s); + }; + kind.stdout.on('data', sammeln); + kind.stderr.on('data', sammeln); + + const timer = setTimeout(() => { + kind.kill('SIGTERM'); + setTimeout(() => kind.kill('SIGKILL'), 60000); + }, RUN_TIMEOUT_MS); + + kind.on('error', (err) => { + clearTimeout(timer); + resolve({ code: -1, ausgabe, fehler: `Agent nicht startbar: ${err.message}` }); + }); + kind.on('close', (code, signal) => { + clearTimeout(timer); + const abgebrochen = signal === 'SIGTERM' || signal === 'SIGKILL'; + resolve({ + code, + ausgabe, + fehler: abgebrochen ? `Zeitlimit von ${RUN_TIMEOUT_MS / 1000}s überschritten.` : null, + }); + }); + }); +} + +async function laufAusfuehren(lauf) { + const userId = lauf.user_id; + const user = await get('SELECT username FROM users WHERE id = ?', [userId]); + const name = user ? user.username : `#${userId}`; + log(`Lauf ${lauf.id} (Benutzer ${name}, ${lauf.ausloeser}) startet.`); + + const profil = suchprofil.fromRow(await get('SELECT * FROM suchprofil WHERE user_id = ?', [userId])); + const fehler = suchprofil.validate(profil); + if (fehler.length) { + await laufAbschliessen(lauf.id, 'fehler', { fehler: fehler.join(' ') }); + log(` abgebrochen: ${fehler.join(' ')}`); + return; + } + + const ollamaKey = await cfgWert(userId, 'OLLAMA_API_KEY'); + if (KI_AUTH !== 'host' && !ollamaKey) { + const msg = 'Kein eigener Ollama-API-Schlüssel hinterlegt — der Suchlauf läuft über das KI-Kontingent des Benutzers. Bitte in den Einstellungen eintragen.'; + await laufAbschliessen(lauf.id, 'fehler', { fehler: msg }); + log(` abgebrochen: kein Ollama-Key für ${name}.`); + return; + } + + const apiKey = await ensureApiKey(userId); + const modell = (await cfgWert(userId, 'OLLAMA_MODEL')) || 'glm-5.2:cloud'; + const prompt = suchprofil.buildPrompt(profil, await profilText(userId)); + + fs.mkdirSync(LOG_DIR, { recursive: true }); + const stamp = new Date().toISOString().replace(/[:.]/g, '-'); + const logDatei = path.join(LOG_DIR, `jobsuche_${name}_${stamp}.log`); + const logStream = fs.createWriteStream(logDatei, { flags: 'a' }); + logStream.write(`=== Suchlauf ${lauf.id} — Benutzer ${name} (${lauf.ausloeser}) ===\n`); + logStream.write(`Modus: ${profil.modus} | Städte: ${profil.staedte.join(', ') || '–'}\n`); + logStream.write(`Modell: ${modell} | KI-Auth: ${KI_AUTH}\n${'-'.repeat(50)}\n`); + + const env = { + ...process.env, + HOME: process.env.HOME || '/root', + PATH: `/root/.local/bin:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}`, + IS_SANDBOX: '1', + // The tracker API — the agent imports Jobangebote as THIS user. + BEWERBUNG_API_URL: API_URL, + BEWERBUNG_API_KEY: apiKey, + JOBSUCHE_MODEL: modell, + }; + if (KI_AUTH !== 'host') { + // Point Claude Code at Ollama's Anthropic-compatible endpoint with the user's key. + env.ANTHROPIC_BASE_URL = (await cfgWert(userId, 'OLLAMA_HOST')) || 'https://ollama.com'; + env.ANTHROPIC_AUTH_TOKEN = ollamaKey; + env.ANTHROPIC_MODEL = modell; + env.ANTHROPIC_SMALL_FAST_MODEL = modell; + } + + const { code, ausgabe, fehler: laufFehler } = await agentStarten(prompt, env, logStream); + const ergebnis = suchprofil.parseErgebnis(ausgabe); + + logStream.write(`\n${'-'.repeat(50)}\nExit-Code: ${code}\n`); + logStream.end(); + + if (laufFehler) { + await laufAbschliessen(lauf.id, 'fehler', { fehler: laufFehler, log: logDatei }); + log(` Lauf ${lauf.id} fehlgeschlagen: ${laufFehler}`); + return; + } + if (code !== 0 && !ergebnis) { + await laufAbschliessen(lauf.id, 'fehler', { + fehler: `Suchlauf mit Exit-Code ${code} beendet (Details im Log).`, + log: logDatei, + }); + log(` Lauf ${lauf.id} fehlgeschlagen (Exit ${code}).`); + return; + } + if (!ergebnis) { + // The agent finished but did not report its tally — treat as done with unknown + // numbers rather than as a failure; the import itself already happened. + await laufAbschliessen(lauf.id, 'fertig', { neu: 0, dubletten: 0, verworfen: 0, log: logDatei }); + log(` Lauf ${lauf.id} beendet, aber ohne ERGEBNIS-Zeile.`); + return; + } + + await laufAbschliessen(lauf.id, 'fertig', { ...ergebnis, log: logDatei }); + log(` Lauf ${lauf.id} fertig: ${ergebnis.neu} neu, ${ergebnis.dubletten} Dubletten, ${ergebnis.verworfen} verworfen.`); +} + +function alteLogsAufraeumen() { + try { + const grenze = Date.now() - LOG_RETENTION_DAYS * 86400000; + for (const f of fs.readdirSync(LOG_DIR)) { + if (!f.startsWith('jobsuche_') || !f.endsWith('.log')) continue; + const p = path.join(LOG_DIR, f); + if (fs.statSync(p).mtimeMs < grenze) fs.unlinkSync(p); + } + } catch (e) { /* Aufräumen ist best effort */ } +} + +async function main() { + if (!fs.existsSync(DB_PATH)) { + console.error(`Datenbank nicht gefunden: ${DB_PATH}`); + process.exit(1); + } + await run('PRAGMA busy_timeout = 10000'); // die App schreibt parallel + + await verwaisteAufraeumen(); + await faelligeEinreihen(); + + // Work the queue until it is empty. Runs are serialised on purpose: a search is + // web- and token-heavy, and several in parallel would trip rate limits. + let lauf; + let anzahl = 0; + while ((lauf = await naechstenLaufClaimen())) { + await laufAusfuehren(lauf); + anzahl += 1; + } + if (!anzahl) log('Nichts zu tun.'); + + alteLogsAufraeumen(); + db.close(); +} + +main().catch((e) => { + console.error('Runner-Fehler:', e); + process.exit(1); +}); diff --git a/scripts/jobsuche-runner.sh b/scripts/jobsuche-runner.sh new file mode 100755 index 0000000..9f232b4 --- /dev/null +++ b/scripts/jobsuche-runner.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Cron-Einstiegspunkt für die Jobsuche (Multi-User). +# +# Ersetzt jobsuche-cron.sh + jobsuche-remote-cron.sh: Diese beiden hatten das +# Suchprofil (sieben Städte, Rollen, Buzzwords) fest im Prompt stehen und liefen +# mit EINEM globalen API-Token aus der .env — beides ist mit der Multi-User- +# Plattform hinfällig. +# +# Jetzt: Jeder Benutzer pflegt sein Suchprofil und seinen Zeitplan in der App +# (/jobsuche). Dieses Skript ruft nur noch den Runner auf, der fällige und manuell +# angeforderte Läufe abarbeitet — je Benutzer mit dessen eigenem Ollama- und +# API-Schlüssel. +# +# Cron (alle 5 Minuten — die Uhrzeit steuert der Benutzer, nicht der Cron): +# */5 * * * * /opt/jobbi-bewerbung/bin/jobsuche-runner.sh +# +# Manueller Lauf: /opt/jobbi-bewerbung/bin/jobsuche-runner.sh + +set -uo pipefail + +export HOME="${HOME:-/root}" +export PATH="/root/.local/bin:/usr/local/bin:/usr/bin:/bin:$PATH" +# Claude Code verweigert --dangerously-skip-permissions als root; in dieser +# Container-/Sandbox-Umgebung mit IS_SANDBOX=1 erlaubt. +export IS_SANDBOX=1 + +PROJECT_DIR="/opt/jobbi-bewerbung" +LOG_DIR="$PROJECT_DIR/logs" +RUNNER="$PROJECT_DIR/source/scripts/jobsuche-runner.js" + +mkdir -p "$LOG_DIR" +LOG="$LOG_DIR/jobsuche-runner.log" + +# Einzelinstanz: ein noch laufender Suchlauf darf vom nächsten Cron-Tick nicht +# ein zweites Mal gestartet werden. +exec 9>"$LOG_DIR/jobsuche-runner.lock" +if ! flock -n 9; then + exit 0 +fi + +cd "$PROJECT_DIR" || exit 1 + +# API-Erreichbarkeit prüfen — ohne den Tracker kann der Agent nichts importieren. +if ! curl -fsS --max-time 10 http://localhost:4327/api/v1/health >/dev/null 2>&1; then + echo "$(date -Is) FEHLER: Bewerbungs-Tracker (localhost:4327) nicht erreichbar – Lauf übersprungen." >> "$LOG" + exit 1 +fi + +node "$RUNNER" >> "$LOG" 2>&1 +STATUS=$? + +# Log der Runner-Steuerung kurz halten (die eigentlichen Suchlauf-Logs liegen +# je Lauf separat und werden vom Runner selbst rotiert). +if [ -f "$LOG" ] && [ "$(wc -l < "$LOG")" -gt 5000 ]; then + tail -n 2000 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG" +fi + +exit $STATUS diff --git a/server.js b/server.js index 32e014c..41efbd4 100644 --- a/server.js +++ b/server.js @@ -46,6 +46,7 @@ const { userContext, currentUser, currentUserId } = require('./lib/context'); const password = require('./lib/password'); const migrate = require('./lib/migrate-multiuser'); const { runMigration, importEnvIntoAdmin } = migrate; +const suchprofil = require('./lib/suchprofil'); const app = express(); const PORT = process.env.PORT || 3000; @@ -642,6 +643,44 @@ async function loadSettings() { } } +// The user's job-search profile. Like the settings above, "no row" is a valid +// state and means "no search configured" — it reads as the defaults (inactive). +async function loadSuchprofil(userId) { + const row = await dbGet('SELECT * FROM suchprofil WHERE user_id = ?', [userId || currentUserId()]); + return suchprofil.fromRow(row); +} + +async function saveSuchprofil(userId, profil) { + const r = suchprofil.toRow(profil); + await dbRun( + `INSERT INTO suchprofil (user_id, aktiv, modus, staedte, zusatz_begriffe, ausschluesse, zeitplan_tage, zeitplan_zeit, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(user_id) DO UPDATE SET + aktiv = excluded.aktiv, modus = excluded.modus, staedte = excluded.staedte, + zusatz_begriffe = excluded.zusatz_begriffe, ausschluesse = excluded.ausschluesse, + zeitplan_tage = excluded.zeitplan_tage, zeitplan_zeit = excluded.zeitplan_zeit, + updated_at = CURRENT_TIMESTAMP`, + [userId, r.aktiv, r.modus, r.staedte, r.zusatz_begriffe, r.ausschluesse, r.zeitplan_tage, r.zeitplan_zeit] + ); +} + +// Queue a search run for a user. The host-side runner executes it (this container +// has no `claude`/`ollama`). Refuses to pile up work: if a run is already waiting +// or in flight for that user, the existing one is returned instead of a second. +async function queueSuchlauf(userId, ausloeser) { + const offen = await dbGet( + "SELECT * FROM suchlaeufe WHERE user_id = ? AND status IN ('angefordert', 'laeuft') ORDER BY id DESC LIMIT 1", + [userId] + ); + if (offen) return { lauf: offen, neu: false }; + const res = await dbRun( + "INSERT INTO suchlaeufe (user_id, status, ausloeser) VALUES (?, 'angefordert', ?)", + [userId, ausloeser === 'zeitplan' ? 'zeitplan' : 'manuell'] + ); + const lauf = await dbGet('SELECT * FROM suchlaeufe WHERE id = ? AND user_id = ?', [res.lastID, userId]); + return { lauf, neu: true }; +} + // Match an incoming message to an application: first via In-Reply-To/References // pointing at one of our sent messages, then by sender = a previous recipient. async function matchBewerbung(msg) { @@ -1201,6 +1240,47 @@ async function initializeDatabase() { ) `); + // Job-search profile (one row per user; a missing row = no search configured). + // The user sets *where* (Städte/Modus) and the guard rails; the roles and + // buzzwords are derived from their Lebenslauf at run time — see lib/suchprofil. + await exec(` + CREATE TABLE IF NOT EXISTS suchprofil ( + user_id INTEGER PRIMARY KEY, + aktiv INTEGER NOT NULL DEFAULT 0, + modus TEXT NOT NULL DEFAULT 'regional', + staedte TEXT NOT NULL DEFAULT '[]', + zusatz_begriffe TEXT DEFAULT '', + ausschluesse TEXT DEFAULT '', + zeitplan_tage TEXT DEFAULT '1,2,3,4,5', + zeitplan_zeit TEXT DEFAULT '17:00', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + + // Queue + history of search runs. The app only ever *enqueues* (status + // 'angefordert'); the host-side runner (bin/jobsuche-runner.sh) picks runs up, + // because `claude`/`ollama` exist on the host, not in this container. + await exec(` + CREATE TABLE IF NOT EXISTS suchlaeufe ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'angefordert', + ausloeser TEXT NOT NULL DEFAULT 'manuell', + angefordert_at DATETIME DEFAULT CURRENT_TIMESTAMP, + gestartet_at DATETIME, + beendet_at DATETIME, + neu INTEGER, + dubletten INTEGER, + verworfen INTEGER, + fehler TEXT, + log_datei TEXT, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ) + `); + await exec('CREATE INDEX IF NOT EXISTS idx_suchlaeufe_user ON suchlaeufe(user_id, id DESC)'); + await exec('CREATE INDEX IF NOT EXISTS idx_suchlaeufe_status ON suchlaeufe(status)'); + // Personal details of the applicant (one row per user). await exec(` CREATE TABLE IF NOT EXISTS settings ( @@ -2912,6 +2992,124 @@ initializeDatabase().then(async () => { } }); + // ----- Jobsuche: Suchprofil + Suchläufe ----- + // The profile steers *where* to search; the roles/technologies come from the + // user's own Lebenslauf at run time. Runs are only queued here — the host-side + // runner executes them (see lib/suchprofil for why). + // + // An admin may manage another user's profile via ?user= / the hidden + // `ziel_user` field; everyone else is always confined to their own. + function resolveZielUser(req) { + const roh = req.query.user != null ? req.query.user : (req.body || {}).ziel_user; + const id = Number(roh); + if (!roh || Number.isNaN(id) || id === Number(req.user.id)) return { id: Number(req.user.id), fremd: false }; + if (!req.user.is_admin) return null; // nur Admins dürfen fremde Profile sehen/ändern + return { id, fremd: true }; + } + + app.get('/jobsuche', async (req, res) => { + try { + const ziel = resolveZielUser(req); + if (!ziel) return res.status(403).send('Zugriff verweigert – nur für Administratoren.'); + const zielUser = await dbGet('SELECT id, username FROM users WHERE id = ?', [ziel.id]); + if (!zielUser) return res.status(404).send('Benutzer nicht gefunden.'); + + const profil = await loadSuchprofil(ziel.id); + const laeufe = await dbAll( + 'SELECT * FROM suchlaeufe WHERE user_id = ? ORDER BY id DESC LIMIT 10', + [ziel.id] + ); + // Warn up front instead of letting a run fail in the background: without a + // Lebenslauf there is no profile to derive roles from, and without an Ollama + // key the run has no model to talk to (both are per-user now). + const lebenslauf = await dbGet( + "SELECT id FROM basis_dokumente WHERE user_id = ? AND typ = 'Lebenslauf' AND inhalt IS NOT NULL AND inhalt != '' LIMIT 1", + [ziel.id] + ); + const ollamaKey = await dbGet( + "SELECT value FROM app_state WHERE user_id = ? AND key = 'cfg:OLLAMA_API_KEY' AND value != ''", + [ziel.id] + ); + + res.render('jobsuche', { + profil, + laeufe, + zielUser, + fremd: ziel.fremd, + modi: suchprofil.MODI, + wochentage: suchprofil.WOCHENTAGE, + maxStaedte: suchprofil.MAX_STAEDTE, + hatLebenslauf: Boolean(lebenslauf), + hatOllamaKey: Boolean(ollamaKey), + fehler: req.query.fehler ? String(req.query.fehler) : null, + hinweis: req.query.hinweis ? String(req.query.hinweis) : null, + hideSettings: false, + }); + } catch (error) { + console.error('Error loading jobsuche:', error); + res.status(500).send('Serverfehler'); + } + }); + + app.post('/jobsuche', async (req, res) => { + try { + const ziel = resolveZielUser(req); + if (!ziel) return res.status(403).send('Zugriff verweigert – nur für Administratoren.'); + const suffix = ziel.fremd ? `?user=${ziel.id}` : ''; + + const profil = suchprofil.fromForm(req.body, sanitizeInput); + const fehler = suchprofil.validate(profil); + if (fehler.length) { + return res.redirect(`/jobsuche${suffix}${suffix ? '&' : '?'}fehler=${encodeURIComponent(fehler.join(' '))}`); + } + await saveSuchprofil(ziel.id, profil); + res.redirect(`/jobsuche${suffix}${suffix ? '&' : '?'}hinweis=${encodeURIComponent('Suchprofil gespeichert.')}`); + } catch (error) { + console.error('Error saving suchprofil:', error); + res.status(500).send('Serverfehler'); + } + }); + + // "Jetzt suchen": queue a run. Picked up by the host runner within a few minutes. + app.post('/jobsuche/start', async (req, res) => { + try { + const ziel = resolveZielUser(req); + if (!ziel) return res.status(403).send('Zugriff verweigert – nur für Administratoren.'); + const suffix = ziel.fremd ? `?user=${ziel.id}` : ''; + const sep = suffix ? '&' : '?'; + + const profil = await loadSuchprofil(ziel.id); + const fehler = suchprofil.validate(profil); + if (fehler.length) { + return res.redirect(`/jobsuche${suffix}${sep}fehler=${encodeURIComponent(fehler.join(' '))}`); + } + const { neu } = await queueSuchlauf(ziel.id, 'manuell'); + const msg = neu + ? 'Suchlauf angefordert – er startet innerhalb weniger Minuten.' + : 'Es läuft bereits ein Suchlauf – es wurde kein zweiter gestartet.'; + res.redirect(`/jobsuche${suffix}${sep}hinweis=${encodeURIComponent(msg)}`); + } catch (error) { + console.error('Error queueing suchlauf:', error); + res.status(500).send('Serverfehler'); + } + }); + + // Poll target for the page (shows a run moving from angefordert -> läuft -> fertig). + app.get('/jobsuche/laeufe', async (req, res) => { + try { + const ziel = resolveZielUser(req); + if (!ziel) return res.status(403).json({ error: 'Zugriff verweigert' }); + const laeufe = await dbAll( + 'SELECT * FROM suchlaeufe WHERE user_id = ? ORDER BY id DESC LIMIT 10', + [ziel.id] + ); + res.json(laeufe); + } catch (error) { + console.error('Error listing suchlaeufe:', error); + res.status(500).json({ error: 'Serverfehler' }); + } + }); + // ----- Admin: Benutzerverwaltung (nur für Admins) ----- // Admins legen neue Benutzer an, setzen Passwörter zurück und löschen // Benutzer. Beim Löschen eines Benutzers löscht die DB per ON DELETE CASCADE diff --git a/views/admin.ejs b/views/admin.ejs index 1463e8d..1651a38 100644 --- a/views/admin.ejs +++ b/views/admin.ejs @@ -74,6 +74,9 @@ <%= u.anzahl_bewerbungen %> <%= u.created_at %> + Jobsuche + + + +
+
+
+

Suchläufe

+

+ Ein Lauf durchsucht das Web und legt neue Treffer als + Jobangebote an. +

+
+
+ <% if (fremd) { %><% } %> + +
+
+ +
+ <%- include('partials/suchlaeufe', { laeufe: laeufe }) %> +
+
+ + +<%- include('partials/footer') %> + + + + diff --git a/views/partials/header.ejs b/views/partials/header.ejs index 3a4c121..a9290e7 100644 --- a/views/partials/header.ejs +++ b/views/partials/header.ejs @@ -32,6 +32,16 @@ + + + + + + + + +

Noch kein Suchlauf durchgeführt.

+<% } else { %> +
+ + + + + + + + + + + <% laeufe.forEach(function (l) { + var badge = { + angefordert: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200', + laeuft: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300', + fertig: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300', + fehler: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300' + }[l.status] || 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200'; + var text = { + angefordert: 'wartet', laeuft: 'läuft…', fertig: 'fertig', fehler: 'Fehler' + }[l.status] || l.status; + var zeit = l.gestartet_at || l.angefordert_at; + %> + + + + + + + <% }); %> + +
ZeitpunktAuslöserStatusErgebnis
+ <%= zeit ? new Date(zeit + 'Z').toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : '–' %> + + <%= l.ausloeser === 'zeitplan' ? 'Zeitplan' : 'manuell' %> + + <%= text %> + + <% if (l.status === 'fertig') { %> + <%= l.neu || 0 %> neu, + <%= l.dubletten || 0 %> Dubletten, <%= l.verworfen || 0 %> verworfen + <% } else if (l.status === 'fehler') { %> + + <%= (l.fehler || 'Unbekannter Fehler').slice(0, 80) %> + + <% } else { %> + + <% } %> +
+
+<% } %>