Files
jobbi-bewerbung/views/jobsuche.ejs
T
thomasandClaude Opus 4.8 9418750061 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>
2026-07-13 23:28:56 +02:00

229 lines
13 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<%- include('partials/head') %>
</head>
<body class="min-h-screen flex flex-col transition-colors duration-300 bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100" id="body">
<%- include('partials/header') %>
<main class="flex-1 container mx-auto px-4 py-8 max-w-4xl">
<div class="mb-6">
<h2 class="text-2xl font-bold text-gray-800 dark:text-white">Jobsuche</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Du legst fest, <strong>wo</strong> gesucht wird. <strong>Was</strong> gesucht wird Rollen und
Technologien leitet die KI aus deinem hinterlegten Lebenslauf ab.
</p>
</div>
<% if (fremd) { %>
<div class="mb-6 rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-4">
<p class="text-sm text-amber-800 dark:text-amber-200">
Du bearbeitest als Administrator das Suchprofil von <strong><%= zielUser.username %></strong>.
<a href="/jobsuche" class="underline">Zum eigenen Profil</a>
</p>
</div>
<% } %>
<% if (fehler) { %>
<div class="mb-6 rounded-lg border border-red-300 dark:border-red-700 bg-red-50 dark:bg-red-900/20 p-4">
<p class="text-sm text-red-800 dark:text-red-200"><%= fehler %></p>
</div>
<% } %>
<% if (hinweis) { %>
<div class="mb-6 rounded-lg border border-green-300 dark:border-green-700 bg-green-50 dark:bg-green-900/20 p-4">
<p class="text-sm text-green-800 dark:text-green-200"><%= hinweis %></p>
</div>
<% } %>
<% if (!hatLebenslauf || !hatOllamaKey) { %>
<div class="mb-6 rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20 p-4">
<p class="text-sm font-medium text-amber-800 dark:text-amber-200 mb-1">Vor dem ersten Suchlauf fehlt noch etwas:</p>
<ul class="text-sm text-amber-800 dark:text-amber-200 list-disc list-inside space-y-0.5">
<% if (!hatLebenslauf) { %>
<li>Kein Lebenslauf hinterlegt ohne ihn kann die KI dein Suchprofil nicht ableiten.
<a href="/vorlagen" class="underline">Unter Vorlagen anlegen</a></li>
<% } %>
<% if (!hatOllamaKey) { %>
<li>Kein Ollama-API-Schlüssel der Suchlauf läuft über dein eigenes KI-Kontingent.
<a href="/einstellungen" class="underline">In den Einstellungen eintragen</a></li>
<% } %>
</ul>
</div>
<% } %>
<form action="/jobsuche" method="POST" class="space-y-6">
<% if (fremd) { %><input type="hidden" name="ziel_user" value="<%= zielUser.id %>"><% } %>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<div class="flex items-start justify-between gap-4">
<div>
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Automatische Suche</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Ist sie aktiv, sucht die KI nach Zeitplan und legt neue Treffer als Jobangebote an.
</p>
</div>
<label class="inline-flex items-center gap-2 shrink-0">
<input type="checkbox" name="aktiv" value="1" <%= profil.aktiv ? 'checked' : '' %>
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500">
<span class="text-sm text-gray-700 dark:text-gray-300">aktiv</span>
</label>
</div>
</section>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Wo gesucht wird</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-5">Modus und Orte.</p>
<div class="space-y-2 mb-5">
<% var modusText = {
regional: 'Nur Stellen mit Arbeitsort in den unten genannten Städten.',
remote: 'Nur Stellen, die zu 100 % im Homeoffice ausübbar sind deutschlandweit, Firmensitz egal.',
beides: 'Zwei Durchgänge: regional in deinen Städten und zusätzlich 100 % Remote deutschlandweit.'
}; %>
<% modi.forEach(function (m) { %>
<label class="flex items-start gap-3 p-3 rounded-md border border-gray-200 dark:border-gray-700 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/40">
<input type="radio" name="modus" value="<%= m %>" <%= profil.modus === m ? 'checked' : '' %>
class="mt-0.5 border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500">
<span>
<span class="block text-sm font-medium text-gray-800 dark:text-white">
<%= m === 'regional' ? 'Regional' : (m === 'remote' ? '100 % Remote' : 'Regional + Remote') %>
</span>
<span class="block text-xs text-gray-500 dark:text-gray-400"><%= modusText[m] %></span>
</span>
</label>
<% }); %>
</div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="staedte">Städte</label>
<textarea id="staedte" name="staedte" rows="3" spellcheck="false"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm"
placeholder="Gladbeck, Bottrop, Gelsenkirchen, Essen"><%= profil.staedte.join(', ') %></textarea>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
Kommagetrennt, höchstens <%= maxStaedte %>. Die <strong>erste Stadt gilt als Wohnort</strong> und wird
am höchsten priorisiert. Bei „100 % Remote“ werden die Städte ignoriert.
</p>
</section>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Feinschliff</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-5">
Optional. Rollen und Technologien kommen aus deinem Lebenslauf hier ergänzt du nur Leitplanken.
</p>
<div class="grid gap-4 sm:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="zusatz_begriffe">Zusätzlich berücksichtigen</label>
<textarea id="zusatz_begriffe" name="zusatz_begriffe" rows="3"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm"
placeholder="z. B. Teilzeit, Rechenzentrum, Systemhaus"><%= profil.zusatz_begriffe %></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="ausschluesse">Ausschließen</label>
<textarea id="ausschluesse" name="ausschluesse" rows="3"
class="w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm"
placeholder="z. B. Zeitarbeit, Headhunter, Schichtdienst"><%= profil.ausschluesse %></textarea>
</div>
</div>
</section>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Zeitplan</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1 mb-5">Wann die automatische Suche laufen soll.</p>
<div class="flex flex-wrap gap-2 mb-5">
<% wochentage.forEach(function (t) { %>
<label class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md border border-gray-300 dark:border-gray-600 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/40">
<input type="checkbox" name="tage" value="<%= t.nr %>"
<%= profil.zeitplan_tage.indexOf(t.nr) !== -1 ? 'checked' : '' %>
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500">
<span class="text-gray-700 dark:text-gray-300"><%= t.kurz %></span>
</label>
<% }); %>
</div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="zeitplan_zeit">Uhrzeit</label>
<input type="time" id="zeitplan_zeit" name="zeitplan_zeit" value="<%= profil.zeitplan_zeit %>"
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm">
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
Der Lauf startet innerhalb weniger Minuten nach dieser Uhrzeit.
</p>
</section>
<div class="flex items-center justify-end gap-3">
<a href="/jobsuche<%= fremd ? '?user=' + zielUser.id : '' %>"
class="px-4 py-2 text-sm border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors">
Verwerfen
</a>
<button type="submit"
class="inline-flex items-center gap-2 px-4 py-2 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
Speichern
</button>
</div>
</form>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mt-6">
<div class="flex items-start justify-between gap-4 mb-5">
<div>
<h3 class="text-base font-semibold text-gray-800 dark:text-white">Suchläufe</h3>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Ein Lauf durchsucht das Web und legt neue Treffer als
<a href="/jobangebote" class="text-blue-600 dark:text-blue-400 hover:underline">Jobangebote</a> an.
</p>
</div>
<form action="/jobsuche/start" method="POST" class="shrink-0">
<% if (fremd) { %><input type="hidden" name="ziel_user" value="<%= zielUser.id %>"><% } %>
<button type="submit"
class="inline-flex items-center gap-2 px-4 py-2 text-sm bg-green-600 hover:bg-green-700 text-white rounded-md transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
Jetzt suchen
</button>
</form>
</div>
<div id="laeufe">
<%- include('partials/suchlaeufe', { laeufe: laeufe }) %>
</div>
</section>
</main>
<%- include('partials/footer') %>
<script>
(function () {
const cy = document.getElementById('currentYear');
if (cy) cy.textContent = new Date().getFullYear();
// While a run is queued or in flight, refresh the list so the user sees it
// progress without reloading. Stops once nothing is pending any more.
const zielUser = <%- fremd ? zielUser.id : 'null' %>;
const url = '/jobsuche/laeufe' + (zielUser ? '?user=' + zielUser : '');
let timer = null;
function offen(laeufe) {
return laeufe.some(function (l) { return l.status === 'angefordert' || l.status === 'laeuft'; });
}
async function poll() {
try {
const res = await fetch(url, { headers: { Accept: 'application/json' } });
if (!res.ok) return;
const laeufe = await res.json();
if (!offen(laeufe)) {
clearInterval(timer);
location.reload(); // fertig -> Ergebnisse serverseitig rendern
}
} catch (e) { /* nächster Tick */ }
}
const initial = <%- JSON.stringify(laeufe.map(function (l) { return { status: l.status }; })) %>;
if (offen(initial)) timer = setInterval(poll, 5000);
})();
</script>
</body>
</html>