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=<id> 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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=<X> dubletten=<Y> verworfen=<Z>'
|
||||
);
|
||||
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user