Compare commits

..
29 Commits
Author SHA1 Message Date
thomasandClaude 472259f189 Zeitplan: gesamten Zeitplan direkt in der Sektion deaktivierbar
aktiv-Hauptschalter aus der Sektion 'Automatische Suche' in die Sektion
'Zeitplan' verschoben, sodass der gesamte Zeitplan dort ein-/ausgeschaltet
werden kann. Wochentage + Uhrzeit werden beim Deaktivieren nur abgedunkelt,
die Einstellungen bleiben erhalten.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 18:26:00 +02:00
thomasandClaude 9ac946bc9e .dockerignore: extension/ wieder ins Image aufnehmen (Plugin-Download)
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 09:47:16 +02:00
thomasandClaude ddf909502a Browser-Plugin: Multi-User/X-API-Key + Footer-Download
Plugin (Manifest V3) refactored für gehostete Multi-User-Instanz:
- Tracker-URL hardcoded https://nextjobs.cc (keine URL-Eingabe mehr)
- Authentifizierung ausschließlich über persönlichen REST-API-Key
  (Header X-API-Key), pro Benutzer in chrome.storage.sync
- Server: /api/indeed-import nutzt neuen requireApiKey-Middleware,
  ordnet Import dem Benutzerkonto mit passendem cfg:API_TOKEN zu

Download: /plugin/nextjobs-stellen-import.zip packt extension/ on the
fly (dep-freier STORE-Zip in lib/zipdir.js, deterministischer Timestamp),
route hinter requireAuth. Footer-Link im App-Footer. Dockerfile kopiert
extension/ ins Image.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 09:44:41 +02:00
thomasandClaude 1e9618c920 Gemerkte Bewerbungen: platzsparende Chip-Wolke statt Listenzeilen
Nur der Firmenname als klickbarer Chip, nebeneinander (flex-wrap), jeder
Chip öffnet die Bewerbung. Status/Datum/Stelle/Ort entfallen hier — die
Details stehen auf der Bewerbungsseite; entmerken ebenfalls dort (Stern).
Lange Firmennamen werden mit truncate abgeschnitten, der volle Name steht
im title-Tooltip. Header (Stern + Zähler) bleibt, id für Such-Einblendung
unverändert.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 09:19:15 +02:00
thomasandClaude a6a2fabf05 Modal-Scroll: Wrapper als definite-height Scroll-Container (Safari-safe)
Ursache: Tailwinds min-h-screen (min-height:100vh) am Flex-Wrapper wächst
in Safari nicht mit dem Panel – das Panel ragt sichtbar aus dem Bild, der
Container meldet aber keinen Overflow → nichts scrollt, Speichern-Button
ist unerreichbar (Chromium wächst dagegen mit, daher dort scheinbar ok).

Fix: Die innere .min-h-screen-Box wird zum Scroll-Container mit
DEFINITE height:100vh + overflow-y:auto (+ -webkit-overflow-scrolling:touch).
Der Backdrop hält overflow:hidden. margin:auto am Panel zentriert kurze
Modals und vermeidet Top-Clipping bei langen. Wirkt in Safari, Chrome,
Firefox, iOS und Android (verifiziert via Chromium desktop + iPhone-Touch).

Zusätzlich Cache-Busting für styles.css (?v=<mtime>) am <link>, damit ein
hart zwischengespeichertes altes Stylesheet nach einem Deploy garantiert
neu geladen wird.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 09:14:12 +02:00
thomasandClaude 811188f492 Modal-Scroll: Backdrop scrollt, Panel wächst natürlich (margin:auto)
Die max-height/overflow-Variante griff beim Bewerbung-hinzufügen-Formular
nicht zuverlässig. Stattdessen scrollt jetzt das Overlay selbst wie eine
Seite; das Panel wächst auf natürliche Höhe und wird über margin:auto
zentriert (kurzer Inhalt) bzw. ohne Top-Clipping erreichbar (langer Inhalt).
Close- und Speichern-Button bleiben beide erreichbar.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 08:55:20 +02:00
thomasandClaude 1c913b2b28 Admin: pro-Nutzer-Rechte für Suchlauf-Start und Zeitplan
- Neue users-Spalten darf_suchlauf_starten / darf_zeitplan_aendern
  (Default 0 = deaktiviert); idempotente ALTER-Migration für Bestands-DBs.
- Admin-Detailseite: zwei Toggles (Erlaubt/Gesperrt) je Nutzer.
- /jobsuche: ohne Recht ist „Jetzt suchen" ausgeblendet und der Zeitplan
  (aktiv/Tage/Uhrzeit) read-only; Speichern und manueller Start sind
  serverseitig abgesichert. Admin (fremd) ist nie beschränkt.
- Default-Zeitplan bleibt Mo–Fr 17:00 (suchprofil-Defaults).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 08:47:48 +02:00
thomasandClaude fa07dbf90a /bewerbung: Tabs Unterlagen/E-Mail + Admin-Nutzerdetailseite
- Bewerbungsdetail: Toggle zwischen Bewerbungsunterlagen und E-Mail-Korrespondenz
  (hash-basiert), weniger Scroll auf der Seite.
- Admin: Klick auf einen Nutzer öffnet neue Verwaltungsseite
  /admin/users/:id (Passwort, Anmelden als, Jobsuche, Admin-Status, Löschen).
- Admin-Tabelle: Löschen und Jobsuche aus den Aktionen entfernt
  (jetzt auf der Nutzerdetailseite).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 08:37:54 +02:00
thomasandClaude ada69eb482 /bewerbung/🆔 zweispaltiges Layout, dezente Eigene Anhänge
- Zweispalter auf >=lg (links: Bewerbungsdaten+Status+Termine,
  rechts: Unterlagen+Korrespondenz), vorher alles untereinander auf
  max-w-3xl. Seite deutlich kürzer, keine Grid-Row-Lücken dank
  unabhängiger Spalten-Container. Unter lg weiter einspaltig.
- Eigene Anhänge: amber durch neutral grau ersetzt, Papierclip durch
  Schloss-Icon ersetzt (signalisiert „nur für dich / nicht im PDF").
- Größte Textareas etwas kürzer (notizen 6->4, Begleit-E-Mail 12->10,
  Versand-Nachricht 10->8).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 08:23:37 +02:00
thomasandClaude 7d5dc582e4 Bewerbungen: Zeile öffnet /bewerbung/:id statt Inline-Aufklappen
Entfernt den ausklappbaren Detailbereich (Status-Verlauf, Notizen,
interne Notizen) unter jeder Bewerbungs-Zeile auf der Startseite. Ein
Klick auf die Zeile öffnet stattdessen die ganze Bewerbung unter
/bewerbung/:id. Die Aktions-Buttons (Merken, Bearbeiten, Löschen)
bleiben separat bedienbar.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 08:10:51 +02:00
thomasandClaude 1ba615fa80 UI fixes: modal scroll, Bewerbungen-Aktionen, E-Mail-Uhrzeit Berlin
- Modal „Bewerbung hinzufügen" lässt sich auf allen Bildschirmgrößen scrollen
  (nicht nur ≤640px), sonst war Speichern abgeschnitten
- Aktionen-Spalte der Bewerbungen von 4.5rem auf 5.5rem (passt zur
  Cluster-Breite, Icons wandern nicht mehr nach rechts)
- Merken-Stern im Compact-Modus ohne Rahmen
- E-Mail-Uhrzeiten in Postfach, Bewerbung und Reply-Quote in Europe/Berlin

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-20 08:02:37 +02:00
thomasandClaude 96fd0e282d Jobangebote: Gemerkte nicht mehr unter Offen anzeigen
Favorisierte Angebote erscheinen ausschließlich im Gemerkt-Tab und werden
aus der Offen-Liste, dem Offen-Zähler (Header-Badge) und dem Tab-Zähler
ausgeblendet. Auf der Offen-Seite wird die Zeile beim Merken live entfernt.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-17 00:01:50 +02:00
thomasandClaude d9ba8698e0 Termine: alle anstehenden auf Startseite + Bearbeiten; Status Telefonat
- Startseite zeigt alle anstehenden Termine (vorher auf 6 begrenzt)
- Termine lassen sich inline bearbeiten (CalDAV-Update, Neu-Anlage bei 404/412)
- Neuer Bewerbungsstatus Telefonat (Listen, Farben, API, PDF, Statistik)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-16 15:27:19 +02:00
thomasandClaude ce5fa1eab1 Startseite: Gemerkte Bewerbungen unter die anstehenden Termine, dezenter & modern
Favoriten-Sektion wird nicht mehr ganz oben gerendert, sondern direkt
unter der Terminkarte; bei aktiver Suche wird sie nun ebenfalls ausge-
blendet. Karte bewusst dezenter gehalten (Border statt Schatten,
kleinere Überschrift, gedämpfte Akzente) mit modernen weichen Kanten.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-15 10:55:20 +02:00
thomasandClaude Opus 4.8 8405b8c057 Tupfen: Ort/Datum im Anschreiben noch etwas tiefer
Abstand über der Ort/Datum-Zeile von 5,5 auf 9,4 Einheiten erhöht, damit
sie klar unter dem Empfängerblock steht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:58:55 +02:00
thomasandClaude Opus 4.8 ec2927cac5 Tupfen: mehr Luft über der Ort/Datum-Zeile im Anschreiben
Ort und Datum standen direkt unter der Kontaktspalte und lasen sich wie
eine weitere Kontaktzeile. Abstand von 1,6 auf 5,5 Einheiten erhöht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:54:47 +02:00
thomasandClaude Opus 4.8 bd7d21bfba Tupfen: Straße im Absenderblock über PLZ/Ort statt Komma-Zeile
Der rechte Absenderblock des Anschreibens ist ohnehin eine Zeilenspalte —
die Anschrift wird jetzt an Komma/Zeilenumbruch aufgebrochen, sodass die
Straße wie in einer Anschrift über der Stadt steht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:36:01 +02:00
thomasandClaude Opus 4.8 1dd4807ca6 Einstellungen: Vorauswahl für KI-Generierung (Dokumente + Anlagen)
Zwei neue, pro Benutzer gespeicherte Konfigurationswerte:
GEN_DOKUMENTE_DEFAULT (Standard: Anschreiben + Lebenslauf) und
GEN_ANLAGEN_DEFAULT (Standard: keine). Sie belegen die Häkchen auf der
Bewerbungsseite vor; eine bereits getroffene Dokumentenauswahl der
Bewerbung gewinnt weiterhin. Die REST-API nutzt die Vorauswahl, wenn
dokumente/anlagen im Body fehlen.

Die Einstellungsseite kennt dafür jetzt Checkbox-Gruppen, deren Optionen
auch erst zur Renderzeit feststehen dürfen (die eigenen Anlagen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:31:19 +02:00
thomasandClaude Opus 4.8 d20a265ebc Neues Layout "Tupfen": dezent, mit verstreuten Punkten
Weiße Seite, Name groß und mittig in Tinte, luftiger Blocksatz — und
korallfarbene Punkte in den Rändern, teils vom Seitenrand angeschnitten,
als einzige Farbe des Blattes. Der Lebenslauf greift das Motiv auf: die
Punkte markieren die Abschnitte, führen die Bullets und bilden als
Fünfer-Reihe die Sprach-Skala (gespeist aus niveauLevel(), also aus der
Formulierung des Lebenslaufs selbst).

Die Punkte skalieren bewusst nicht mit der Schrift: sie gehören zur Seite
wie die Ränder. Das Anschreiben reserviert dafür unten ein Band, in das
der Text nicht hineinläuft.

Der Brief behält das DIN-Anschriftenfeld, das der Vorlage fehlt — es sitzt
in deren zweispaltiger Meta-Zeile (Empfänger links, Datum und Kontakt
rechts), so bleibt die Komposition erhalten und die Bewerbung deutsch.

Dazu:
- neue Akzentfarbe "Koralle"
- write() kann optional Blocksatz. jsPDF stretcht eine Zeile nur, wenn es
  weiß, dass eine weitere folgt — der Absatz geht deshalb in einem Aufruf
  rüber, wodurch die Schlusszeile ungestretcht bleibt.
- Infobox unter /vorlagen beschreibt jetzt auch Sunny (fehlte) und Tupfen

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:15:20 +02:00
thomasandClaude Opus 4.8 75813a38a4 Jobangebote: Favoriten-Funktion mit eigenem "Gemerkt"-Tab
Jobangebote lassen sich per Stern merken — eigene Spalte `favorit`,
getrennt von den Bewerbungs-Favoriten. Gemerkte Angebote stehen oben in
der offenen Liste und haben einen eigenen Tab, der Favoriten quer über
alle Status zeigt und ausgeblendet bleibt, solange es keine gibt.

Der Stern wird nur in der Weboberfläche gesetzt; ein erneutes Einspielen
desselben Angebots über die REST-API überschreibt ihn nicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 21:45:04 +02:00
thomasandClaude cd30af3d69 Default OLLAMA_MODEL für neue Nutzer auf glm-5.2:cloud
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 20:16:10 +02:00
thomasandClaude Opus 4.8 0301a1c430 Sunny: Anschreiben dezenter — Farbe nur noch im Kopfband
Das Anschreiben war ein zweites Poster: Konfetti im Band, solides
Akzent-Badge, Betreff und Anrede beide in der Akzentfarbe, ein pastellener
Marker-Strich hinter dem Namen und eine Farbleiste als Fußzeile. Neben dem
Lebenslauf las sich das als Lärm, nicht als Set.

Jetzt trägt die Seite genau eine Farbfläche — das Kopfband —, alles darunter
ist Typografie:

- Kopfband ohne Konfetti, Name 27pt -> 22pt
- Rollen-Badge als weiße Pille mit Akzentschrift (der Lebenslauf behält sein
  solides Badge; die Umstellung sitzt in der Letter-Palette)
- Datum als schlichte rechtsbündige Zeile statt Pastell-Chip
- Betreff und Anrede in Tinte; die Bar über dem Betreff bleibt der einzige
  Farbakzent im Textkörper
- Signatur ohne Bild: Name über feinem Haarstrich statt Marker-Swipe
- Fußzeile: Haarlinie mit gedämpftem Kontakttext statt Farbleiste

suKonfetti/suDot hatten damit keinen Aufrufer mehr und fallen weg.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:54:18 +02:00
thomasandClaude Opus 4.8 21608b8ca7 Sunny: Anschreiben als Gegenstück zum Poster-Lebenslauf gestalten
Der Brief trug bisher nur dieselbe Farbe wie der Lebenslauf, nicht dieselbe
Sprache. Jetzt bekommt er denselben Kopf: Name plakativ auf dem Farbband, Ziel-
position als solides Badge, Kontaktdaten als weiße Chips mit Icons rechts
gestapelt, dazu Konfetti in den Randstreifen. Das Datum ist ein Pastell-Chip
neben dem Anschriftfeld statt einer einsamen rechtsbündigen Zeile, der Betreff
bekommt Balken und Größe einer Sektionsüberschrift, die Anrede die Akzentfarbe,
die Anlagen werden zu Chips, und der getippte Name liegt auf einem Pastell-
Streifen wie mit dem Marker gezogen – der Ersatz für die fehlende Handschrift.

DIN-5008 bleibt unangetastet: Anschriftfeld, Datum, Betreff, Anrede, Fließtext,
Gruß, Unterschrift und Anlagen stehen, wo ein deutscher Leser sie erwartet.

Zwei Dinge, die erst im Render auffielen:

- Die Konfetti-Punkte saßen in der linken Rinne direkt neben dem Namen und lasen
  sich als Aufzählungszeichen davor statt als Deko. Sie liegen jetzt rechts; nur
  ein Funkeln bleibt links, tief im Band und weit weg vom Namen.
- Die Kontaktzeile der Fußzeile lief bei langer Anschrift plus langer E-Mail über
  das Band hinaus und schob sich über die Punkte. Sie skaliert jetzt passend und
  lässt notfalls die Anschrift weg, damit Telefon und E-Mail lesbar bleiben.

suBar las seine Farbe fest aus der Lebenslauf-Palette (t.rc), obwohl der Brief
t.lc benutzt – das ging nur gut, weil beide Werte zufällig gleich sind. Die
Palette wird jetzt übergeben.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:35:23 +02:00
thomasandClaude Opus 4.8 ffaaafed7e Uploads: abgewiesene Dateien melden statt still zu verwerfen, Anlagen bis 30 MB
Eine zu große oder nicht erlaubte Datei wurde von multer abgewiesen, der Fehler
nur auf die Server-Konsole geloggt und der Browser kommentarlos auf /vorlagen
zurückgeleitet. Für den Benutzer sah das aus, als sei der Upload durchgelaufen,
die Anlage tauchte nur nirgends auf ("Upload error: File too large" im Log, die
Teildatei räumt multer selbst wieder weg). Betroffen waren alle drei Uploads der
Vorlagen-Seite: Anlagen, Unterschrift und Bewerbungsfoto.

Die Routen leiten jetzt mit ?fehler=… um – dasselbe Muster wie auf der Jobsuche –
und die Seite zeigt den Grund als Banner: zu groß (mit Limit), Dateityp nicht
erlaubt, oder keine Datei gewählt. Das Limit steht zusätzlich am Datei-Feld,
bevor jemand eine zu große Datei auswählt.

Limit für Anlagen von 15 auf 30 MB angehoben; gescannte Zeugnisse liegen
regelmäßig darüber. Die Größen sind Konstanten (ANHANG_MAX_MB / BILD_MAX_MB),
aus denen sowohl multer als auch die Meldung und der Hinweis im Formular lesen –
eine Meldung, die ein anderes Limit nennt als das durchgesetzte, wäre schlimmer
als gar keine. Der Bild-Filter für Unterschrift/Foto lehnt jetzt ebenfalls mit
einem Fehler ab statt die Datei still zu verwerfen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:47:24 +02:00
thomasandClaude Opus 4.8 1f2e299c55 Design: drittes Layout "Sunny" für Anschreiben und Lebenslauf
Plakatives Layout nach Vorlage eines Referenz-Lebenslaufs: weiße Seite,
Pastellband im Kopf mit überstehendem Foto, Name groß daneben, Zielposition
als solides Badge, überdimensionale Display-Überschriften unter Pastellbalken,
die abwechselnd links und rechts stehen, zweispaltige Einträge und Level-Balken
für die Sprachen.

Die Balkenlänge kommt aus dem im Lebenslauf genannten Niveau (Muttersprache,
C1, B1 …) – es wird kein Prozentwert erfunden, der nicht in den Unterlagen
steht. Studium, Berufsausbildung und Weiterbildungen laufen unter einer
gemeinsamen Überschrift "Ausbildung": drei Überschriften dieser Größe
hintereinander würden die Seite auffressen.

Neue Akzentfarbe "Beere" als Standard für das Layout. Die Layout-Weiche steckt
jetzt in einer Tabelle (CV_LAYOUT / LETTER_LAYOUT) statt in Ternären – ein
weiteres Layout ist eine Zeile dort plus seine Palette in lib/design.js.
Sidebar und Social bleiben unverändert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:25:20 +02:00
thomasandClaude 9a40f395c7 Anlagen: user_id aus req.user statt AsyncLocalStorage im multer-Callback
In multer/busboy-Upload-Callbacks ist der per-request AsyncLocalStorage-
Kontext nicht zuverlässig propagiert, sodass currentUserId() dort null
liefern kann und basis_anhaenge-/interne_anhaenge-Zeilen mit user_id=NULL
verwaisten (für den User unsichtbar). Die INSERTs nutzen jetzt uidFromReq
(= req.user.id mit uid()-Fallback) wie bereits userStorageDirForReq für
Signatur/Foto. Zwei bestehende verwaiste Zeilen wurden repariert.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 16:49:16 +02:00
thomasandClaude ce844412e5 Jobsuche: Agent pro Benutzer in isoliertem Docker-Container
Jeder Suchlauf läuft nun in einem frischen Container pro Benutzer, dessen
pro-Benutzer-Home als ~/.claude gemountet ist — Memories und Session-Contexte
liegen damit strikt getrennt pro Benutzer. Die Such-Skills sind Shared-Code
aus dem Image und werden im Container nur nach ~/.claude/skills verlinkt.

Der Host-Runner startet pro Lauf `docker run --rm` und führt bis zu
JOBSUCHE_MAX_PARALLEL (Default 4) Läufe parallel über verschiedene Benutzer
aus (jeder hat eigenen Ollama-Key = getrennte Rate-Limits). Der alte gemeinsame
~/.claude-Pfad wird vom Runner nicht mehr beschrieben.

- source/agent/: neues Agent-Image (Dockerfile + entrypoint + drei Skills)
- scripts/jobsuche-runner.js: agentStarten als docker run, ensureAgentDir, runPool
- scripts/jobsuche-runner.sh + bin/-Kopie: Image-Guard
- package.json: docker:build-agent (lokal, ohne Registry-Push)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 15:41:22 +02:00
thomasandClaude 7c92f23351 Aktivitäten-Feed: empfangene und gesendete E-Mails einbeziehen
Der Aktivitäten-Feed (Startseite, /aktivitaeten, /statistik) führt Status-
änderungen und E-Mails per UNION zusammen, sortiert nach Erfassungszeit.
E-Mail-Einträge bekommen einen eigenen Farbpunkt (blau = erhalten, sky =
gesendet); ohne verknüpfte Bewerbung wird Absender bzw. Empfänger als
Kopfzeile gezeigt. Das UNION steckt in einem Subselect, weil SQLite ORDER BY
an einem Compound-SELECT keine Ergebnisspalten nach Namen aufloest. Die
Paginierung von /aktivitaeten zaehlt jetzt Statusaenderungen + E-Mails.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 15:03:20 +02:00
thomasandClaude fc1b68ebb4 Bewerbungen favorisieren (statt Jobangebote)
Stern zum Merken einzelner Bewerbungen auf der Startseiten-Liste, der
Bewerbungsdetailseite und einer neuen 'Gemerkte Bewerbungen'-Sektion ganz
oben — unabhaengig vom Status. Die bisherigen Jobangebot-Favoriten werden
durchgaengig entfernt (Endpoint, Migration, UI, Startseiten-Sektion), da
nur Bewerbungen gemerkt werden sollen.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-14 14:58:18 +02:00
53 changed files with 5438 additions and 1044 deletions
-1
View File
@@ -1,6 +1,5 @@
node_modules
data
extension
indeed_example.txt
.env
.env.*
+2 -2
View File
@@ -7,8 +7,8 @@
# Ollama Cloud API-Schlüssel https://ollama.com/settings/keys
OLLAMA_API_KEY=
# Optional Cloud-Modell (Standard: gpt-oss:120b)
# OLLAMA_MODEL=gpt-oss:120b
# Optional Cloud-Modell (Standard: glm-5.2:cloud)
# OLLAMA_MODEL=glm-5.2:cloud
# Optional anderer Host (z. B. lokale Ollama-Instanz: http://localhost:11434)
# OLLAMA_HOST=https://ollama.com
+1
View File
@@ -31,6 +31,7 @@ COPY server.js ./
COPY lib ./lib
COPY views ./views
COPY public ./public
COPY extension ./extension
# Persisted SQLite data lives here; make it writable for the non-root user
RUN mkdir -p /app/data && chown -R node:node /app
+54
View File
@@ -0,0 +1,54 @@
# syntax=docker/dockerfile:1
#
# Agent-Image für die isolierte, pro-Benutzer-Jobsuche.
#
# Anders als das App-Image (Dockerfile im Repo-Root) enthält dieses Image KEINE
# Anwendung, sondern ausschließlich die Werkzeuge, die der headless Such-Agent
# braucht: Claude Code, curl/python3/jq für die Skill-Skripte (Arbeitsagentur-API,
# bt.sh) sowie git. Der eigentliche Suchlauf wird vom Host-Runner pro Lauf als
# `docker run --rm` gestartet, mit dem jeweiligen Benutzer-Home als Volume —
# dadurch landen Memories und Session-Contexte getrennt pro Benutzer.
FROM node:20-bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
# Werkzeuge für die Skill-Skripte: arbeitsagentur.sh = curl+python3, bt.sh =
# curl+jq, plus git/flock/bash/coreutils für die Skripte selbst.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates curl git python3 jq util-linux bash coreutils \
&& rm -rf /var/lib/apt/lists/*
# Claude Code auf einem systemweiten Pfad, damit es unabhängig vom zur Laufzeit
# gemounteten HOME gefunden wird. Version an den Host-CLI (2.1.209) angeglichen;
# DISABLE_AUTOUPDATER verhindert, dass jeder Benutzer sein eigenes Update zieht.
RUN npm install -g @anthropic-ai/claude-code@2.1.209
ENV DISABLE_AUTOUPDATER=1
# Nicht-Root-Benutzer, als der der Such-Agent läuft. Das node:20-Image bringt
# bereits uid/gid 1000 (»node«) mit — wir verwenden es unverändert, damit der
# Container dieselbe uid hat wie das vom Host-Runner gechownte pro-Benutzer-Home.
# COPY/Zielordner müssen für uid 1000 schreibbar sein.
# Geteilte Skills (Shared-Code, lesbar für alle); der Entrypoint verlinkt sie
# pro Benutzer nach ~/.claude/skills.
COPY skills /opt/skills
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Schreibbarer Arbeitsordner (cwd während des Suchlaufs); Projekt-Key von Claude
# Code wird -work, also liegen Memory/Contexte unter ~/.claude/projects/-work.
# /home/agent wird im Image angelegt (uid 1000), damit der Entrypoint auch ohne
# gemountetes Volume (z. B. `docker run … claude --version`) läuft; im echten
# Lauf legt der Host-Runner das pro-Benutzer-Volume genau dortüber.
RUN mkdir -p /work /home/agent && chown -R 1000:1000 /work /home/agent
ENV HOME=/home/agent
WORKDIR /work
USER 1000:1000
ENTRYPOINT ["/entrypoint.sh"]
CMD ["claude", "--version"]
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
set -e
# Der Container läuft mit einem pro Benutzer gemounteten HOME (anfangs leer).
# Claude Code schreibt seine Memories und Session-Contexte nach ~/.claude; die
# drei Such-Skills sind geteilter Code aus dem Image und werden hier nur
# verlinkt, statt pro Benutzer kopiert zu werden.
mkdir -p "$HOME/.claude"
ln -sfn /opt/skills "$HOME/.claude/skills"
exec "$@"
+331
View File
@@ -0,0 +1,331 @@
---
name: bewerbungs-tracker
description: Read and manage job applications ("Bewerbungen") via the local Bewerbungs-Tracker REST-API. Use whenever the user wants to list, create, update or delete Bewerbungen, manage their Statusverlauf/Timeline, download generated Unterlagen (PDF attachments), read E-Mail correspondence, trigger or check KI document generation, read/write Settings, get Statistics or an Export, browse Templates (Vorlagen), trigger document generation with optional static attachments (Anlagen), or manage JobOffers (Jobangebote). Trigger phrases: "Bewerbung(en)", "Bewerbungs-Tracker", "Statusverlauf", "Jobangebot(e)", "Bewerbungsstatistik", "Anschreiben generieren", "Anlagen beilegen".
---
# Bewerbungs-Tracker REST-API
REST-API zum Lesen und Verwalten von Bewerbungen, Statusverlauf, generierten
Unterlagen (PDFs), E-Mail-Korrespondenz, Einstellungen, Statistiken, Vorlagen
und Jobangeboten.
## Konfiguration
- **Base-URL:** `http://localhost:4327/api/v1` (override: env `BEWERBUNG_API_URL`)
- **Auth:** Header `X-API-Key: <token>` bei allen Endpunkten außer `/health`.
- **Key-Quelle:** env `BEWERBUNG_API_KEY`, sonst der Token des Benutzers
`BEWERBUNG_USER` (Default `admin`) aus der Tracker-DB.
### Multi-User — der Key entscheidet, wessen Daten du siehst
Der Tracker ist eine Plattform mit **mehreren Benutzern**. Jeder Benutzer hat
**seinen eigenen API-Token** (App → Einstellungen → REST-API → „Neu generieren"):
- Der `X-API-Key` **identifiziert den Benutzer**. Jeder Endpunkt liest und schreibt
**ausschließlich dessen eigene Daten** — fremde Bewerbungen, E-Mails, Anhänge,
Jobangebote und Einstellungen sind nicht erreichbar, auch nicht über eine fremde
`id` (die API antwortet dann `404`, als gäbe es den Datensatz nicht).
- Ein unbekannter, leerer oder mehrdeutiger Token ergibt `401`.
- **Es gibt keinen globalen Key mehr.** Der frühere `API_TOKEN` aus
`/opt/jobbi-bewerbung/.env` wird **nicht mehr benutzt**: er entsprach zufällig dem
Token des Admins (wirkte also still als Admin) und wird ungültig, sobald der Key in
der Oberfläche neu erzeugt wird.
- Willst du im Namen eines anderen Benutzers arbeiten, setze `BEWERBUNG_USER=<name>`
(oder direkt `BEWERBUNG_API_KEY`). Der Jobsuche-Runner setzt `BEWERBUNG_API_KEY`
automatisch je Benutzer.
> Der Key wird nicht im Skill gespeichert. Sende ihn **nie** an externe Dienste.
## Helper-Script (bevorzugt)
`scripts/bt.sh` kapselt curl inkl. Auth und gibt Body + `<http STATUS>` aus:
```bash
scripts/bt.sh GET /applications
scripts/bt.sh GET "/applications?status=Gesendet&year=2026&limit=20"
scripts/bt.sh POST /applications '{"datum":"2026-07-03","firma":"ACME GmbH","stelle":"DevOps Engineer","art":"E-Mail"}'
scripts/bt.sh PUT /applications/12 @body.json
scripts/bt.sh DELETE /joboffers/5
```
Roh mit curl geht auch:
```bash
curl -sS -H "X-API-Key: $BEWERBUNG_API_KEY" http://localhost:4327/api/v1/statistics
```
## Endpunkte
### System
| Method | Pfad | Beschreibung |
|--------|------|--------------|
| GET | `/health` | Verfügbarkeit (ohne Auth). |
### Applications (Bewerbungen)
| Method | Pfad | Beschreibung |
|--------|------|--------------|
| GET | `/applications` | Auflisten. Query: `month`(0112), `year`(YYYY), `status`, `art`, `label` (`Regional`\|`Remote-Deutschlandweit`\|`Homeoffice-Deutschlandweit`), `search` (Firma/Stelle), `limit`(1500, def 100), `offset`. |
| POST | `/applications` | Anlegen. Duplicate-Guard → **409** bei gleicher Firma+Stelle; mit `"force":true` trotzdem anlegen. |
| GET | `/applications/{id}` | Einzelne Bewerbung (inkl. `verlauf`). |
| PUT | `/applications/{id}` | Aktualisieren (volle Ressource, `datum`/`firma`/`stelle` erforderlich). |
| DELETE | `/applications/{id}` | Löschen. |
| GET | `/applications/{id}/timeline` | Statusverlauf abrufen. |
| POST | `/applications/{id}/timeline` | Statusverlauf-Eintrag hinzufügen. |
| DELETE | `/applications/{id}/timeline/{eintragId}` | Verlaufseintrag löschen. |
### Attachments (generierte PDFs)
| Method | Pfad | Beschreibung |
|--------|------|--------------|
| GET | `/applications/{id}/attachments` | Anhänge auflisten. |
| GET | `/applications/{id}/attachments/{attachmentId}` | Anhang herunterladen (`application/octet-stream` → mit `-o datei` speichern). |
### Emails
| Method | Pfad | Beschreibung |
|--------|------|--------------|
| GET | `/applications/{id}/emails` | E-Mail-Korrespondenz (in/out) inkl. Anhang-Metadaten. |
| GET | `/emails/{emailId}/attachments/{attachmentId}` | E-Mail-Anhang herunterladen (binär). |
### Generation (KI-Unterlagen)
| Method | Pfad | Beschreibung |
|--------|------|--------------|
| POST | `/applications/{id}/generate` | Generierung anstoßen. Body optional: `{"llm_notizen":"...","anlagen":[2]}`. `anlagen` = IDs statischer Basis-Anlagen (siehe „Anlagen bei der Generierung"). |
| GET | `/applications/{id}/generation-status` | Status: `nicht_gestartet`\|`ausstehend`\|`fertig`\|`fehler` (+ `anhaenge`). |
### Settings / Statistics / Export / Templates
| Method | Pfad | Beschreibung |
|--------|------|--------------|
| GET | `/settings` | Persönliche Angaben des eigenen Benutzers: `name`, `adresse`, `kundennummer`, `email`, `telefon`, `ort`, `webseite`, `geburtsdatum`. Noch nichts gespeichert → `{}` (gültiger Zustand). |
| PUT | `/settings` | Persönliche Angaben speichern. Es werden **nur die Felder geändert, die im Body stehen** — weggelassene behalten ihren Wert. Antwort enthält den neuen Stand (`{success, settings}`). |
| GET | `/statistics` | `total`, `byArt[]`, `byStatus[]` — nur eigene Bewerbungen. |
| GET | `/export` | Export (ohne `interne_notizen`). Query: `month`, `year`. |
| GET | `/templates` | Basis-Unterlagen / Vorlagen des Benutzers. Enthält den **Lebenslauf** — daraus leitet die Stellensuche Rolle und Skills ab. |
### JobOffers (Jobangebote)
| Method | Pfad | Beschreibung |
|--------|------|--------------|
| GET | `/joboffers` | Auflisten (neueste zuerst). |
| POST | `/joboffers` | Upsert (per `(quelle, external_id)`, sonst normalisierte `quelle_url`) → Response `action: created\|updated`. **409** wenn geblacklistet (siehe unten). |
| GET | `/joboffers/{id}` | Einzelnes Jobangebot. |
| DELETE | `/joboffers/{id}` | Löschen. **Setzt das Angebot standardmäßig zuvor auf die Blacklist** (Typ `auto`), damit dieselbe Stelle nie erneut eingespielt wird. Mit `?blacklist=false` hart löschen ohne Blockierung. Response `{success, blacklisted}`. |
### Blacklist (blockierte Jobangebote)
| Method | Pfad | Beschreibung |
|--------|------|--------------|
| GET | `/joboffers/blacklist` | Blacklist-Einträge auflisten (neueste zuerst). |
| POST | `/joboffers/blacklist` | Block anlegen (per URL, Domain, Firma oder Firma+Stelle) → **201** `{success, entry}`. |
| DELETE | `/joboffers/blacklist/{id}` | Block entfernen — betroffene Angebote sind danach wieder einspielbar. |
## JobOffers einspielen (Drittanbieter → Upsert)
`POST /joboffers` ist der Einspiel-Endpunkt für Drittanbietersoftware (CRM,
Scraper, Job-Board-Sync). Er **upsertet** anhand `(quelle, external_id)`, und
falls keine `external_id` passt, anhand der **normalisierten `quelle_url`**:
- Treffer vorhanden → Datensatz wird aktualisiert → Response
`"action":"updated"`, HTTP **200**.
- kein Treffer → neuer Datensatz → `"action":"created"`, HTTP **201**.
- Angebot steht auf der **Blacklist** → HTTP **409**, `BlacklistConflict`-Body,
Angebot wird **nicht** aufgenommen (siehe Abschnitt „Blacklist“).
> Damit ist der Import **idempotent**: dieselbe Quelle kann beliebig oft
> eingespielt werden, ohne Dubletten zu erzeugen — Voraussetzung ist eine
> stabile `external_id` pro Quelle.
> ⚠️ **Upsert ersetzt, er merged nicht.** Beim Update wird der Datensatz mit dem
> gesendeten Body überschrieben; weggelassene optionale Felder werden **geleert**.
> Beim Re-Import daher immer den **vollständigen** Datensatz senden, nicht nur die
> geänderten Felder.
### Felder
| Feld | Pflicht | Beschreibung |
|------|:---:|--------------|
| `firma` | ✅ | Arbeitgeber. |
| `stelle` | ✅ | Stellenbezeichnung. |
| `firma_slug` | ✅ | **Pflicht.** Firmenname als `lowercase` + `kebab-case` (z. B. `Acme GmbH``acme-gmbh`). Dient der eindeutigen Identifizierung beim Blacklist-Abgleich. |
| `external_id` | | ID des Angebots in der Quellsoftware. **Upsert-Schlüssel** — für idempotenten Import immer setzen. |
| `quelle` | | Herkunftssystem/Kanal (z. B. `mein-crm`). Default serverseitig `drittanbieter`. |
| `ort` | | Arbeitsort. |
| `adresse` | | Anschrift des Arbeitgebers (Straße, Hausnummer, PLZ, Ort). |
| `ansprechpartner` | | Name des Ansprechpartners für die Bewerbung. |
| `gehalt` | | Freitext (z. B. `55.000 €`). |
| `beschreibung` | | Volltext der Stellenanzeige. |
| `quelle_url` | | Deeplink zum Original-Inserat. |
| `art` | | Bewerbungsart-Enum (siehe unten). |
| `status` | | `offen` \| `uebernommen` \| `abgelehnt`. Default `offen`. |
| `anzeige_datum` | | Veröffentlichungsdatum des Inserats (`YYYY-MM-DD`). Wird gespeichert. |
| `kontakt_email` | | Kontakt-/Bewerbungs-E-Mail-Adresse der Stelle. |
| `labels` | | Array von Arbeitsort-Labels (Enum, mehrere möglich): `Regional`, `Remote-Deutschlandweit`, `Homeoffice-Deutschlandweit`. Kennzeichnet den Arbeitsort-Typ der Stelle. |
| ~~`created_at`~~ | ⛔ | **Nicht mitsenden** — Einspiel-Zeitpunkt wird serverseitig gesetzt; ein mitgesendeter Wert wird ignoriert. |
### Beispiel
```bash
scripts/bt.sh POST /joboffers '{
"external_id": "job-12345",
"quelle": "mein-crm",
"firma": "Acme GmbH",
"stelle": "Softwareentwickler (m/w/d)",
"firma_slug": "acme-gmbh",
"ort": "Berlin",
"adresse": "Musterstraße 1, 10115 Berlin",
"gehalt": "55.000 €",
"beschreibung": "…",
"quelle_url": "https://acme.example/job/12345",
"art": "Online-Portal",
"anzeige_datum": "2026-07-01",
"kontakt_email": "bewerbung@acme.example",
"labels": ["Regional"]
}'
```
Roh mit curl:
```bash
curl -X POST "$BEWERBUNG_API_URL/joboffers" \
-H "X-API-Key: $BEWERBUNG_API_KEY" -H "Content-Type: application/json" \
-d '{"external_id":"job-12345","quelle":"mein-crm","firma":"Acme GmbH","stelle":"Softwareentwickler (m/w/d)","firma_slug":"acme-gmbh","art":"Online-Portal","anzeige_datum":"2026-07-01"}'
```
> JSON erlaubt **keine Kommentare** — die `// created_at …`-Zeile aus Notizen
> vor dem Absenden entfernen, sonst schlägt das Parsen fehl (HTTP 400).
### Batch-Einspielung
Kein Bulk-Endpunkt: pro Angebot ein `POST`. Für viele Angebote in einer Schleife
importieren und `action` je Antwort auswerten:
```bash
# offers.json = JSON-Array von JobOfferCreate-Objekten
jq -c '.[]' offers.json | while read -r offer; do
scripts/bt.sh POST /joboffers "$offer" | head -1
done
```
## Blacklist (Angebote dauerhaft blockieren)
Über `/joboffers/blacklist` lassen sich Angebote sperren. Ein geblacklistetes
Angebot wird beim `POST /joboffers` **nicht** aufgenommen, sondern mit **409**
und `BlacklistConflict`-Body abgelehnt (`blacklisted:true`, `matched_by`,
`blacklist_entry`). Der Filter greift auf **künftige** Einspielungen; bereits
importierte Angebote werden nicht rückwirkend gelöscht.
### Block-Typen (`typ`)
| `typ` | Body-Felder | Blockiert |
|-------|-------------|-----------|
| `url` | `wert` = konkrete URL | genau dieses Inserat (URL wird normalisiert, Tracking-Parameter entfernt). |
| `domain` | `wert` = Domain (z. B. `stellenbetrug.example`) | alle Angebote dieser Domain. |
| `firma` | `wert` = Firmenname | alle Angebote dieser Firma. |
| `firma_stelle` | `firma` + `stelle` (+ optional `ort`) | eine bestimmte Firma-Stelle-Kombination. |
`grund` (Freitext) ist überall optional. Firma/Stelle/URL werden serverseitig
normalisiert, der Vergleich ist also unabhängig von Groß-/Kleinschreibung und
Tracking-Parametern. GET-Antworten enthalten die normalisierten Vergleichsfelder
zusätzlich: `url_norm`, `domain`, `firma_norm`, `firma_slug`, `stelle_norm`,
`ort_norm` (nur lesbar, serverseitig berechnet — nicht mitsenden). (Der Typ `auto`
in Antworten markiert automatisch erzeugte Einträge — aus einer **Ablehnung** oder
aus dem **Löschen eines Jobangebots** (`DELETE /joboffers/{id}`); er kann nicht
direkt angelegt werden.)
### Beispiele
```bash
# Ganze Domain sperren
scripts/bt.sh POST /joboffers/blacklist '{"typ":"domain","wert":"stellenbetrug.example","grund":"Fake-Inserate"}'
# Eine Firma sperren
scripts/bt.sh POST /joboffers/blacklist '{"typ":"firma","wert":"Dubios GmbH"}'
# Konkrete Firma+Stelle sperren
scripts/bt.sh POST /joboffers/blacklist '{"typ":"firma_stelle","firma":"Acme GmbH","stelle":"Vertrieb (m/w/d)","ort":"Berlin"}'
# Auflisten und wieder entfernen
scripts/bt.sh GET /joboffers/blacklist
scripts/bt.sh DELETE /joboffers/blacklist/3
```
> ⚠️ Vor dem `POST /joboffers` (v. a. Batch-Import) kann ein 409 auftreten, weil
> das Angebot geblacklistet ist. In Schleifen den Statuscode auswerten und solche
> Angebote überspringen statt abzubrechen.
## Anlagen bei der Generierung (statische Basis-Anlagen)
Beim Anstoßen der KI-Generierung (`POST /applications/{id}/generate`) lassen sich
optional **statische Basis-Anlagen** (`basis_anhaenge`) beilegen — z. B. Zeugnisse,
Zertifikate. Feld **`anlagen`**: ein Array von **Integer-IDs** dieser Basis-Anlagen.
- **Default: keine.** Fehlt `anlagen` oder ist das Array leer, werden **keine**
statischen Anlagen beigelegt.
- Die gewählten Anlagen erscheinen zusätzlich **im Anschreiben unter „Anlagen"**.
- IDs verweisen auf die im Tracker hinterlegten Basis-Anlagen (`basis_anhaenge`).
Für die aktuell verfügbaren IDs/Namen im Tracker nachsehen (die Basis-Anlagen
werden dort verwaltet); nur belegte IDs senden, keine raten.
```bash
# Mit einer statischen Anlage (z. B. Abschlusszeugnis = ID 2) generieren
scripts/bt.sh POST /applications/12/generate '{"anlagen":[2]}'
# Mehrere Anlagen + KI-Kontext
scripts/bt.sh POST /applications/12/generate '{"llm_notizen":"…","anlagen":[2,5]}'
# Ohne Anlagen (Default) — Feld einfach weglassen
scripts/bt.sh POST /applications/12/generate '{}'
```
## Enums
- **art:** `E-Mail`, `Online-Portal`, `Indeed`, `StepStone`, `Firmenwebsite`, `Post`, `Initiativbewerbung`, `Arbeitsagentur`, `Sonstiges`
- **status (Bewerbung/Timeline):** `Entwurf`, `Gesendet`, `Eingangsbestätigung`, `In Bearbeitung`, `Interessiert`, `Warten auf Rückmeldung`, `Warten auf meine Antwort`, `Vorstellungsgespräch`, `Absage`, `Einstellung`, `Keine Rückmeldung`
- **joboffer.status:** `offen`, `uebernommen`, `abgelehnt`
- **labels (Arbeitsort, Array, mehrere möglich):** `Regional`, `Remote-Deutschlandweit`, `Homeoffice-Deutschlandweit` — auf `JobOffer` **und** `Application`; als `?label=…`-Filter auf `GET /applications` nutzbar.
- **blacklist.typ (anlegen):** `url`, `domain`, `firma`, `firma_stelle` (Antworten können zusätzlich `auto` enthalten)
- **email.direction:** `in`, `out`
- **generation.status:** `nicht_gestartet`, `ausstehend`, `fertig`, `fehler`
## Feld-Referenz (wichtigste)
**ApplicationCreate** — erforderlich `datum` (YYYY-MM-DD), `firma`, `stelle`;
optional `art`, `status`, `notizen`, `interne_notizen`, `ort`,
`stellenbeschreibung`, `quelle_url`, `llm_notizen`, `kommentar`, `force`,
`labels` (Array: `Regional`\|`Remote-Deutschlandweit`\|`Homeoffice-Deutschlandweit`;
auch als `?label=`-Filter beim Auflisten). `ApplicationUpdate` kennt `labels` ebenso.
**TimelineEntryCreate** — erforderlich `status`; optional `datum`, `kommentar`.
**GenerateRequest** (`POST /applications/{id}/generate`) — alle Felder optional:
`llm_notizen` (Freitext-Kontext für die KI), `anlagen` (Array von Integer-IDs
statischer Basis-Anlagen; Default **keine**, siehe Abschnitt „Anlagen bei der
Generierung“).
**JobOfferCreate** — erforderlich `firma`, `stelle`, `firma_slug` (Firmenname als
`lowercase` + `kebab-case`, z. B. `acme-gmbh` — eindeutige ID für den
Blacklist-Abgleich); optional `external_id`, `quelle`, `ort`, `adresse`,
`ansprechpartner`, `gehalt`, `beschreibung`, `quelle_url`, `art`, `status`,
`anzeige_datum`, `kontakt_email`, `labels` (Array:
`Regional`\|`Remote-Deutschlandweit`\|`Homeoffice-Deutschlandweit`, mehrere
möglich) (siehe Abschnitt „JobOffers einspielen“).
`created_at` wird **serverseitig** gesetzt und darf nicht mitgesendet werden.
**BlacklistEntryCreate** — erforderlich `typ` (`url`\|`domain`\|`firma`\|
`firma_stelle`); bei `url`/`domain`/`firma` das Feld `wert`; bei `firma_stelle`
die Felder `firma`+`stelle` (optional `ort`); `grund` überall optional (siehe
Abschnitt „Blacklist“).
## Hinweise / Fehlerbehandlung
- **401** `{"error":"Ungültiger oder fehlender API-Key ..."}` → Key falsch, leer oder
keinem Benutzer zugeordnet. Der Key liegt **in der Datenbank** (pro Benutzer), nicht
mehr in der `.env` — ein Serverneustart ist nach einer Key-Änderung **nicht** nötig,
ein neuer Key gilt sofort und macht den alten ungültig. Häufigste Ursache: Der Token
wurde in der Oberfläche neu generiert, die Umgebung nutzt noch den alten.
- **404 obwohl die ID existiert** → Die ID gehört einem **anderen Benutzer**. Die API
zeigt fremde Datensätze grundsätzlich als „nicht vorhanden". Prüfe, mit wessen Key du
arbeitest (`BEWERBUNG_USER` / `BEWERBUNG_API_KEY`).
- **409** bei `POST /applications` → mögliche Dublette (`matches[]`); mit `force:true` erzwingen.
- **409** bei `POST /joboffers` → Angebot steht auf der **Blacklist**
(`BlacklistConflict`: `blacklisted`, `matched_by`, `blacklist_entry`); nicht
erzwingbar — erst den Blacklist-Eintrag entfernen (`DELETE /joboffers/blacklist/{id}`).
- **404** → ID existiert nicht.
- Binär-Downloads (Attachments) mit `curl ... -o datei` speichern, nicht ins Terminal.
- Vor Löschungen (DELETE) beim User rückversichern.
Vollständige OpenAPI-Definition: `http://localhost:4327/swagger.json`.
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Helper for the Bewerbungs-Tracker REST-API.
# Usage:
# bt.sh GET /applications
# bt.sh GET "/applications?status=Gesendet&limit=20"
# bt.sh POST /applications '{"datum":"2026-07-03","firma":"ACME","stelle":"DevOps"}'
# bt.sh PUT /applications/12 @/path/to/body.json
# bt.sh DELETE /joboffers/5
#
# Config via env (with sensible defaults):
# BEWERBUNG_API_URL base URL (default http://localhost:4327/api/v1)
# BEWERBUNG_API_KEY API key (X-API-Key header)
# BEWERBUNG_USER username whose key to use (default: admin)
#
# MULTI-USER: the tracker has one API key PER USER; the key decides whose data you
# see and change. There is no global key any more. The old fallback (API_TOKEN from
# /opt/jobbi-bewerbung/.env) is gone on purpose: it happened to hold the admin's
# token, so it silently acted as admin — and it goes stale the moment a key is
# rotated in the UI (Einstellungen -> "Neu generieren").
#
# Key resolution:
# 1. $BEWERBUNG_API_KEY if set (this is what the Jobsuche-Runner passes per user)
# 2. otherwise: look up the key of $BEWERBUNG_USER (default admin) in the tracker DB
set -euo pipefail
BASE="${BEWERBUNG_API_URL:-http://localhost:4327/api/v1}"
DB="${BEWERBUNG_DB:-/opt/jobbi-bewerbung/data/bewerbungen.db}"
USER_NAME="${BEWERBUNG_USER:-admin}"
KEY="${BEWERBUNG_API_KEY:-}"
if [[ -z "$KEY" && -f "$DB" ]]; then
KEY="$(node -e "
const s = require('/opt/jobbi-bewerbung/source/node_modules/sqlite3');
const db = new s.Database(process.argv[1]);
db.get(
\"SELECT a.value FROM app_state a JOIN users u ON u.id = a.user_id \" +
\"WHERE u.username = ? AND a.key = 'cfg:API_TOKEN' AND a.value != ''\",
[process.argv[2]],
(e, r) => process.stdout.write(r && r.value ? r.value : '')
);
" "$DB" "$USER_NAME" 2>/dev/null || true)"
fi
if [[ -z "$KEY" ]]; then
echo "bt.sh: kein API-Key für Benutzer '$USER_NAME'." >&2
echo " -> In der App unter Einstellungen -> REST-API einen Token erzeugen," >&2
echo " oder BEWERBUNG_API_KEY / BEWERBUNG_USER setzen." >&2
exit 2
fi
METHOD="${1:?method required (GET/POST/PUT/DELETE)}"
PATH_ARG="${2:?path required, e.g. /applications}"
BODY="${3:-}"
url="${BASE}${PATH_ARG}"
args=(-sS -X "$METHOD" -H "X-API-Key: ${KEY}" -w $'\n<http %{http_code}>\n')
if [[ -n "$BODY" ]]; then
args+=(-H "Content-Type: application/json" --data-binary "$BODY")
fi
curl "${args[@]}" "$url"
@@ -0,0 +1,548 @@
---
name: it-stellensuche-remote
description: Search the web for NEW 100% remote / homeoffice IT / system administrator jobs anywhere in Germany (Deutschland-weit, ortsunabhängig) that the user has NOT applied to yet, then present them and optionally import them as JobOffers into the Bewerbungs-Tracker. Use whenever the user wants to find new fully-remote IT/sysadmin postings, "Remote-Stellensuche", "Homeoffice-Jobs suchen", "100% Remote finden", "ortsunabhängige Systemadministrator-Stellen", or refresh open remote job leads. Deduplicates against existing Bewerbungen and Jobangebote via the bewerbungs-tracker skill.
---
# Stellensuche REMOTE (100 % Homeoffice, deutschlandweit)
Findet **neue** Stellen im Web, die **100 % remote / vollständig im Homeoffice** und
**deutschlandweit ortsunabhängig** ausübbar sind, filtert alle raus, für die sich der
Nutzer schon beworben hat **oder die auf der Blacklist stehen**, und spielt sie auf
Wunsch als JobOffers in den Bewerbungs-Tracker ein.
> **Multi-User (WICHTIG):** Der Tracker hat mehrere Benutzer, jeder mit **eigenem
> Suchprofil und eigenem API-Key**. Der Remote-Modus ist inzwischen Teil des
> Suchprofils (`/jobsuche` → Modus „100 % Remote" bzw. „Regional + Remote"), und der
> Jobsuche-Runner ruft dafür den Skill `it-stellensuche` mit einem entsprechenden
> Prompt auf. Dieser Skill hier ist der **manuelle Einstieg** für eine reine
> Remote-Suche.
>
> Rolle und Technologien **nicht raten**: Sie kommen aus dem Lebenslauf des Benutzers,
> dem der `BEWERBUNG_API_KEY` gehört (`GET /templates`, typ `Lebenslauf`) — bzw. aus dem
> Profil, das der Prompt mitliefert. Die Rollen-/Buzzword-Listen unten sind **Beispiele
> für ein IT-Infrastruktur-Profil**, kein Default für jeden Benutzer.
> **Abgrenzung zum regionalen Skill:** Dieser Skill sucht **ausschließlich
> ortsunabhängige 100-%-Remote-Stellen deutschlandweit** — **ohne** Bindung an die
> sechs Städte. Der **Arbeitsort/Firmensitz spielt keine Rolle**, solange die
> Tätigkeit vollständig aus dem Homeoffice irgendwo in Deutschland erbracht werden
> kann. Regionale, orts­gebundene Stellen sind Sache des Skills `it-stellensuche`
> (sechs Städte) und werden hier **nicht** behandelt.
> **Blacklist-Prinzip (zentral):** Jeder erfolgreich importierte Job wird
> **unmittelbar danach geblacklistet**. So taucht dieselbe Stelle in späteren
> Suchläufen nie wieder auf — die Dublett-Vermeidung bleibt bestehen, selbst wenn
> das JobOffer später gelöscht oder in eine Bewerbung überführt wird. Umgekehrt
> werden Kandidaten, die schon auf der Blacklist stehen, gar nicht erst gelistet
> oder importiert. Die Blacklist/Dedup ist **gemeinsam** mit dem regionalen Skill —
> derselbe Job wird also über beide Suchen hinweg nicht doppelt erfasst.
## Suchprofil (Default)
**Rolle:** breit fassen — dieselbe Tätigkeit läuft je nach Firma unter vielen Titeln.
Alle folgenden Bezeichnungen als Query-Varianten und für die Relevanzbewertung nutzen
(zutreffend, sobald der Schwerpunkt auf IT-Infrastruktur/Systembetrieb liegt):
- **Kern (deckt sich mit dem CV: 2nd-Level-Sysadmin + IT-Consultant):**
Systemadministrator, IT-Administrator, IT-Systemadministrator, IT-Systemadministration,
IT-Systemadministrator 2nd Level, 2nd-Level-Administrator, System-Administrator (m/w/d),
Fachinformatiker Systemintegration, Administrator (m/w/d).
- **Support/Betrieb:** 1st/2nd/3rd-Level-Support, 2nd-Level-Support, IT-Support,
IT-Supporter, IT-Techniker, IT-Systemtechniker, Systemtechniker, IT-Systembetreuer,
IT-Betreuer, IT-Allrounder, IT-Koordinator, IT-Mitarbeiter, IT-Operations / IT-Betrieb,
Remote-Support, Remote-Systemadministrator, Service-Desk, Application Manager (remote).
(Rein ortsgebundene Onsite-/Field-Service-Rollen entfallen hier — sie sind nicht
remote ausübbar.)
- **Infrastruktur/Netz:** System Engineer, Systems Engineer, Infrastructure Engineer,
IT-Infrastruktur, Infrastruktur-Administrator, Netzwerkadministrator, Network Engineer,
Virtualisierungsadministrator, Storage-/Backup-Administrator.
- **Cloud/Modern Workplace:** Cloud-Administrator, Cloud Engineer, Cloud-Operations-Engineer,
Microsoft-365-Administrator, M365-/Modern-Workplace-Administrator,
Azure-/Entra-Administrator, DevOps-Engineer (Infrastruktur-lastig),
Site Reliability Engineer (SRE), Platform Engineer, Linux-Administrator,
Linux System Engineer, Windows-Administrator.
- **Managed Services / Hosting / Betrieb (passt zu Hetzner/OVH im CV):**
IT-Consultant (remote), IT-Berater (technisch), IT-Systemberater,
Managed-Services-Engineer, MSP-Engineer, NOC-Engineer, Cloud Operations,
Hosting-Administrator, Hosting Engineer, Systemadministrator Rechenzentrum (remote).
Ausschließen bleiben reine Softwareentwickler-, Data-Science-, Vertriebs- und
SAP-only-Stellen, außer sie passen klar zum Infrastruktur-Profil.
> **Ex-Arbeitgeber IMMER ausschließen (harte Regel, unabhängig von der Schreibweise):**
> Stellen der **„IT-Problemlöser"** (Essen) — voller Firmenname u. a.
> **„IT Problemlöser Verwaltungs- und Handels GmbH"**, auch **„IT-Problemlöser GmbH"**,
> **„IT Problemlöser"** o. Ä. — werden **nie** gelistet oder importiert. Es ist der
> frühere Arbeitgeber des Nutzers (siehe Lebenslauf). Diese Firma taucht immer wieder
> mit **wechselnden Stellentiteln** auf; die `firma_stelle`-Blacklist greift dann nicht,
> weil der Titel abweicht. Deshalb **jeden Treffer verwerfen, dessen Firmenname `IT
> Problemlöser` / `IT-Problemlöser` (in jeder Rechtsform-/Schreibvariante) enthält** —
> ohne auf die Blacklist zu warten. (Firmenweite `firma`-Blacklist-Einträge greifen
> inzwischen schreibweisen-robust über `firma_slug` inkl. Präfix-Match — diese
> Modell-Regel bleibt trotzdem als zusätzliche Sicherung bestehen.)
**Arbeitsmodell (harter Filter — das Kernkriterium dieses Skills):**
Nur **100 % Remote / vollständig ortsunabhängige** Stellen, die **deutschlandweit
aus dem Homeoffice** erbracht werden können.
- **Zulässig** (behalten): Anzeigen, die klar als „100 % Remote", „Full Remote",
„vollständig remote", „remote-first", „ortsunabhängig", „deutschlandweit im
Homeoffice", „remote (Deutschland)" o. Ä. ausgeschrieben sind — die Tätigkeit ist
im Alltag komplett aus dem Homeoffice möglich. **Gelegentliche** Vor-Ort-Termine
(Onboarding, wenige Team-Events/Kick-offs pro Quartal/Jahr) sind unschädlich.
- **Verwerfen** (nicht listen, nicht importieren):
- **Reine Vor-Ort-/Präsenz-Stellen** (kein oder nur „nach Absprache"-Homeoffice).
- **Klassische Hybrid-Modelle mit festen Präsenz-/Bürotagen** (z. B. „23 Tage/
Woche vor Ort", „überwiegend Präsenz", „Home­office anteilig/tageweise möglich") —
das ist **nicht** 100 % remote.
- Stellen, die **Wohnsitz/Anwesenheit in einem bestimmten Ort oder Umkreis**
verlangen („Einsatzort …", „Wohnsitz im Raum …", „regelmäßig beim Kunden vor
Ort", „Rufbereitschaft vor Ort", Onsite-Field-Service).
- Stellen ohne **Anstellung in Deutschland** (z. B. „remote nur aus <anderes
Land>", Arbeitsvertrag/Steueransässigkeit außerhalb DE). Der Job muss für einen
in **Deutschland ansässigen** Bewerber als reguläre Anstellung offenstehen.
- **Kein geografischer Prioritäts-Ranking** (deutschlandweit gleichwertig) — statt
nach Ort **nach Relevanz** (Profil-/Buzzword-Passung, s. u.) und **Klarheit des
100-%-Remote-Versprechens** priorisieren.
> Bei Unsicherheit, ob eine Stelle wirklich 100 % remote ist, die Anzeige per
> `WebFetch` genau lesen (Abschnitte „Arbeitsmodell", „Das bieten wir", „Standort").
> Steht dort nur „Homeoffice möglich"/„flexibel" ohne klares 100-%-Remote, im Zweifel
> **verwerfen** — dieser Skill nimmt ausschließlich eindeutig vollständig remote
> Stellen auf.
**Skills/Buzzwords** (für Query-Varianten & Relevanzbewertung): erste Gruppe stammt
direkt aus dem Lebenslauf (Kernkompetenzen, höchste Gewichtung), die weiteren sind
naheliegende, zum Profil passende Technologien — als Suchbegriffe und zum Erkennen
passender Anzeigen nutzen, auch wenn sie nicht wörtlich im CV stehen:
- **Kern (aus dem CV, höchste Gewichtung):** Windows Server, Active Directory,
Exchange, Microsoft 365 / MS365, Linux-Administration (Debian/Ubuntu),
Docker/Container, Proxmox/Virtualisierung, Netzwerk (OPNsense, pfSense,
VPN: WireGuard/OpenVPN/IPsec, LAN/WAN, TCP/IP), Firewall, Monitoring
(Prometheus, Grafana, Beszel, Uptime Kuma), Hetzner/OVH Cloud, Self-Hosting,
Automatisierung, AI/Claude/Agent-Workflows.
- **Microsoft-/Windows-Umfeld:** Entra ID / Azure AD, Intune, Endpoint Manager,
Group Policy / GPO, WSUS, PowerShell, Hyper-V, SharePoint, Teams, Windows 10/11,
Client-Management, MDM.
- **Virtualisierung/Server:** VMware vSphere / ESXi, Hyper-V, Citrix, KVM,
Terminalserver / RDS, Server-Hardware (Dell/HPE/Lenovo).
- **Backup/Storage:** Veeam, Backup & Recovery, Datensicherung, NAS/SAN, TrueNAS,
ZFS, Ceph, Synology, QNAP.
- **Netzwerk/Security:** VLAN, Routing & Switching, Cisco, Fortinet/FortiGate,
Sophos, Ubiquiti/UniFi, DNS/DHCP, Reverse Proxy (Nginx, Traefik), Let's Encrypt,
IT-Security, Patch-Management, ISO 27001, IT-Grundschutz.
- **Cloud/DevOps/Automatisierung:** Microsoft Azure, AWS, Kubernetes, Ansible,
Terraform, GitLab / CI/CD, Bash-/Shell-Scripting, Portainer, Nextcloud.
- **Datenbanken/Web:** MySQL/MariaDB, PostgreSQL, MS SQL Server, Apache, Nginx, IIS.
- **Betrieb/Organisation:** Ticketsystem (Jira, OTRS, Zammad), Helpdesk,
Rechenzentrum, On-Premise, Managed Services, IT-Dienstleister, Systemhaus,
Cloud-Provider, Hosting-Anbieter.
> Aktuelles Profil (Titel, Skills, Ort) steht in der Lebenslauf-Vorlage der API:
> `bewerbungs-tracker` → `GET /templates` (typ `Lebenslauf`). Bei Bedarf dort
> gegenlesen, statt zu raten.
## Quellen-Politik (WICHTIG)
Ziel ist **immer die Original-Stellenausschreibung direkt beim echten Arbeitgeber**
— bevorzugt auf dessen **Firmen-/Karriereseite**.
- **Ausschließen (nicht listen, nicht importieren):**
- **Headhunter / Personalvermittler / Personalberatungen** (recruiten für Dritte).
- **Zeitarbeit / Arbeitnehmerüberlassung / Personaldienstleister** (z. B. Randstad,
Hays, GULP, Amadeus FiRe, Adecco, Manpower, Piening, Tempton, DIS AG, Robert Half
u. ä. — auch unbekannte, wenn die Anzeige „Arbeitnehmerüberlassung",
„im Kundenauftrag", „für unseren Kunden", „Personaldienstleister" o. Ä. nennt).
- **Stepstone** als Quelle — nicht als Board nutzen, keine `site:stepstone.de`-Query,
keine Stepstone-Links importieren.
- **Erkennungsmerkmale eines Vermittlers/Zeitarbeit** (bei Unsicherheit die
Firmen-/Impressumsseite per `WebFetch` prüfen): Formulierungen wie „für unseren
Kunden", „im Auftrag unseres Mandanten", „Arbeitnehmerüberlassung", „Direktvermittlung",
„Personaldienstleistung", „Recruiting-Partner"; oder die inserierende Firma ist erkennbar
eine Personal-/Recruiting-Agentur. → **verwerfen**.
- **Zulässig als Quelle**, wenn es zur Original-Anzeige des Arbeitgebers führt:
Firmen-Karriereseite (bevorzugt), Arbeitsagentur/Bundesagentur für Arbeit, Indeed
oder LinkedIn **nur** wenn die Anzeige eindeutig vom echten Arbeitgeber (nicht von
einem Vermittler) stammt. Führt ein Board-Treffer zu einer Firma, deren eigene
Karriereseite dieselbe Stelle direkt listet, **immer den Firmen-Direktlink** nehmen.
- **Remote-spezifische Boards** (nur als Ergänzung, immer zur Arbeitgeber-Original­anzeige
durchklicken): z. B. `germantechjobs.de`, `remotarr`, `remote.io`, `remoteok.com`,
`4dayweek.io`, `join.com`, `arbeitsagentur.de` (Filter „Homeoffice/Telearbeit"),
`de.indeed.com` (Filter „Remote"), `de.linkedin.com/jobs` (Filter „Remote") — auch hier
Vermittler/Zeitarbeit/Stepstone ausschließen und den Firmen-Direktlink bevorzugen.
## Workflow
1. **Bereits erfasste Jobs + Blacklist laden** (Dedup-Basis). Dieser Schritt ist
**immer zuerst** auszuführen, damit Stellen aus früheren Suchläufen — **auch aus
dem regionalen Skill** — **nicht erneut** gefunden/importiert werden. Zwei Quellen:
```bash
scripts/applied-set.sh # firma_slug<TAB>firma<TAB>stelle<TAB>ort<TAB>quelle_url<TAB>external_id
scripts/blacklist-set.sh # id<TAB>typ<TAB>firma<TAB>stelle<TAB>ort<TAB>domain<TAB>url_norm<TAB>firma_norm<TAB>firma_slug<TAB>stelle_norm<TAB>ort_norm<TAB>grund
```
- **applied-set** (Bewerbungen **und** importierte Jobangebote): **eine Zeile
pro bereits erfasster Firma** (company-level, nicht mehr pro Stelle). Je Zeile
drei Dublett-Merkmale merken: **`firma_slug`** (robuster Firmen-Schlüssel:
lowercase, Umlaute→ae/oe/ue/ss, `(m/w/d)` raus, End-Rechtsform wie gmbh/ag/kg
entfernt, mit `-` verbunden), **`quelle_url`** und **`external_id`**.
- **blacklist-set** (gesperrte Muster): je Zeile den **`typ`** und die dazu
passenden Felder merken (`url_norm`, `domain`, `firma_norm`, **`firma_slug`**,
`stelle_norm`). Die `*_norm`/`firma_slug`-Felder liefert der Server bereits
normalisiert (lowercase, Tracking-Parameter entfernt, `(m/w/d)`/Rechtsform
bereinigt).
2. **Kandidaten sammeln — strukturierte Quelle zuerst, dann WebSearch.**
**(a) Arbeitsagentur-API mit Homeoffice-Filter (primär, strukturiert, frisch).**
Vor der Freitext-Suche die Jobsuche-API der Bundesagentur für Arbeit abfragen —
**deutschlandweit** (`wo` leer) mit `arbeitszeit=ho` (Homeoffice-Flag):
```bash
scripts/arbeitsagentur.sh search "<Rolle>" "" 0 30 100 ho
# -> firma_slug<TAB>firma<TAB>titel<TAB>ort<TAB>datum<TAB>refnr<TAB>angebotsart
```
Rollen aus dem Suchprofil durchrotieren (Systemadministrator, IT-Administrator,
Linux/Windows-Administrator, Cloud Administrator, System Engineer, DevOps, SRE, …).
Jede Zeile sofort per `firma_slug` gegen applied-set/blacklist prüfen (Firmen-Dedup,
Schritt 1). Für überlebende Treffer die Details holen:
```bash
scripts/arbeitsagentur.sh detail <refnr>
```
Detail liefert **Volltext** (→ `beschreibung`), **`EXTERNE_URL`** (→ bevorzugt
`quelle_url`), **`HOMEOFFICE_MOEGLICH`**, **Vergütung**, Firmensitz und die Flags
**`ZEITARBEIT_AUE` / `PRIVATE_ARBEITSVERMITTLUNG`** — `True` → Zeitarbeit/Vermittler
→ **verwerfen**. ⚠️ `arbeitszeit=ho`/`HOMEOFFICE_MOEGLICH` heißt nur „Homeoffice
möglich", **nicht** automatisch „100 % remote" — die harte 100-%-Remote-Prüfung
(Schritt 4) gilt unverändert am Volltext. „Keine aktive Anzeige" → verwerfen; `refnr`
stabil → Existenz-Check & `external_id`. (Die API ist ortsorientiert und deshalb hier
nur **eine** Quelle — WebSearch bleibt für Remote-Boards & remote-first-Firmen wichtig.)
**(b) WebSearch (ergänzend).** Mit dem `WebSearch`-Tool **viele** Queries fahren —
Rolle × **Remote/Homeoffice**, **deutschlandweit** (kein Ort anhängen), mit Fokus
auf **Original-Anzeigen der Arbeitgeber**.
> **Query-Strategie (Recall maximieren — WICHTIG, findet die Suche „kaum noch
> Stellen"):** Lieber **viele einfache, natürlichsprachliche Einzel-Queries** als
> wenige überladene. **Boolesche Operatoren sparsam einsetzen** — `OR`, `"…"`,
> `site:`, `inurl:` und `-minus`-Ausschlüsse **senken bei WebSearch oft drastisch
> die Trefferzahl**. Statt `A OR B "…" inurl:jobs`-Monsterqueries lieber **getrennte,
> kurze Queries**; Vermittler/Stepstone/Hybrid erst **nachträglich beim Filtern**
> (Schritt 3) rauswerfen. Jede Rolle mit **mehreren Remote-Synonymen** und Suffixen
> durchvariieren: Remote-Wörter `remote`, `100% Remote`, `Homeoffice`,
> `home office`, `ortsunabhängig`, `deutschlandweit`, `remote-first`, `full remote`;
> Suffixe `Stellenangebot`, `Job`, `Jobs`, `Festanstellung`, `unbefristet`,
> `Vollzeit`, `2026`.
**Rolle × Remote (mehrere Synonyme/Suffixe durchspielen), Beispiele:**
- `Systemadministrator 100% Remote Stellenangebot Deutschland`,
`Systemadministrator remote Homeoffice Job`, `Systemadministrator ortsunabhängig Festanstellung`
- `IT-Administrator remote deutschlandweit Job`, `IT-Administrator Homeoffice Vollzeit`,
`IT-Systemadministrator full remote 2026`
- `IT-Systemadministrator vollständig remote Stellenangebot`,
`2nd Level Support remote deutschlandweit`, `Remote-Systemadministrator Festanstellung`
- `Linux Administrator remote Deutschland`, `Linux System Engineer Homeoffice`,
`Windows Administrator remote deutschlandweit`
- `Microsoft 365 Administrator remote Homeoffice`, `Cloud Administrator 100% Homeoffice`,
`Cloud Engineer remote Deutschland`, `Azure Administrator remote Festanstellung`
- `System Engineer remote Deutschland`, `Infrastructure Engineer Homeoffice`,
`Platform Engineer remote deutschlandweit`, `SRE remote Deutschland`
- `Hosting Administrator remote`, `Managed Services Engineer remote Homeoffice`,
`NOC Engineer remote Deutschland`, `IT-Consultant remote Homeoffice`
- **Buzzword-getrieben** (holt Anzeigen mit abweichendem Titel, passendem Inhalt):
`VMware Administrator remote Deutschland`, `Proxmox remote Homeoffice Job`,
`Docker Kubernetes remote Administrator`, `Active Directory remote Homeoffice`,
`Veeam Backup remote Job`, `Hetzner OVH remote Administrator`,
`OPNsense pfSense Firewall remote Job`, `Ansible Automatisierung remote Deutschland`.
- **Remote-Boards zusätzlich** (jeweils **einzeln**, nicht stapeln): `germantechjobs
systemadministrator remote`, `remoteok IT administrator`, `4dayweek.io sysadmin`,
`join.com systemadministrator remote`, `Systemadministrator Homeoffice arbeitsagentur`,
`IT Administrator remote indeed`. Falls doch `site:`, dann einzeln:
`site:germantechjobs.de systemadministrator remote`,
`site:de.indeed.com IT Administrator remote`,
`site:arbeitsagentur.de Systemadministrator Telearbeit`. **Kein** `site:stepstone.de`.
Für Details/Arbeitsmodell/Firma einer Trefferseite `WebFetch` nutzen. Sieht ein
Treffer nach Firmen-Karriereseite aus: dort direkt nach der Einzelanzeige suchen.
2b. **Remote-freundliche Arbeitgeber gezielt finden** (Hauptquelle für „unentdeckte"
Stellen). Board-Suchen finden nur, was breit ausgeschrieben ist — viele
remote-first-Arbeitgeber posten IT-Stellen **ausschließlich auf der eigenen
Website**. Deshalb parallel zur Rolle-×-Remote-Suche **remote-affine Arbeitgeber
deutschlandweit identifizieren** und deren Karriereseite direkt öffnen:
- **Arbeitgeber-Typen recherchieren:** Cloud-/Hosting-Provider, Managed-Service-
Provider (MSP), SaaS-/Tech-Unternehmen, IT-Dienstleister/Systemhäuser mit
Remote-Kultur, verteilte/remote-first-Firmen. Beispiel-Queries:
`remote-first Unternehmen Deutschland IT`, `MSP remote Systemadministrator Karriere`,
`SaaS Unternehmen Deutschland remote Stellenangebote`,
`Cloud Provider Deutschland Karriere remote`.
- **Karriereseite direkt anfahren:** zu jeder gefundenen Firma
`WebSearch "<Firma> Karriere remote"` bzw. `"<Firma> Jobs remote"` und die
Jobliste per `WebFetch` öffnen; auf offene **100-%-Remote IT-/Administrator-/
Support-Stellen** prüfen. Auch `inurl:karriere`, `inurl:jobs`, `inurl:stellen`
gezielt einsetzen und gängige Bewerber-Portale erkennen (softgarden/onlyfy/
Personio/Workday/join.com).
- **Buzzwords als Türöffner:** findet die reine Titel-Suche wenig, mit den
Technologie-Buzzwords (oben) + „remote" suchen, z. B.
`Active Directory remote Administrator Deutschland`, `Veeam remote Job Homeoffice`.
- Jede so gefundene Stelle läuft durch dieselben Prüf-/Filter-/Dedup-Schritte
(37). Der Firmen-Direktlink ist hier ohnehin schon die bevorzugte `quelle_url`.
3. **Filtern & bewerten:**
- **Arbeitsmodell (harter Ausschluss):** **nur** eindeutig **100 % Remote /
vollständig ortsunabhängige** Stellen, deutschlandweit aus dem Homeoffice
ausübbar (Anstellung in Deutschland). Alles andere — reine Präsenz, Hybrid mit
festen Bürotagen, ortsgebundene/„Homeoffice möglich"-ohne-100%-Zusage,
Anstellung außerhalb DE — **verwerfen**. Kein geografisches Ranking; nach
Relevanz priorisieren.
- **Rolle:** Systemadministration/IT-Infrastruktur; keine reinen Entwickler-,
Vertriebs- oder SAP-only-Stellen (außer sie passen klar zum Profil).
- **Quelle (harter Ausschluss):** Headhunter/Personalvermittler, Zeitarbeit/
Arbeitnehmerüberlassung und Stepstone werden **verworfen** — siehe
„Quellen-Politik". Nur Original-Anzeigen echter Arbeitgeber behalten.
- **Dedup (persistent, FIRMEN-basiert):** **Eine Firma darf nur EINMAL gefunden
werden** (regional **und** remote gemeinsam). Treffer **verwerfen**, sobald
**eines** zutrifft: (a) gleiche/sehr ähnliche `quelle_url`, (b) gleiche
`external_id`, oder (c) **die Firma steht schon im Applied-Set** — unabhängig vom
Stellentitel. Firmen-Gleichheit über `firma_slug` prüfen: Kandidaten-Firma ebenso
sluggen (lowercase, Umlaute→ae/oe/ue/ss, `(m/w/d)` & End-Rechtsform raus, mit `-`
verbunden) und als **dieselbe Firma** werten, wenn der Slug **gleich** ist ODER
**ein Slug ein führendes Bindestrich-Präfix des anderen** ist (mind. 2 Tokens) —
so zählen **verschiedene Schreibweisen/Rechtsform-/Namensvarianten derselben
Firma als eine** (z. B. „IT-Problemlöser GmbH" = „IT Problemlöser Verwaltungs-
und Handels GmbH"). Offensichtlich derselbe Arbeitgeber → als Dublette verwerfen.
Distinkte Firmen mit nur gleichem ersten Wort („Meyer IT" vs. „Meyer Logistik")
sind **nicht** dieselbe Firma. Im Zweifel behalten und als „evtl. Dublette"
markieren.
- **Blacklist (harter Ausschluss):** Kandidat **verwerfen** (nicht listen, nicht
importieren), sobald er auf einen Blacklist-Eintrag passt — je nach `typ`:
- `url` → normalisierte Kandidaten-URL == `url_norm`.
- `domain` → Host der Kandidaten-URL == `domain` (auch Subdomains).
- `firma` → **dieselbe Firma** wie der Eintrag (Kandidaten-`firma_slug` gleich
`firma_slug` **oder** Präfix-Match wie bei der Dedup; `firma_norm` nur als
Fallback für Alt-Einträge ohne Slug).
- `firma_stelle` → dieselbe Firma (`firma_slug`, wie oben) **und** `stelle_norm`
passen (ist `ort_norm` gesetzt, muss auch der Ort passen).
- `auto` → wie die konkreten Felder, die der Eintrag trägt.
Diese Muster sind bewusst blockiert (u. a. jeder früher importierte Job, siehe
Schritt 6) — geblacklistete Stellen daher **stillschweigend überspringen**,
nicht als „evtl. Dublette" präsentieren.
- **Relevanz:** höher gewichten, je mehr Buzzwords aus dem Profil passen.
4. **Existenz prüfen, Link prüfen, Remote-Zusage bestätigen, Kontakt-E-Mail &
Firmensitz recherchieren** — für jeden Treffer, der in die engere Wahl kommt,
**zwingend einzeln**:
- **Existenz-Prüfung (Pflicht):** Jede Stelle mit `WebFetch` auf dem Einzel-Link
öffnen und bestätigen, dass die Anzeige **noch aktiv** ist (Firma + Stelle
stehen dort, kein 404/„Stelle nicht mehr verfügbar"/Redirect auf Jobliste).
Snippets aus der WebSearch reichen **nicht** — Suchindizes zeigen oft schon
abgelaufene Anzeigen. Kein Live-Nachweis → Treffer **verwerfen**. Bei bereits
importierten Angeboten, die nicht mehr existieren, das JobOffer löschen
(`DELETE /joboffers/{id}`).
- **100-%-Remote live bestätigen (Pflicht):** Im per `WebFetch` geöffneten
Anzeigentext prüfen, dass die Stelle **tatsächlich vollständig remote /
deutschlandweit ortsunabhängig** ist (Abschnitte „Arbeitsmodell", „Standort",
„Das bieten wir"). Nur „Homeoffice möglich"/„hybrid"/feste Präsenztage → **nicht**
100 % remote → **verwerfen**. Nur eindeutig vollständig remote Stellen behalten.
- **Vollständige Stellenausschreibung erfassen (Pflicht):** Beim `WebFetch` der
Einzelanzeige den **kompletten Ausschreibungstext** übernehmen — nicht nur eine
Kurzzusammenfassung. Dazu gehören: Einleitung/Unternehmensvorstellung, **Aufgaben/
Tätigkeiten**, **Anforderungen/Profil**, **Wir bieten/Benefits**, Angaben zu
Arbeitszeit/Vertragsart/**Remote-Regelung**, Gehalt (falls genannt), Bewerbungsweg/
Kontakt und Referenz-/Kennziffer. Text weitgehend **wortgetreu und vollständig**
sichern (nur Navigations-/Cookie-/Footer-Boilerplate der Seite weglassen), inkl.
der Gliederung/Überschriften. Dieser Volltext wandert in `beschreibung`
(Schritt 6). Ist die Seite lang/abgeschnitten, `WebFetch` gezielt erneut aufrufen.
- **`quelle_url` verifizieren:** Muss auf die **konkrete, noch aktive
Einzelanzeige** zeigen (nicht Trefferliste/Suchseite). Deeplink defekt/Liste →
per Suche den echten Einzel-Link finden (bevorzugt Firmenwebsite/Karriereseite),
sonst verwerfen.
- **Vermittler/Zeitarbeit aussortieren (Pflicht):** Zeigt der Treffer auf eine
Personalberatung/Headhunter/Zeitarbeit statt den echten Arbeitgeber, **verwerfen**.
Bei Unsicherheit die inserierende Firma per `WebFetch` (Impressum/„Über uns")
prüfen — Recruiting-/Personaldienstleister raus. Existiert dieselbe Stelle direkt
auf der Firmen-Karriereseite, diese Original-Anzeige verwenden.
- **Bewerber-Kontakt-E-Mail recherchieren:** Die E-Mail-Adresse ermitteln, an die
sich Bewerber wenden. Reihenfolge: (a) direkt in der Stellenanzeige genannte
Bewerbungs-/Kontaktadresse; (b) Karriere-/Kontaktseite der Firma
(`WebSearch` „<Firma> Karriere Kontakt Bewerbung E-Mail", `WebFetch` der Seite);
(c) allgemeine Bewerbungsadresse (`bewerbung@`/`jobs@`/`karriere@<domain>`) nur,
wenn auf der Firmenseite belegt. **Nicht raten** — nur belegte Adressen; sonst
`kontakt_email` leer lassen und als „nicht gefunden" markieren.
- **Firmensitz recherchieren (Pflicht, soweit belegbar):** Da die Stelle
ortsunabhängig ist, gibt es keinen festen Arbeitsort — als `adresse` den
**eingetragenen Firmensitz** des Arbeitgebers erfassen (Straße, Hausnummer, PLZ,
Ort; Quelle: Impressum/Kontaktseite, `WebSearch` „<Firma> Impressum Adresse",
`WebFetch` der Seite). **Nicht raten** — nur eine belegte Anschrift übernehmen;
sonst `adresse` leer lassen und als „nicht gefunden" markieren. Der Firmensitz
ist reine Zusatzinfo und **kein** Filterkriterium (die Stelle bleibt zulässig,
egal wo die Firma sitzt).
5. **Ergebnis präsentieren** als Tabelle: Firma · Stelle · **Arbeitsmodell (100 %
Remote)** · Firmensitz · Quelle (Board) · **verifizierter Link** ·
**Kontakt-E-Mail** · kurze Passt-Begründung. Sortierung: neue, klar passende
Treffer **nach Relevanz** (Buzzword-Passung; bei Gleichstand klar/eindeutig
100-%-Remote vor „remote, aber schwammig formuliert"). Fehlt Link, E-Mail oder
Firmensitz, kennzeichnen.
6. **Importieren** — im **interaktiven** Modus nur nach Rückfrage/Bestätigung des
Nutzers; im **autonomen Modus** (siehe unten) automatisch ohne Rückfrage. Jeden
neuen Treffer als JobOffer einspielen (Upsert, idempotent) über den
`bewerbungs-tracker`-Skill:
```bash
~/.claude/skills/bewerbungs-tracker/scripts/bt.sh POST /joboffers '{
"external_id": "remote-<slug(firma)>-<slug(stelle)>",
// IMMER setzen und DETERMINISTISCH aus
// firma+stelle bilden — NICHT aus der
// Board-/Anzeigen-ID. Präfix "remote-" hält
// Remote-Funde auseinander; Slug = normalisiert
// wie der firma_slug (lowercase, ohne (m/w/d)
// & Rechtsform, Interpunktion→"-"), z. B.
// "remote-musterfirma-cloud-administrator".
// So bekommt DIESELBE Stelle über jeden Lauf
// UND jedes Board dasselbe external_id →
// Server-Upsert greift zuverlässig statt eine
// Dublette anzulegen. (Eine flüchtige Board-ID
// würde je Quelle abweichen und Duplikate
// erzeugen — daher NICHT nutzen.)
"quelle": "stellensuche-remote",
"firma": "…", "stelle": "…",
"ort": "Remote (Deutschland)", // ortsunabhängig; ggf. "100% Remote / Homeoffice"
"adresse": "<eingetragener Firmensitz: Straße Hausnr, PLZ Ort; recherchiert &
verifiziert; sonst weglassen>",
"gehalt": "…",
"beschreibung": "<VOLLSTÄNDIGE Stellenausschreibung>",
// Der komplette, in Schritt 4 per WebFetch
// erfasste Anzeigentext inkl. Remote-Regelung —
// wortgetreu und vollständig, nicht kürzen.
// Gliederung/Überschriften erhalten (Markdown ok).
"quelle_url": "<verifizierter Einzel-Link, bevorzugt Firmen-Karriereseite>",
"art": "Firmenwebsite",
"anzeige_datum": "YYYY-MM-DD",
"kontakt_email": "<recherchierte Bewerber-E-Mail, sonst weglassen>",
"labels": ["Remote-Deutschlandweit", "Homeoffice-Deutschlandweit"]
// IMMER beide setzen: dieser Skill sucht ausschließlich
// 100% Remote / Homeoffice deutschlandweit → Arbeitsort-
// Labels "Remote-Deutschlandweit" + "Homeoffice-Deutschlandweit".
}'
```
`art`-Enum: `E-Mail`, `Online-Portal`, `Indeed`, `StepStone`, `Firmenwebsite`,
`Post`, `Initiativbewerbung`, `Arbeitsagentur`, `Sonstiges`. **Standard ist
`Firmenwebsite`** (Original-Anzeige des Arbeitgebers). `StepStone` nie verwenden
(ausgeschlossene Quelle); `Arbeitsagentur`/`Indeed` nur, wenn der Link direkt zur
Arbeitgeber-Anzeige führt.
**`409`-Antwort beim Import:** Steht der Job (trotz Vorfilter) auf der Blacklist,
antwortet `POST /joboffers` mit **409** (`BlacklistConflict`) und nimmt ihn
**nicht** auf. Das ist **kein Fehler** — als „übersprungen (Blacklist)" zählen
und mit dem nächsten Treffer weitermachen, **nicht** mit `force` erzwingen.
7. **Direkt blacklisten (nach jedem erfolgreichen Import).** Sobald ein JobOffer
angelegt wurde (Antwort **201** / `action:created`; bei `updated` ist der Job
bereits erfasst), die **Firma sofort ganz** blacklisten, damit dieser Arbeitgeber
in künftigen Läufen — remote **wie regional** — **mit keinem Stellentitel** wieder
auftaucht (Regel „eine Firma nur einmal"). Per `typ: firma` sperren — der Server
matcht firmenweit über `firma_slug` und deckt so abweichende Schreibweisen/
Rechtsformen mit ab:
```bash
~/.claude/skills/bewerbungs-tracker/scripts/bt.sh POST /joboffers/blacklist '{
"typ": "firma",
"firma": "<firma wie importiert>",
"grund": "auto: stellensuche-remote-import <YYYY-MM-DD>"
}'
```
Server-Antwort **201** = geblacklistet. Normalisierung (Groß/Klein, `(m/w/d)`,
Rechtsform, Umlaute) übernimmt der Server. Nur nach **echtem** Neuimport
blacklisten — nicht bei einem 409-Skip und nicht bei bloßem `updated`.
## Autonomer Modus (headless / geplant)
Wird der Skill **nicht-interaktiv** ausgeführt — d. h. ohne Nutzer, der bestätigen
kann (z. B. `claude -p "…" --dangerously-skip-permissions`, Cron/geplanter Lauf,
`ollama launch claude … -p …`) — im **autonomen Modus** arbeiten:
- **Ohne Rückfrage importieren:** Neue, geprüfte Treffer direkt als JobOffer
einspielen (Schritt 6) **und anschließend blacklisten** (Schritt 7). Es gibt
niemanden zum Bestätigen — nicht auf Eingabe warten.
- **Alle Prüfregeln gelten unverändert und strikt:** Existenz-Prüfung, **100-%-Remote
live bestätigt**, korrekter verifizierter Einzel-Link, Kontakt-E-Mail nur wenn
belegt, **Firmensitz nur wenn belegt**, die persistente Dedup **und der
Blacklist-Filter** — importiere **nur** echte, live verifizierte, eindeutig
vollständig remote, noch nicht erfasste und **nicht geblacklistete** Stellen.
Im Zweifel **nicht** importieren (lieber auslassen).
- **Keine Bewerbungen anlegen/versenden**, keine Löschungen bestehender Bewerbungen,
keine Generierung anstoßen — nur neue Jobangebote (`POST /joboffers`) einpflegen
und den jeweils importierten Job blacklisten (`POST /joboffers/blacklist`).
- **Kurzbericht ausgeben** (für das Log): je Treffer `action`
(created+blacklisted / updated / skip-dedup / skip-blacklist) mit Firma, Stelle,
Arbeitsmodell, Link; am Ende Zähler „X neu importiert & geblacklistet, Y als
Dublette/Blacklist übersprungen, Z verworfen (nicht verifizierbar / nicht 100 %
remote)".
## Regeln
- **Nur 100 % Remote, deutschlandweit.** Es werden **ausschließlich** eindeutig
vollständig remote / ortsunabhängige Stellen gelistet/importiert, die aus dem
Homeoffice irgendwo in Deutschland ausübbar sind (Anstellung in DE). Reine
Präsenz-, Hybrid- (feste Bürotage) und ortsgebundene Stellen werden **verworfen** —
Letztere sind Sache des regionalen Skills `it-stellensuche`. Kein Orts-Ranking,
Priorität nach Relevanz.
- **Labels `Remote-Deutschlandweit` + `Homeoffice-Deutschlandweit` setzen (Pflicht).**
Jedes von diesem Skill importierte JobOffer bekommt
`labels: ["Remote-Deutschlandweit", "Homeoffice-Deutschlandweit"]` (100 % remote,
deutschlandweit). Kein `Regional`-Label — ortsgebundene Stellen sind Sache des
Skills `it-stellensuche`.
- **Keine erfundenen Stellen.** Nur Jobs ausgeben, die per WebSearch/WebFetch real
belegt sind. **`quelle_url` muss der korrekte, geprüfte Direktlink zur
Einzelanzeige sein** (per `WebFetch` bestätigt) — keine Such-/Listenseiten, keine
geratenen URLs. Kein verifizierbarer Einzel-Link → Treffer nicht listen/importieren.
- **100 % Remote live belegt.** Vor dem Listen/Importieren im Anzeigentext bestätigen,
dass die Stelle wirklich vollständig remote ist. „Homeoffice möglich"/hybrid/feste
Präsenztage genügen **nicht** → verwerfen.
- **Nur Original-Anzeigen echter Arbeitgeber.** Headhunter/Personalvermittler,
Zeitarbeit/Arbeitnehmerüberlassung und **Stepstone** sind ausgeschlossen — weder
listen noch importieren (siehe „Quellen-Politik"). Bevorzugt der Firmen-Direktlink.
- **`beschreibung` = vollständige Ausschreibung.** Immer den kompletten, per WebFetch
erfassten Anzeigentext (Aufgaben, Anforderungen, Benefits, Remote-Regelung,
Bewerbungsweg, Kennziffer) übernehmen — nicht kürzen oder zusammenfassen.
- **Kontakt-E-Mail nicht erfinden.** `kontakt_email` nur setzen, wenn die Adresse in
der Anzeige oder auf der Firmen-Karriereseite belegt ist; sonst weglassen.
- **Firmensitz recherchieren, nicht erfinden.** `adresse` = belegter Firmensitz aus
dem Impressum; sonst leer lassen. Kein Filterkriterium — nur Zusatzinfo.
- **Keine Doppelfunde — eine Firma nur EINMAL, auch über Läufe und Skills hinweg.**
Vor dem Listen/Importieren immer `applied-set.sh` **und** `blacklist-set.sh` laden
und jeden Kandidaten per `quelle_url`, `external_id` **und `firma_slug`
(firmenweit)** sowie gegen die Blacklist abgleichen. Dedup/Blacklist sind mit dem
regionalen Skill **gemeinsam**. **Steht die Firma schon im Applied-Set oder auf der
Blacklist — egal unter welchem Stellentitel und egal in welcher Schreibweise/
Rechtsform (`firma_slug` gleich ODER Präfix-Match, mind. 2 Tokens) — wird kein
weiterer Treffer dieses Arbeitgebers ausgegeben oder importiert.** Auch **innerhalb
eines Laufs** jede Firma nur **einmal** präsentieren. Beim Import die `external_id`
**deterministisch aus `firma+stelle`** bilden (Präfix `remote-`), **nicht** aus
einer Board-/Anzeigen-ID — so ergibt dieselbe Stelle über jeden Lauf/jedes Board
dasselbe `external_id` (Server-Upsert statt Dublette). Der Server dedupliziert
`POST /joboffers` über `external_id` (Upsert) und die Blacklist (409, firmenweit
über `firma_slug`).
- **Blacklisten nach jedem Import (Pflicht, firmenweit).** Jeder frisch angelegte Job
(`action:created`) wird direkt anschließend per `POST /joboffers/blacklist`
(**`typ:firma`** — die ganze Firma) gesperrt (Schritt 7), damit dieser Arbeitgeber
in Folgeläufen mit keinem Titel wieder auftaucht. Nicht blacklisten bei `updated`
oder 409-Skip. Ein 409 beim `POST /joboffers` bedeutet „steht schon auf der
Blacklist" → überspringen, nie mit `force` erzwingen.
- **Interaktiv: nicht ungefragt importieren** — erst zeigen, dann auf Bestätigung
importieren. **Autonom/headless: automatisch importieren** (siehe „Autonomer
Modus"). In **keinem** Modus Bewerbungen anlegen/versenden.
- Board-Aggregatoren-Duplikate (dieselbe Stelle auf mehreren Portalen) zu einem
Eintrag zusammenfassen, bevorzugt mit Direkt-/Firmenwebsite-Link.
- Ist die Bewerbungs-Tracker-API nicht erreichbar (`GET /health`), Dedup nicht
möglich → Nutzer warnen und Treffer ohne Dedup mit Hinweis liefern.
## Abhängigkeiten
- Tools: `WebSearch`, `WebFetch`.
- Skill `bewerbungs-tracker` (für Dedup-Daten, JobOffer-Import und Blacklist:
`GET/POST /joboffers/blacklist`).
- Scripts: `scripts/arbeitsagentur.sh` (strukturierte Jobsuche-API der Bundesagentur
für Arbeit, `search`/`detail` — hier mit `arbeitszeit=ho`), `scripts/applied-set.sh`
(erfasste Firmen), `scripts/blacklist-set.sh` (gesperrte Muster) — allesamt
**Symlinks** auf die geteilten Scripts des Skills `it-stellensuche` (eine Quelle der
Wahrheit). `applied-set`/`blacklist-set` nutzen den `bt.sh`-Helfer des
`bewerbungs-tracker`-Skills; `arbeitsagentur.sh` nur `curl` + `python3`.
@@ -0,0 +1 @@
../../it-stellensuche/scripts/applied-set.sh
@@ -0,0 +1 @@
../../it-stellensuche/scripts/arbeitsagentur.sh
@@ -0,0 +1 @@
../../it-stellensuche/scripts/blacklist-set.sh
+565
View File
@@ -0,0 +1,565 @@
---
name: it-stellensuche
description: Search the web for NEW jobs matching the user's own search profile (cities / remote mode, taken from the Bewerbungs-Tracker) that they have NOT applied to yet, then present them and optionally import them as JobOffers into the Bewerbungs-Tracker. Use whenever the user wants to find new job postings, "Stellensuche", "neue Stellen suchen", "Jobs finden", "Systemadministrator-Stellen", or refresh open job leads. Deduplicates against existing Bewerbungen and Jobangebote via the bewerbungs-tracker skill.
---
# Stellensuche (Suchprofil des jeweiligen Benutzers)
Findet **neue** Stellen im Web — **in den Städten bzw. im Modus, die das Suchprofil des
Benutzers vorgibt** —, filtert alle raus, für die sich der Nutzer schon beworben hat
**oder die auf der Blacklist stehen**, und spielt sie auf Wunsch als JobOffers in den
Bewerbungs-Tracker ein.
> **Multi-User (WICHTIG):** Der Tracker ist eine Plattform mit mehreren Benutzern; jeder
> hat **sein eigenes Suchprofil und seinen eigenen API-Key**. Region, Modus und
> Ausschlüsse stehen **nicht mehr in diesem Skill**, sondern kommen von außen:
>
> 1. **Aus dem Prompt**, wenn er sie nennt (so ruft der Jobsuche-Runner den Skill auf —
> er baut den Prompt aus dem Suchprofil des Benutzers). Der Prompt hat immer Vorrang.
> 2. **Sonst aus dem Tracker:** `GET /templates` (typ `Lebenslauf`) liefert Rolle und
> Technologien des Benutzers; nach Orten/Modus fragen, wenn der Prompt nichts sagt.
>
> Die Beispiele weiter unten (Rollen, Buzzwords, Städte) sind **nur Muster** — niemals
> ungefragt die Städte oder das Profil eines anderen Benutzers verwenden. Der API-Key in
> `BEWERBUNG_API_KEY` bestimmt, in wessen Konto importiert wird.
> **Blacklist-Prinzip (zentral):** Jeder erfolgreich importierte Job wird
> **unmittelbar danach geblacklistet**. So taucht dieselbe Stelle in späteren
> Suchläufen nie wieder auf — die Dublett-Vermeidung bleibt bestehen, selbst wenn
> das JobOffer später gelöscht oder in eine Bewerbung überführt wird. Umgekehrt
> werden Kandidaten, die schon auf der Blacklist stehen, gar nicht erst gelistet
> oder importiert.
## Suchprofil
**Quelle des Profils (in dieser Reihenfolge):**
1. **Der Prompt** — der Jobsuche-Runner hängt den Lebenslauf/das Kurzprofil des Benutzers
direkt an („--- PROFIL --- … --- ENDE PROFIL ---"). Rollen und Buzzwords **daraus**
ableiten.
2. **Der Tracker** — sonst `GET /templates` (typ `Lebenslauf`, sonst `Profil/Kurzprofil`)
des Benutzers, dem der `BEWERBUNG_API_KEY` gehört.
3. Erst wenn beides fehlt: aus den bereits erfassten Bewerbungen (`GET /applications`)
ableiten oder nachfragen.
**Rolle:** breit fassen — dieselbe Tätigkeit läuft je nach Firma unter vielen Titeln.
Passende Bezeichnungen als Query-Varianten und für die Relevanzbewertung nutzen.
> Die folgende Liste ist ein **Beispiel für ein IT-Infrastruktur-Profil** — sie zeigt, wie
> breit zu fassen ist, ist aber **kein Default**. Für einen Benutzer mit anderem Beruf die
> Titel entsprechend aus **seinem** Lebenslauf bilden.
- **Kern (Beispiel: 2nd-Level-Sysadmin + IT-Consultant):**
Systemadministrator, IT-Administrator, IT-Systemadministrator, IT-Systemadministration,
IT-Systemadministrator 2nd Level, 2nd-Level-Administrator, System-Administrator (m/w/d),
Fachinformatiker Systemintegration, Administrator (m/w/d).
- **Support/Betrieb:** 1st/2nd/3rd-Level-Support, 2nd-Level-Support, IT-Support,
IT-Supporter, Support-Techniker, Helpdesk-Mitarbeiter, IT-Techniker,
IT-Systemtechniker, Systemtechniker, IT-Systemelektroniker, Servicetechniker IT,
Onsite-Techniker, Field-Service-Techniker, IT-Systembetreuer, IT-Betreuer,
IT-Allrounder, IT-Koordinator, IT-Mitarbeiter, IT-Operations / IT-Betrieb,
IT-Administrator EDV, EDV-Administrator, EDV-Betreuer.
- **Infrastruktur/Netz:** System Engineer, Systems Engineer, Infrastructure Engineer,
IT-Infrastruktur, Infrastruktur-Administrator, Netzwerkadministrator, Network Engineer,
Virtualisierungsadministrator, Storage-/Backup-Administrator, Rechenzentrums-Techniker,
Datacenter-Techniker.
- **Cloud/Hosting/Modern Workplace (passt zu Hetzner/OVH im CV):** Cloud-Administrator,
Cloud Engineer, Cloud-Operations-Engineer, Hosting-Administrator, Hosting Engineer,
Microsoft-365-Administrator, M365-/Modern-Workplace-Administrator,
Azure-/Entra-Administrator, DevOps-Engineer (Infrastruktur-lastig),
Linux-Administrator, Linux System Engineer, Windows-Administrator.
- **Beratung/Leitung (klein) — deckt die CV-Rolle „IT-Consultant" ab:** IT-Consultant,
IT-Berater (technisch, hands-on), IT-Systemberater, IT-Kundenberater (technisch),
IT-Teamleiter/IT-Leiter in kleinen Firmen (nur wenn hands-on).
Ausschließen bleiben reine Softwareentwickler-, Data-Science-, Vertriebs- und
SAP-only-Stellen, außer sie passen klar zum Infrastruktur-Profil.
> **Ex-Arbeitgeber IMMER ausschließen (harte Regel, unabhängig von der Schreibweise):**
> Stellen der **„IT-Problemlöser"** (Essen) — voller Firmenname u. a.
> **„IT Problemlöser Verwaltungs- und Handels GmbH"**, auch **„IT-Problemlöser GmbH"**,
> **„IT Problemlöser"** o. Ä. — werden **nie** gelistet oder importiert. Es ist der
> frühere Arbeitgeber des Nutzers (siehe Lebenslauf). Diese Firma taucht immer wieder
> mit **wechselnden Stellentiteln** auf; die `firma_stelle`-Blacklist greift dann nicht,
> weil der Titel abweicht. Deshalb **jeden Treffer verwerfen, dessen Firmenname `IT
> Problemlöser` / `IT-Problemlöser` (in jeder Rechtsform-/Schreibvariante) enthält** —
> ohne auf die Blacklist zu warten. (Firmenweite `firma`-Blacklist-Einträge greifen
> inzwischen schreibweisen-robust über `firma_slug` inkl. Präfix-Match — diese
> Modell-Regel bleibt trotzdem als zusätzliche Sicherung bestehen.)
**Region: kommt aus dem Suchprofil des Benutzers, nicht aus diesem Skill.**
Der Prompt nennt entweder eine **Städteliste** (regionaler Modus) oder **100 % Remote /
deutschlandweit** — oder beides. Es gilt strikt:
- **Regionaler Modus:** **Nur** Stellen mit Arbeitsort in einer der **genannten** Städte.
Die **erste genannte Stadt ist der Wohnort** und wird am höchsten priorisiert, danach
in der angegebenen Reihenfolge. Orte **außerhalb** dieser Liste werden **verworfen**
auch das direkte Umland, und **auch reine Homeoffice-/Remote-Stellen ohne Sitz in einer
der genannten Städte**. Sitzt der Arbeitgeber in einer der Städte und bietet zusätzlich
Homeoffice, ist die Stelle zulässig.
- **Remote-Modus:** Nur Stellen, die **zu 100 % im Homeoffice** ausübbar sind
(deutschlandweit, Firmensitz egal). Präsenz, Hybrid mit festen Bürotagen und vages
„Homeoffice möglich" werden verworfen; die 100-%-Zusage live im Anzeigentext bestätigen.
Nennt der Prompt keine Region, **nachfragen** statt zu raten — niemals auf eine im Skill
hinterlegte Städteliste zurückfallen (die gibt es bewusst nicht mehr).
**Skills/Buzzwords** (für Query-Varianten & Relevanzbewertung): Die **Kernbegriffe kommen
aus dem Lebenslauf des jeweiligen Benutzers** (höchste Gewichtung); ergänze naheliegende,
dazu passende Technologien — als Suchbegriffe und zum Erkennen passender Anzeigen, auch
wenn sie nicht wörtlich im CV stehen.
> Auch die folgende Liste ist ein **Beispiel** (IT-Infrastruktur). Für andere Profile die
> Kernbegriffe aus **deren** Lebenslauf ziehen, nicht von hier.
- **Kern (Beispiel, aus einem Infrastruktur-CV):** Windows Server, Active Directory,
Exchange, Microsoft 365 / MS365, Linux-Administration (Debian/Ubuntu),
Docker/Container, Proxmox/Virtualisierung, Netzwerk (OPNsense, pfSense,
VPN: WireGuard/OpenVPN/IPsec, LAN/WAN, TCP/IP), Firewall, Monitoring
(Prometheus, Grafana, Beszel, Uptime Kuma), Hetzner/OVH Cloud, Self-Hosting,
Automatisierung, AI/Claude/Agent-Workflows.
- **Microsoft-/Windows-Umfeld:** Entra ID / Azure AD, Intune, Endpoint Manager,
Group Policy / GPO, WSUS, PowerShell, Hyper-V, SharePoint, Teams, Windows 10/11,
Client-Management, MDM.
- **Virtualisierung/Server:** VMware vSphere / ESXi, Hyper-V, Citrix, KVM,
Terminalserver / RDS, Server-Hardware (Dell/HPE/Lenovo).
- **Backup/Storage:** Veeam, Backup & Recovery, Datensicherung, NAS/SAN, TrueNAS,
ZFS, Ceph, Synology, QNAP.
- **Netzwerk/Security:** VLAN, Routing & Switching, Cisco, Fortinet/FortiGate,
Sophos, Ubiquiti/UniFi, DNS/DHCP, Reverse Proxy (Nginx, Traefik), Let's Encrypt,
IT-Security, Patch-Management, ISO 27001, IT-Grundschutz.
- **Cloud/DevOps/Automatisierung:** Microsoft Azure, AWS, Kubernetes, Ansible,
Terraform, GitLab / CI/CD, Bash-/Shell-Scripting, Portainer, Nextcloud.
- **Datenbanken/Web:** MySQL/MariaDB, PostgreSQL, MS SQL Server, Apache, Nginx, IIS.
- **Betrieb/Organisation:** Ticketsystem (Jira, OTRS, Zammad), Helpdesk,
Rechenzentrum, On-Premise, Managed Services, IT-Dienstleister, Systemhaus.
> Aktuelles Profil (Titel, Skills, Ort) steht in der Lebenslauf-Vorlage der API:
> `bewerbungs-tracker` → `GET /templates` (typ `Lebenslauf`). Bei Bedarf dort
> gegenlesen, statt zu raten.
## Quellen-Politik (WICHTIG)
Ziel ist **immer die Original-Stellenausschreibung direkt beim echten Arbeitgeber**
— bevorzugt auf dessen **Firmen-/Karriereseite**.
- **Ausschließen (nicht listen, nicht importieren):**
- **Headhunter / Personalvermittler / Personalberatungen** (recruiten für Dritte).
- **Zeitarbeit / Arbeitnehmerüberlassung / Personaldienstleister** (z. B. Randstad,
Hays, GULP, Amadeus FiRe, Adecco, Manpower, Piening, Tempton, DIS AG, Robert Half
u. ä. — auch unbekannte, wenn die Anzeige „Arbeitnehmerüberlassung",
„im Kundenauftrag", „für unseren Kunden", „Personaldienstleister" o. Ä. nennt).
- **Stepstone** als Quelle — nicht als Board nutzen, keine `site:stepstone.de`-Query,
keine Stepstone-Links importieren.
- **Erkennungsmerkmale eines Vermittlers/Zeitarbeit** (bei Unsicherheit die
Firmen-/Impressumsseite per `WebFetch` prüfen): Formulierungen wie „für unseren
Kunden", „im Auftrag unseres Mandanten", „Arbeitnehmerüberlassung", „Direktvermittlung",
„Personaldienstleistung", „Recruiting-Partner"; oder die inserierende Firma ist erkennbar
eine Personal-/Recruiting-Agentur. → **verwerfen**.
- **Zulässig als Quelle**, wenn es zur Original-Anzeige des Arbeitgebers führt:
Firmen-Karriereseite (bevorzugt), Arbeitsagentur/Bundesagentur für Arbeit, Indeed
oder LinkedIn **nur** wenn die Anzeige eindeutig vom echten Arbeitgeber (nicht von
einem Vermittler) stammt. Führt ein Board-Treffer zu einer Firma, deren eigene
Karriereseite dieselbe Stelle direkt listet, **immer den Firmen-Direktlink** nehmen.
## Workflow
1. **Bereits erfasste Jobs + Blacklist laden** (Dedup-Basis). Dieser Schritt ist
**immer zuerst** auszuführen, damit Stellen aus früheren Suchläufen **nicht
erneut** gefunden/importiert werden. Zwei Quellen:
```bash
scripts/applied-set.sh # firma_slug<TAB>firma<TAB>stelle<TAB>ort<TAB>quelle_url<TAB>external_id
scripts/blacklist-set.sh # id<TAB>typ<TAB>firma<TAB>stelle<TAB>ort<TAB>domain<TAB>url_norm<TAB>firma_norm<TAB>firma_slug<TAB>stelle_norm<TAB>ort_norm<TAB>grund
```
- **applied-set** (Bewerbungen **und** importierte Jobangebote): **eine Zeile
pro bereits erfasster Firma** (company-level, nicht mehr pro Stelle). Je Zeile
drei Dublett-Merkmale merken: **`firma_slug`** (robuster Firmen-Schlüssel:
lowercase, Umlaute→ae/oe/ue/ss, `(m/w/d)` raus, End-Rechtsform wie gmbh/ag/kg
entfernt, mit `-` verbunden), **`quelle_url`** und **`external_id`**.
- **blacklist-set** (gesperrte Muster): je Zeile den **`typ`** und die dazu
passenden Felder merken (`url_norm`, `domain`, `firma_norm`, **`firma_slug`**,
`stelle_norm`). Die `*_norm`/`firma_slug`-Felder liefert der Server bereits
normalisiert (lowercase, Tracking-Parameter entfernt, `(m/w/d)`/Rechtsform
bereinigt).
2. **Kandidaten sammeln — strukturierte Quelle zuerst, dann WebSearch.**
**(a) Arbeitsagentur-API (primär — frisch, hohe Trefferzahl, strukturiert).** Vor
der Freitext-Suche die Jobsuche-API der Bundesagentur für Arbeit abfragen. Sie
liefert frische, deduplizierbare Treffer mit Firma, Ort, Datum und stabiler
`refnr` — genau das, was reine WebSearch nicht zuverlässig hergibt. Pro Rolle einen
Umkreis-Query:
```bash
scripts/arbeitsagentur.sh search "<Rolle>" "<Stadt>" 15 30 50
# -> firma_slug<TAB>firma<TAB>titel<TAB>ort<TAB>datum<TAB>refnr<TAB>angebotsart
```
Rollen aus dem Suchprofil durchrotieren (Systemadministrator, IT-Administrator,
Fachinformatiker Systemintegration, IT-Support, IT-Techniker, System Engineer,
Netzwerkadministrator, …). Städte: **Gladbeck** deckt per Umkreis 1520 km
Bottrop/Gelsenkirchen/Essen/Oberhausen weitgehend mit ab; **Dorsten** und **Marl**
je separat (`wo=Dorsten`, `wo=Marl`, Umkreis ~10). `tage` (Default 30) steuert die
Frische. Achtung: der Umkreis zieht auch **Nachbarorte außerhalb der sieben
Städte** rein (z. B. Haltern, Recklinghausen, Herten) — die **harte Regionsregel**
(nur die sieben Städte, Schritt 3) über die `ort`-Spalte anwenden und Fremdorte
**verwerfen**. Jede verbleibende Zeile **sofort per `firma_slug`** gegen
applied-set/blacklist prüfen (Firmen-Dedup, Schritt 1) — bereits erfasste/gesperrte
Firmen raus. Für
**überlebende** Treffer die Details holen:
```bash
scripts/arbeitsagentur.sh detail <refnr>
```
Detail liefert **Volltext** (→ `beschreibung`), **`EXTERNE_URL`** (Original-Link des
Arbeitgebers → bevorzugt `quelle_url`), **Adresse**, **Vergütung** und die Flags
**`ZEITARBEIT_AUE` / `PRIVATE_ARBEITSVERMITTLUNG`** — steht dort `True`, ist es
Zeitarbeit/Vermittler → **verwerfen** (ohne WebFetch, spart einen Schritt). Meldet
das Detail „keine aktive Anzeige", ist sie abgelaufen → **verwerfen**. Die `refnr`
ist stabil → als Existenz-Nachweis und für die `external_id` nutzbar. Führt
`EXTERNE_URL` nur auf ein Board (nicht den Arbeitgeber), die Firmen-Karriereseite
suchen und deren Direktlink bevorzugen (Quellen-Politik gilt unverändert).
**(b) WebSearch (ergänzend — für alles, was nicht in der API steht).** Mit dem
`WebSearch`-Tool **viele** Queries fahren — Rolle × Ort für **jede der sieben
Städte**, mit Fokus auf **Original-Anzeigen der Arbeitgeber**. Nur diese sieben
Orte abfragen, keine weiteren Revierstädte.
> **Query-Strategie (Recall maximieren — WICHTIG, findet die Suche „kaum noch
> Stellen"):** Lieber **viele einfache, natürlichsprachliche Einzel-Queries** als
> wenige überladene. **Boolesche Operatoren sparsam einsetzen** — `OR`, `site:`,
> `inurl:` und `-minus`-Ausschlüsse **senken bei WebSearch oft drastisch die
> Trefferzahl**. Statt eine `A OR B OR C … -stepstone -zeitarbeit`-Monsterquery
> lieber **getrennte, kurze Queries** absetzen und Vermittler/Stepstone erst
> **nachträglich beim Filtern** (Schritt 3) rauswerfen. Pro Stadt **mehrere
> Formulierungen** durchvariieren:
> **Rolle × Ort × Suffix**, mit den Suffixen `Stellenangebot`, `Job`, `Jobs`,
> `Stelle`, `Vollzeit`, `Festanstellung`, `unbefristet`, `gesucht`, `m/w/d`, `2026`
> und **ganz ohne Suffix** (nur `Rolle Ort`). Zusätzlich **Buzzword × Ort**
> (s. u.). So deckt jede Stadt 1015 verschiedene Queries ab statt 12.
**Rolle × Ort (je Stadt mehrere Suffixe durchspielen), Beispiele:**
- `IT-Systemadministrator Gladbeck Stellenangebot`, `Systemadministrator Gladbeck`,
`IT-Administrator Gladbeck Vollzeit`, `IT Systemadministrator Gladbeck m/w/d`
- `Systemadministrator Essen Jobs`, `2nd Level Support Essen`,
`IT-Administrator Essen Festanstellung`, `IT Systemadministrator Essen gesucht`
- `Fachinformatiker Systemintegration Oberhausen`, `IT-Administrator Oberhausen Stelle`,
`Systemadministrator Oberhausen unbefristet`
- `IT Administrator Gelsenkirchen Stellenangebot`, `Systemadministrator Gelsenkirchen`,
`IT-Systemadministrator Gelsenkirchen 2026`, `IT-Support Gelsenkirchen`
- `Systemadministrator Bottrop Jobs`, `IT-Administrator Bottrop`,
`IT Systemadministrator Bottrop Vollzeit`, `EDV-Administrator Bottrop`
- `IT-Administrator Dorsten Stellenangebot`, `Systemadministrator Dorsten`,
`IT-Techniker Dorsten Job`
- `Systemadministrator Marl Stellenangebot`, `IT-Administrator Marl`,
`IT Systemadministrator Marl Vollzeit`, `IT-Techniker Marl Job`
- Weitere Titel je Stadt durchrotieren: `System Engineer <Ort>`,
`IT-Systembetreuer <Ort>`, `Cloud Administrator <Ort>`, `Linux Administrator <Ort>`,
`Netzwerkadministrator <Ort>`, `IT-Consultant <Ort>`, `IT-Support <Ort>`.
- **Buzzword × Ort** (holt Anzeigen, die im Titel anders heißen, inhaltlich aber
passen — s. Skills/Buzzwords oben): `Active Directory <Ort> Stellenangebot`,
`Microsoft 365 Administrator <Ort>`, `VMware <Ort> IT Job`, `Proxmox <Ort>`,
`Hyper-V Administrator <Ort>`, `Veeam <Ort> IT`, `PowerShell Administrator <Ort>`.
- **Deutsche Jobbörsen zusätzlich anzapfen** (breitere Abdeckung, jeweils als
eigene, einfache Query — nicht stapeln): `<Rolle> <Ort> meinestadt`,
`<Rolle> <Ort> kimeta`, `<Rolle> <Ort> yourfirm`, `<Rolle> <Ort> arbeitsagentur`,
`<Rolle> <Ort> indeed`. Falls doch ein `site:`-Filter, dann **einzeln**:
`site:arbeitsagentur.de Systemadministrator <Ort>`, `site:de.indeed.com
IT-Administrator <Ort>`, `site:de.linkedin.com/jobs Systemadministrator <Ort>`.
**Kein** `site:stepstone.de`. Von Board-Treffern stets zur Original-Anzeige des
Arbeitgebers durchklicken.
- **Firmen-Karriereseiten gezielt** (bevorzugt): `IT-Administrator Essen Karriere`,
`Systemadministrator Gelsenkirchen Karriere Stellenangebot`,
`IT Jobs <Ort> Karriere` — Vermittler/Stepstone später beim Filtern aussortieren,
nicht per `-minus` in der Query.
Für Details/Ort/Firma einer Trefferseite `WebFetch` nutzen. Sieht ein Treffer nach
Firmen-Karriereseite aus: dort direkt nach der Einzelanzeige suchen.
2b. **Firmen zuerst finden, dann deren Karriereseite prüfen** (Hauptquelle für
„unentdeckte" Stellen). Board-Suchen finden nur, was breit ausgeschrieben ist —
viele Arbeitgeber posten IT-Stellen **ausschließlich auf der eigenen Website**.
Deshalb parallel zur Rolle-×-Ort-Suche **lokale Arbeitgeber in den sieben Städten
identifizieren** und deren Karriereseite direkt öffnen:
- **Arbeitgeber recherchieren** je Stadt — Firmen, die typischerweise interne IT
oder IT-Dienstleistung haben: Systemhäuser/IT-Dienstleister, Industrie &
Mittelstand, Chemie/Logistik (Essen, Oberhausen, Gelsenkirchen), Stadtwerke &
kommunale Betriebe, Krankenhäuser/Kliniken, Hochschulen, Versicherungen/Banken,
größere Handels-/Handwerksbetriebe. Beispiel-Queries:
`größte Arbeitgeber Essen IT`, `Systemhaus Gelsenkirchen`,
`IT-Dienstleister Oberhausen`, `Unternehmen Gladbeck IT-Abteilung`,
`Stadtwerke Bottrop Karriere`, `Klinikum Dorsten Stellenangebote IT`,
`Chemiepark Marl IT`, `Stadtwerke Marl Karriere`.
- **Karriereseite direkt anfahren:** zu jeder gefundenen Firma
`WebSearch "<Firma> Karriere"` bzw. `"<Firma> Stellenangebote"` und die
Jobliste per `WebFetch` öffnen; auf offene **IT-/Administrator-/Support-Stellen**
am jeweiligen Standort prüfen. Auch `inurl:karriere`, `inurl:jobs`,
`inurl:stellen` gezielt einsetzen und gängige Bewerber-Portale erkennen
(z. B. `/karriere`, `jobs.<firma>.de`, softgarden/onlyfy/Personio/Workday-Seiten).
- **Buzzwords als Türöffner:** findet die reine Titel-Suche wenig, mit den
Technologie-Buzzwords (oben) über die Stadt suchen, z. B.
`VMware Essen Stellenangebot`, `Active Directory Gelsenkirchen Job`,
`Microsoft 365 Administrator Oberhausen`, `Linux Bottrop IT` — so tauchen
Anzeigen auf, die im Titel anders heißen, inhaltlich aber passen.
- Jede so gefundene Stelle läuft durch dieselben Prüf-/Filter-/Dedup-Schritte
(37). Der Firmen-Direktlink ist hier ohnehin schon die bevorzugte `quelle_url`.
3. **Filtern & bewerten:**
- **Region (harter Ausschluss):** **nur** die sieben Städte Gladbeck, Essen,
Gelsenkirchen, Oberhausen, Bottrop, Dorsten, Marl. Jede Stelle mit Arbeitsort außerhalb
dieser sieben Städte **verwerfen** — auch übrige Revierstädte und reines
Homeoffice/Remote ohne Sitz in einer der sieben Städte. **Nach Nähe zu Gladbeck
priorisieren** (Gladbeck > Bottrop/Gelsenkirchen > Essen/Oberhausen > Dorsten/Marl).
- **Rolle:** Systemadministration/IT-Infrastruktur; keine reinen Entwickler-,
Vertriebs- oder SAP-only-Stellen (außer sie passen klar zum Profil).
- **Quelle (harter Ausschluss):** Headhunter/Personalvermittler, Zeitarbeit/
Arbeitnehmerüberlassung und Stepstone werden **verworfen** — siehe
„Quellen-Politik". Nur Original-Anzeigen echter Arbeitgeber behalten.
- **Dedup (persistent, FIRMEN-basiert):** **Eine Firma darf nur EINMAL gefunden
werden.** Treffer **verwerfen**, sobald **eines** zutrifft: (a) gleiche/sehr
ähnliche `quelle_url`, (b) gleiche `external_id`, oder (c) **die Firma steht
schon im Applied-Set** — unabhängig vom Stellentitel. Firmen-Gleichheit über
`firma_slug` prüfen: Kandidaten-Firma ebenso sluggen (lowercase, Umlaute→
ae/oe/ue/ss, `(m/w/d)` & End-Rechtsform raus, mit `-` verbunden) und als
**dieselbe Firma** werten, wenn der Slug **gleich** ist ODER **ein Slug ein
führendes Bindestrich-Präfix des anderen** ist (mind. 2 Tokens) — so zählen
**verschiedene Schreibweisen/Rechtsform-/Namensvarianten derselben Firma als
eine** (z. B. „IT-Problemlöser GmbH" = „IT Problemlöser Verwaltungs- und Handels
GmbH"; „Stadtwerke Essen" = „Stadtwerke Essen Netz GmbH"). Zusätzlich gesunder
Menschenverstand: ist offensichtlich derselbe Arbeitgeber gemeint, als Dublette
verwerfen. Distinkte Firmen mit nur gleichem ersten Wort (z. B. „Meyer IT" vs.
„Meyer Logistik") sind **nicht** dieselbe Firma. So taucht kein Arbeitgeber aus
früheren Läufen erneut auf. Im Zweifel behalten und als „evtl. Dublette" markieren.
- **Blacklist (harter Ausschluss):** Kandidat **verwerfen** (nicht listen, nicht
importieren), sobald er auf einen Blacklist-Eintrag passt — je nach `typ`:
- `url` → normalisierte Kandidaten-URL == `url_norm`.
- `domain` → Host der Kandidaten-URL == `domain` (auch Subdomains).
- `firma` → **dieselbe Firma** wie der Eintrag (Kandidaten-`firma_slug` gleich
`firma_slug` **oder** Präfix-Match wie bei der Dedup; `firma_norm` nur als
Fallback für Alt-Einträge ohne Slug).
- `firma_stelle` → dieselbe Firma (`firma_slug`, wie oben) **und** `stelle_norm`
passen (ist `ort_norm` gesetzt, muss auch der Ort passen).
- `auto` → wie die konkreten Felder, die der Eintrag trägt.
Diese Muster sind bewusst blockiert (u. a. jeder früher importierte Job, siehe
Schritt 6) — geblacklistete Stellen daher **stillschweigend überspringen**,
nicht als „evtl. Dublette“ präsentieren.
- **Relevanz:** höher gewichten, je mehr Buzzwords aus dem Profil passen.
4. **Existenz prüfen, Link prüfen, Kontakt-E-Mail & Firmenadresse recherchieren** —
für jeden Treffer, der in die engere Wahl kommt, **zwingend einzeln**:
- **Existenz-Prüfung (Pflicht):** Jede Stelle mit `WebFetch` auf dem Einzel-Link
öffnen und bestätigen, dass die Anzeige **noch aktiv** ist (Firma + Stelle
stehen dort, kein 404/„Stelle nicht mehr verfügbar"/Redirect auf Jobliste).
Snippets aus der WebSearch reichen **nicht** — Suchindizes zeigen oft schon
abgelaufene Anzeigen. Kein Live-Nachweis → Treffer **verwerfen** (nicht listen,
nicht importieren). Bei bereits importierten Angeboten, die nicht mehr existieren,
das JobOffer löschen (`DELETE /joboffers/{id}`).
- **Vollständige Stellenausschreibung erfassen (Pflicht):** Beim `WebFetch` der
Einzelanzeige den **kompletten Ausschreibungstext** übernehmen — nicht nur eine
Kurzzusammenfassung. Dazu gehören: Einleitung/Unternehmensvorstellung, **Aufgaben/
Tätigkeiten**, **Anforderungen/Profil**, **Wir bieten/Benefits**, Angaben zu
Arbeitszeit/Vertragsart/Standort, Gehalt (falls genannt), Bewerbungsweg/Kontakt
und Referenz-/Kennziffer. Text weitgehend **wortgetreu und vollständig** sichern
(nur Navigations-/Cookie-/Footer-Boilerplate der Seite weglassen), inkl. der
Gliederung/Überschriften. Dieser Volltext wandert in `beschreibung` (Schritt 6).
Ist die Seite lang/abgeschnitten, `WebFetch` gezielt erneut aufrufen, um alle
Abschnitte zu bekommen.
- **`quelle_url` verifizieren:** Muss auf die **konkrete, noch aktive
Einzelanzeige** zeigen (nicht Trefferliste/Suchseite). Deeplink defekt/Liste →
per Suche den echten Einzel-Link finden (bevorzugt Firmenwebsite/Karriereseite),
sonst verwerfen.
- **Vermittler/Zeitarbeit aussortieren (Pflicht):** Zeigt der Treffer auf eine
Personalberatung/Headhunter/Zeitarbeit statt den echten Arbeitgeber, **verwerfen**
(nicht listen, nicht importieren). Bei Unsicherheit die inserierende Firma per
`WebFetch` (Impressum/„Über uns") prüfen — Recruiting-/Personaldienstleister raus.
Existiert dieselbe Stelle direkt auf der Firmen-Karriereseite, diese
Original-Anzeige verwenden statt des Vermittler-Treffers.
- **Bewerber-Kontakt-E-Mail recherchieren:** Die E-Mail-Adresse ermitteln, an die
sich Bewerber wenden. Reihenfolge: (a) direkt in der Stellenanzeige genannte
Bewerbungs-/Kontaktadresse; (b) Karriere-/Kontaktseite der Firma
(`WebSearch` „<Firma> Karriere Kontakt Bewerbung E-Mail“, `WebFetch` der Seite);
(c) allgemeine Bewerbungsadresse (`bewerbung@`/`jobs@`/`karriere@<domain>`) nur,
wenn auf der Firmenseite belegt. **Nicht raten** — nur Adressen übernehmen, die
belegt sind; sonst `kontakt_email` leer lassen und als „nicht gefunden“ markieren.
- **Firmenadresse recherchieren (Pflicht):** Die **genaue Anschrift** des
Arbeitgebers ermitteln (Straße, Hausnummer, PLZ, Ort) — und zwar die, die
**exakt zu dieser Stelle passt**. Reihenfolge: (a) in der Stellenanzeige selbst
genannter Einsatz-/Arbeitsort (oft unter „Standort"/„Einsatzort"/am Anzeigenende);
(b) Impressum bzw. Kontakt-/Standortseite der Firma
(`WebSearch` „<Firma> Impressum Adresse <Ort>“, `WebFetch` der Seite).
⚠️ **Adresse muss zur Stelle passen:** Hat die Firma **mehrere Standorte/
Niederlassungen**, die Anschrift des **konkreten Arbeitsorts der Stelle** nehmen —
**nicht** blind die Zentrale/Hauptsitz-Adresse, wenn die Stelle an einem anderen
Standort ausgeschrieben ist. Nennt die Anzeige nur eine Stadt (`ort`), aber keine
Straße, gezielt die zu **dieser Stadt** gehörende Niederlassungsadresse suchen und
verifizieren. **Nicht raten** — nur eine belegte, zum Stellen-Ort passende Adresse
übernehmen; sonst `adresse` leer lassen und als „nicht gefunden“ markieren.
Passt die einzige auffindbare Firmenadresse **nicht** zum `ort` der Stelle
(anderer Standort), lieber leer lassen als eine falsche Zentrale eintragen.
5. **Ergebnis präsentieren** als Tabelle: Firma · Stelle · Ort · **Adresse** ·
Quelle (Board) · **verifizierter Link** · **Kontakt-E-Mail** · kurze
Passt-Begründung. Sortierung: neue, klar passende Treffer **nach Nähe zu Gladbeck**
(Gladbeck zuerst, dann Bottrop/Gelsenkirchen, dann Essen/Oberhausen, dann Dorsten/Marl).
Fehlt Link, E-Mail oder Adresse, kennzeichnen.
6. **Importieren** — im **interaktiven** Modus nur nach Rückfrage/Bestätigung des
Nutzers; im **autonomen Modus** (siehe unten) automatisch ohne Rückfrage. Jeden
neuen Treffer als JobOffer einspielen (Upsert, idempotent) über den
`bewerbungs-tracker`-Skill:
```bash
~/.claude/skills/bewerbungs-tracker/scripts/bt.sh POST /joboffers '{
"external_id": "stellensuche-<slug(firma)>-<slug(stelle)>",
// IMMER setzen und DETERMINISTISCH aus
// firma+stelle bilden — NICHT aus der
// Board-/Anzeigen-ID. Slug = normalisiert
// wie der firma_slug (lowercase, ohne (m/w/d)
// & Rechtsform, Interpunktion→"-"), z. B.
// "stellensuche-musterfirma-it-systemadministrator".
// So bekommt DIESELBE Stelle über jeden
// Lauf UND jedes Board dasselbe external_id
// → Server-Upsert greift zuverlässig statt
// eine Dublette anzulegen. (Eine flüchtige
// Board-ID würde je Quelle abweichen und so
// Duplikate erzeugen — daher NICHT nutzen.)
"quelle": "stellensuche",
"firma": "…", "stelle": "…", "ort": "…",
"adresse": "<genaue Anschrift des Arbeitsorts der Stelle: Straße Hausnr, PLZ Ort;
recherchiert & verifiziert, passend zum Stellen-Standort; sonst weglassen>",
"gehalt": "…",
"beschreibung": "<VOLLSTÄNDIGE Stellenausschreibung>",
// Der komplette, in Schritt 4 per WebFetch
// erfasste Anzeigentext: Aufgaben, Anforderungen/
// Profil, Wir-bieten/Benefits, Arbeitszeit/
// Vertragsart, Standort, Gehalt, Bewerbungsweg,
// Referenz-/Kennziffer — wortgetreu und
// vollständig, nicht kürzen/zusammenfassen.
// Gliederung/Überschriften erhalten (Markdown
// ok). Nur Seiten-Boilerplate weglassen.
"quelle_url": "<verifizierter Einzel-Link, bevorzugt Firmen-Karriereseite>",
"art": "Firmenwebsite",
"anzeige_datum": "YYYY-MM-DD",
"kontakt_email": "<recherchierte Bewerber-E-Mail, sonst weglassen>",
"labels": ["Regional"] // IMMER setzen: dieser Skill sucht ortsgebunden in
// den sieben Städten → Arbeitsort-Label "Regional".
}'
```
`art`-Enum: `E-Mail`, `Online-Portal`, `Indeed`, `StepStone`, `Firmenwebsite`,
`Post`, `Initiativbewerbung`, `Arbeitsagentur`, `Sonstiges`. **Standard ist
`Firmenwebsite`** (Original-Anzeige des Arbeitgebers). `StepStone` nie verwenden
(ausgeschlossene Quelle); `Arbeitsagentur`/`Indeed` nur, wenn der Link direkt zur
Arbeitgeber-Anzeige führt.
**`409`-Antwort beim Import:** Steht der Job (trotz Vorfilter) auf der Blacklist,
antwortet `POST /joboffers` mit **409** (`BlacklistConflict`) und nimmt ihn
**nicht** auf. Das ist **kein Fehler** — als „übersprungen (Blacklist)" zählen
und mit dem nächsten Treffer weitermachen, **nicht** mit `force` erzwingen.
7. **Direkt blacklisten (nach jedem erfolgreichen Import).** Sobald ein JobOffer
angelegt wurde (Antwort **201** / `action:created`; bei `updated` ist der Job
bereits erfasst), die **Firma sofort ganz** blacklisten, damit dieser Arbeitgeber
in künftigen Läufen **mit keinem Stellentitel** wieder auftaucht (Regel „eine Firma
nur einmal"). Per `typ: firma` sperren — der Server matcht firmenweit über
`firma_slug` und deckt so abweichende Schreibweisen/Rechtsformen mit ab:
```bash
~/.claude/skills/bewerbungs-tracker/scripts/bt.sh POST /joboffers/blacklist '{
"typ": "firma",
"firma": "<firma wie importiert>",
"grund": "auto: stellensuche-import <YYYY-MM-DD>"
}'
```
Server-Antwort **201** = geblacklistet. Normalisierung (Groß/Klein, `(m/w/d)`,
Rechtsform, Umlaute) übernimmt der Server. Nur nach **echtem** Neuimport
blacklisten — nicht bei einem 409-Skip (steht ja schon drauf) und nicht bei
bloßem `updated`.
## Autonomer Modus (headless / geplant)
Wird der Skill **nicht-interaktiv** ausgeführt — d. h. ohne Nutzer, der bestätigen
kann (z. B. `claude -p "…" --dangerously-skip-permissions`, Cron/geplanter Lauf,
`ollama launch claude … -p …`) — im **autonomen Modus** arbeiten:
- **Ohne Rückfrage importieren:** Neue, geprüfte Treffer direkt als JobOffer
einspielen (Schritt 6) **und anschließend blacklisten** (Schritt 7). Es gibt
niemanden zum Bestätigen — nicht auf Eingabe warten.
- **Alle Prüfregeln gelten unverändert und strikt:** Existenz-Prüfung (Schritt 4),
korrekter verifizierter Einzel-Link, Kontakt-E-Mail nur wenn belegt, **Firmenadresse
nur wenn belegt und zum Stellen-Ort passend** (Schritt 4), die
persistente Dedup **und der Blacklist-Filter** (Schritt 1/3) — importiere **nur**
echte, live verifizierte, noch nicht erfasste und **nicht geblacklistete** Stellen.
Im Zweifel **nicht** importieren (lieber auslassen).
- **Keine Bewerbungen anlegen/versenden**, keine Löschungen bestehender Bewerbungen,
keine Generierung anstoßen — nur neue Jobangebote (`POST /joboffers`) einpflegen
und den jeweils importierten Job blacklisten (`POST /joboffers/blacklist`).
- **Kurzbericht ausgeben** (für das Log): je Treffer `action`
(created+blacklisted / updated / skip-dedup / skip-blacklist) mit Firma, Stelle,
Ort, Link; am Ende Zähler „X neu importiert & geblacklistet, Y als Dublette/
Blacklist übersprungen, Z verworfen (nicht verifizierbar)".
## Regeln
- **Nur sieben Städte.** Es werden **ausschließlich** Stellen mit Arbeitsort in
Gladbeck, Essen, Gelsenkirchen, Oberhausen, Bottrop, Dorsten oder Marl gelistet/importiert.
Alles außerhalb dieser sieben Städte (übriges Ruhrgebiet, reines Remote/Homeoffice ohne
Sitz in einer der sieben Städte) wird **verworfen**. Priorität nach Nähe zu Gladbeck.
- **Label `Regional` setzen (Pflicht).** Jedes von diesem Skill importierte JobOffer
bekommt `labels: ["Regional"]` (ortsgebundene Stelle in einer der sieben Städte).
Kein Remote-/Homeoffice-Label — solche Stellen sind Sache des Skills
`it-stellensuche-remote`.
- **Keine erfundenen Stellen.** Nur Jobs ausgeben, die per WebSearch/WebFetch real
belegt sind. **`quelle_url` muss der korrekte, geprüfte Direktlink zur
Einzelanzeige sein** (per `WebFetch` bestätigt) — keine Such-/Listenseiten, keine
geratenen URLs. Kein verifizierbarer Einzel-Link → Treffer nicht listen/importieren.
- **Nur Original-Anzeigen echter Arbeitgeber.** Headhunter/Personalvermittler,
Zeitarbeit/Arbeitnehmerüberlassung und **Stepstone** sind ausgeschlossen — weder
listen noch importieren (siehe „Quellen-Politik"). Bevorzugt der Firmen-Direktlink;
im Zweifel (Vermittler? Zeitarbeit?) die inserierende Firma prüfen und lieber
verwerfen.
- **`beschreibung` = vollständige Ausschreibung.** Immer den kompletten, per WebFetch
erfassten Anzeigentext (Aufgaben, Anforderungen, Benefits, Konditionen, Bewerbungsweg,
Kennziffer) übernehmen — nicht kürzen oder zusammenfassen. Inhalt aus der realen
Anzeige, nicht frei ergänzen.
- **Kontakt-E-Mail nicht erfinden.** `kontakt_email` nur setzen, wenn die Adresse in
der Anzeige oder auf der Firmen-Karriereseite belegt ist; sonst weglassen.
- **Firmenadresse recherchieren, nicht erfinden — und passend zur Stelle.** `adresse`
(Straße, Hausnummer, PLZ, Ort) im Web ermitteln und nur eine **belegte** Anschrift
eintragen, die **genau zum Arbeitsort der Stelle** gehört. Bei mehreren Standorten
die Niederlassung der ausgeschriebenen Stelle nehmen, **nicht** pauschal den
Hauptsitz. Passt keine verifizierte Adresse zum Stellen-Ort, `adresse` leer lassen
statt eine falsche Zentrale einzutragen.
- **Keine Doppelfunde — eine Firma nur EINMAL, auch über Läufe hinweg.** Vor dem
Listen/Importieren immer `applied-set.sh` **und** `blacklist-set.sh` laden und
jeden Kandidaten per `quelle_url`, `external_id` **und `firma_slug` (firmenweit)**
sowie gegen die Blacklist abgleichen. **Steht die Firma schon im Applied-Set oder
auf der Blacklist — egal unter welchem Stellentitel und egal in welcher
Schreibweise/Rechtsform (`firma_slug` gleich ODER Präfix-Match, mind. 2 Tokens) —
wird kein weiterer Treffer dieses Arbeitgebers ausgegeben oder importiert.** Auch
**innerhalb eines Laufs** jede Firma nur **einmal** präsentieren (bei mehreren
Stellen derselben Firma die am besten passende wählen). Beim Import die
`external_id` **deterministisch aus `firma+stelle`** bilden, **nicht** aus einer
Board-/Anzeigen-ID — so ergibt dieselbe Stelle über jeden Lauf/jedes Board dasselbe
`external_id` (Server-Upsert statt Dublette). Der Server dedupliziert
`POST /joboffers` über `external_id` (Upsert) und die Blacklist (409, firmenweit
über `firma_slug`).
- **Blacklisten nach jedem Import (Pflicht, firmenweit).** Jeder frisch angelegte Job
(`action:created`) wird direkt anschließend per `POST /joboffers/blacklist`
(**`typ:firma`** — die ganze Firma) gesperrt (Schritt 7), damit dieser Arbeitgeber
in Folgeläufen mit keinem Titel wieder auftaucht. Nicht blacklisten bei `updated`
oder bei einem 409-Skip. Ein 409 beim `POST /joboffers` bedeutet „steht schon auf
der Blacklist" → überspringen, nie mit `force` erzwingen.
- **Interaktiv: nicht ungefragt importieren** — erst zeigen, dann auf Bestätigung
importieren. **Autonom/headless: automatisch importieren** (siehe „Autonomer
Modus"). In **keinem** Modus Bewerbungen anlegen/versenden.
- Board-Aggregatoren-Duplikate (dieselbe Stelle auf mehreren Portalen) zu einem
Eintrag zusammenfassen, bevorzugt mit Direkt-/Firmenwebsite-Link.
- Ist die Bewerbungs-Tracker-API nicht erreichbar (`GET /health`), Dedup nicht
möglich → Nutzer warnen und Treffer ohne Dedup mit Hinweis liefern.
## Abhängigkeiten
- Tools: `WebSearch`, `WebFetch`.
- Skill `bewerbungs-tracker` (für Dedup-Daten, JobOffer-Import und Blacklist:
`GET/POST /joboffers/blacklist`).
- Scripts: `scripts/arbeitsagentur.sh` (strukturierte Jobsuche-API der Bundesagentur
für Arbeit — primäre Quelle, `search`/`detail`), `scripts/applied-set.sh` (erfasste
Firmen), `scripts/blacklist-set.sh` (gesperrte Muster). `applied-set`/`blacklist-set`
nutzen den `bt.sh`-Helfer des `bewerbungs-tracker`-Skills; `arbeitsagentur.sh` nutzt
nur `curl` + `python3` (öffentliche API, kein Key nötig).
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# Prints the set of jobs the user has ALREADY engaged with, for deduplication
# during a job search — so the same job is never found/imported twice, also on
# LATER runs. Combines existing Bewerbungen (/applications) and already-imported
# Jobangebote (/joboffers) from the Bewerbungs-Tracker API.
#
# Output: EINE Zeile pro bereits erfasster FIRMA (company-level), tab-separated:
# firma_slug <TAB> firma <TAB> stelle <TAB> ort <TAB> quelle_url <TAB> external_id
#
# firma_slug = robuster Firmen-Schlüssel (lowercase, Umlaute ae/oe/ue/ss,
# Diakritika entfernt, (m/w/d) raus, End-Rechtsform-Tokens wie
# gmbh/ag/kg entfernt, mit "-" verbunden). Identisch zu firmaSlug
# in lib/blacklist.js des Servers — deckt Schreibweisen-/
# Rechtsform-/Umlaut-Varianten EINER Firma ab.
#
# Regel: eine FIRMA darf nur EINMAL gefunden werden. Ein neuer Treffer gilt als
# Dublette (→ verwerfen), wenn seine Firma zu EINER Zeile hier passt:
# * gleicher firma_slug, ODER
# * ein firma_slug ist ein führendes Bindestrich-Präfix des anderen (≥2 Tokens)
# — d. h. Kurzname vs. voller Firmenname ("it-problemloeser" ⊂
# "it-problemloeser-verwaltungs-und-handels"), ODER
# * gleiche quelle_url ODER gleiche external_id.
# Es wird bewusst NICHT mehr nach Stellentitel unterschieden — eine bereits
# erfasste Firma taucht mit KEINEM (auch nicht neuem) Titel wieder auf.
# HTML-Entities werden dekodiert.
#
# Reuses the bewerbungs-tracker skill's helper for auth/base-URL.
set -euo pipefail
BT="${BT_SCRIPT:-$HOME/.claude/skills/bewerbungs-tracker/scripts/bt.sh}"
if [[ ! -x "$BT" ]]; then
echo "applied-set.sh: bewerbungs-tracker helper not found at $BT" >&2
exit 2
fi
# In Tempdateien schreiben (NICHT in Env-Vars): die kombinierte JSON aus
# /applications + /joboffers wird groß; als Env-Var würde sie ARG_MAX (argv+envp)
# sprengen ("Argument list too long" beim python3-Start). Dateien lesen umgeht das.
TMPDIR_AS="$(mktemp -d)"
trap 'rm -rf "$TMPDIR_AS"' EXIT
APPS_FILE="$TMPDIR_AS/apps.json"
OFFERS_FILE="$TMPDIR_AS/offers.json"
"$BT" GET '/applications?limit=500' > "$APPS_FILE"
"$BT" GET '/joboffers' > "$OFFERS_FILE"
APPS_FILE="$APPS_FILE" OFFERS_FILE="$OFFERS_FILE" python3 <<'PY'
import os, json, html, re, sys, unicodedata
def load(name):
with open(os.environ[name], encoding='utf-8') as fh:
raw = fh.read().split('<http')[0]
try:
return json.loads(raw)
except Exception:
return []
def clean(v):
return html.unescape(str(v or '')).replace('\t', ' ').strip()
# Robuster Firmen-Schlüssel — MUSS mit firmaSlug() in lib/blacklist.js des
# Servers übereinstimmen (Umlaut-Translit, (m/w/d) raus, End-Rechtsform-Tokens
# entfernt, mit "-" verbunden), damit Skill- und Server-Dedup dieselbe Firma
# gleich erkennen.
_GENDER = re.compile(r'\((?:[mwdfax](?:\s*/\s*[mwdfax])*)\)', re.I) # (m/w/d), (w/m/x)…
_LEGAL_FORMS = {
'gmbh', 'ggmbh', 'mbh', 'ug', 'haftungsbeschraenkt', 'ag', 'kg', 'kgaa',
'ohg', 'gbr', 'se', 'ek', 'eg', 'ev', 'partg', 'partmbb', 'co', 'cie',
'inc', 'incorporated', 'llc', 'ltd', 'limited', 'plc', 'corp', 'corporation',
'company', 'sa', 'sarl', 'sas', 'bv', 'nv', 'oy', 'ab', 'as', 'aps', 'srl', 'spa',
}
def firma_slug(s):
t = html.unescape(str(s or '')).lower()
t = (t.replace('ä', 'ae').replace('ö', 'oe').replace('ü', 'ue').replace('ß', 'ss'))
t = unicodedata.normalize('NFKD', t)
t = ''.join(c for c in t if not unicodedata.combining(c))
t = _GENDER.sub(' ', t)
t = re.sub(r'[^a-z0-9]+', ' ', t)
t = re.sub(r'\s+', ' ', t).strip()
if not t:
return ''
words = t.split(' ')
kept = words[:]
while len(kept) > 1 and kept[-1] in _LEGAL_FORMS:
kept.pop()
return '-'.join(kept if kept else words)
# Eine Firma nur EINMAL: pro firma_slug genau eine Zeile (erste gewinnt).
seen = set()
def emit(firma, stelle, ort, url, ext):
slug = firma_slug(firma)
if not slug or slug in seen:
return
seen.add(slug)
print('\t'.join([slug, clean(firma), clean(stelle), clean(ort),
clean(url), clean(ext)]))
for a in load('APPS_FILE'):
emit(a.get('firma'), a.get('stelle'), a.get('ort'), a.get('quelle_url'), '')
for o in load('OFFERS_FILE'):
emit(o.get('firma'), o.get('stelle'), o.get('ort'), o.get('quelle_url'),
o.get('external_id'))
print(f'# {len(seen)} bereits erfasste Firmen (Bewerbungen + Jobangebote)', file=sys.stderr)
PY
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# Query the Bundesagentur für Arbeit Jobsuche-API — a structured, FRESH, high-recall
# primary source for the it-stellensuche skills that complements free-text WebSearch.
# Results come back already shaped for the skill's firma_slug-based Dedup, and a
# posting's full detail (Volltext, Bewerbungs-URL, Zeitarbeit/Vermittler-Flags,
# Adresse) can be pulled with one call — no WebFetch needed for the basics.
#
# Public, read-only API. The API key is the well-known public value
# "jobboerse-jobsuche" (same key the arbeitsagentur.de site itself uses).
#
# Usage:
# arbeitsagentur.sh search <was> <wo> [umkreis_km] [tage] [size] [arbeitszeit]
# Lists postings for role <was> around location <wo>. One API call.
# umkreis_km default 15, tage (veröffentlicht seit) default 30, size default 50.
# <wo> leer lassen ("") = deutschlandweit. arbeitszeit optional, u. a. "ho"
# (Homeoffice) für die Remote-Suche, sonst weglassen.
# Output: one posting per line, tab-separated:
# firma_slug <TAB> firma <TAB> titel <TAB> ort <TAB> datum <TAB> refnr <TAB> angebotsart
# firma_slug is identical to applied-set.sh / firmaSlug() in lib/blacklist.js,
# so a hit can be dedup'd against the applied-set/blacklist immediately.
#
# arbeitsagentur.sh detail <refnr>
# Full posting for a refnr (from a search row). Prints a readable field block
# plus the complete Beschreibung — treat it like a WebFetch of the ad. Carries
# istArbeitnehmerUeberlassung / istPrivateArbeitsvermittlung → drop Zeitarbeit/
# Vermittler here WITHOUT a fetch; externeURL → the employer's original link
# (bevorzugt als quelle_url, sofern es zum echten Arbeitgeber führt).
#
# Existence check: refnr is stable; a detail call that returns no Titel/Beschreibung
# (HTTP 404 / error body) means the ad is gone → verwerfen (bzw. JobOffer löschen).
set -euo pipefail
API='https://rest.arbeitsagentur.de/jobboerse/jobsuche-service/pc/v4'
KEY='jobboerse-jobsuche'
usage() { sed -n '2,25p' "$0" >&2; exit 2; }
TMP="$(mktemp)"
trap 'rm -f "$TMP"' EXIT
cmd="${1:-}"; [[ -n "$cmd" ]] && shift || usage
if [[ "$cmd" == "search" ]]; then
was="${1:-}"; wo="${2:-}"; umkreis="${3:-15}"; tage="${4:-30}"; size="${5:-50}"; arbeitszeit="${6:-}"
[[ -n "$was" ]] || { echo "search: <was> fehlt" >&2; usage; }
# curl-Argumente als Array — sauberes, optionales wo=/arbeitszeit=.
args=(-sS -m 30 -G "$API/jobs" -H "X-API-Key: $KEY"
--data-urlencode "was=$was"
--data-urlencode "umkreis=$umkreis"
--data-urlencode "veroeffentlichtseit=$tage"
--data-urlencode "size=$size")
[[ -n "$wo" ]] && args+=(--data-urlencode "wo=$wo")
[[ -n "$arbeitszeit" ]] && args+=(--data-urlencode "arbeitszeit=$arbeitszeit")
curl "${args[@]}" -o "$TMP" || { echo "# Arbeitsagentur-API nicht erreichbar" >&2; exit 0; }
RESP_FILE="$TMP" python3 <<'PY'
import os, sys, json, html, re, unicodedata
# firma_slug — MUSS mit firmaSlug() (lib/blacklist.js) & applied-set.sh übereinstimmen.
_GENDER = re.compile(r'\((?:[mwdfax](?:\s*/\s*[mwdfax])*)\)', re.I)
_LEGAL = {'gmbh','ggmbh','mbh','ug','haftungsbeschraenkt','ag','kg','kgaa','ohg','gbr',
'se','ek','eg','ev','partg','partmbb','co','cie','inc','incorporated','llc',
'ltd','limited','plc','corp','corporation','company','sa','sarl','sas','bv',
'nv','oy','ab','as','aps','srl','spa'}
def firma_slug(s):
t = html.unescape(str(s or '')).lower()
t = t.replace('ä','ae').replace('ö','oe').replace('ü','ue').replace('ß','ss')
t = unicodedata.normalize('NFKD', t)
t = ''.join(c for c in t if not unicodedata.combining(c))
t = _GENDER.sub(' ', t)
t = re.sub(r'[^a-z0-9]+',' ',t)
t = re.sub(r'\s+',' ',t).strip()
if not t: return ''
w = t.split(' '); kept = w[:]
while len(kept) > 1 and kept[-1] in _LEGAL: kept.pop()
return '-'.join(kept if kept else w)
def clean(v):
return html.unescape(str(v if v is not None else '')).replace('\t',' ').replace('\n',' ').strip()
try:
with open(os.environ['RESP_FILE'], encoding='utf-8') as fh:
d = json.load(fh)
except Exception as e:
print(f'# Arbeitsagentur-API: keine/ungueltige Antwort ({e})', file=sys.stderr); sys.exit(0)
jobs = d.get('stellenangebote') or []
n = 0
for j in jobs:
slug = firma_slug(j.get('arbeitgeber'))
if not slug:
continue
ort = (j.get('arbeitsort') or {}).get('ort')
print('\t'.join([slug, clean(j.get('arbeitgeber')), clean(j.get('titel')), clean(ort),
clean(j.get('aktuelleVeroeffentlichungsdatum')),
clean(j.get('refnr')), clean(j.get('angebotsart') or 'ARBEIT')]))
n += 1
print(f'# {n} Treffer (Arbeitsagentur, {d.get("maxErgebnisse","?")} gesamt)', file=sys.stderr)
PY
elif [[ "$cmd" == "detail" ]]; then
ref="${1:-}"; [[ -n "$ref" ]] || { echo "detail: <refnr> fehlt" >&2; usage; }
seg="$(REF="$ref" python3 -c 'import base64,urllib.parse,os; print(urllib.parse.quote(base64.b64encode(os.environ["REF"].encode()).decode(), safe=""))')"
curl -sS -m 30 "$API/jobdetails/$seg" -H "X-API-Key: $KEY" -o "$TMP" \
|| { echo "# Arbeitsagentur-API nicht erreichbar" >&2; exit 0; }
REF="$ref" RESP_FILE="$TMP" python3 <<'PY'
import os, sys, json, html
try:
with open(os.environ['RESP_FILE'], encoding='utf-8') as fh:
d = json.load(fh)
except Exception as e:
print(f'# detail: ungueltige Antwort ({e})', file=sys.stderr); sys.exit(0)
titel = d.get('stellenangebotsTitel') or d.get('titel')
if not titel and not d.get('stellenangebotsBeschreibung'):
print(f'# refnr {os.environ.get("REF","")}: keine aktive Anzeige (abgelaufen/entfernt)')
sys.exit(0)
def u(v): return html.unescape(v) if isinstance(v, str) else v
lok = (d.get('stellenlokationen') or [{}])[0]
adr = ', '.join(x for x in [lok.get('strasse'),
' '.join(y for y in [lok.get('plz'), lok.get('ort')] if y)] if x)
print('FIRMA:', u(d.get('firma')))
print('TITEL:', u(titel))
print('ART:', d.get('stellenangebotsart'))
print('ZEITARBEIT_AUE:', d.get('istArbeitnehmerUeberlassung'))
print('PRIVATE_ARBEITSVERMITTLUNG:', d.get('istPrivateArbeitsvermittlung'))
print('HOMEOFFICE_MOEGLICH:', d.get('homeofficemoeglich'))
print('VERGUETUNG:', d.get('verguetungsangabe'))
print('ADRESSE:', adr or '(keine)')
print('EXTERNE_URL:', u(d.get('externeURL')) or '(keine — dann Firmen-Karriereseite suchen)')
print('REFERENZNUMMER:', d.get('referenznummer') or os.environ.get('REF',''))
print('VEROEFFENTLICHT:', d.get('datumErsteVeroeffentlichung') or d.get('aktuelleVeroeffentlichungsdatum'))
print('AENDERUNG:', d.get('aenderungsdatum'))
print('--- BESCHREIBUNG ---')
print(u(d.get('stellenangebotsBeschreibung')) or '(keine)')
PY
else
usage
fi
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Prints the Bewerbungs-Tracker BLACKLIST in a matchable, tab-separated form, so
# a job-search run can discard candidates that are blocked BEFORE listing or
# importing them (server also rejects blocked offers with 409 on POST /joboffers,
# but we don't want to even present them).
#
# Output: one blacklist entry per line, tab-separated:
# id <TAB> typ <TAB> firma <TAB> stelle <TAB> ort <TAB> domain <TAB> url_norm <TAB> firma_norm <TAB> firma_slug <TAB> stelle_norm <TAB> ort_norm <TAB> grund
#
# typ = url | domain | firma | firma_stelle | auto
#
# firma_slug = robuster Firmen-Schlüssel (Umlaut-Translit, End-Rechtsform raus,
# mit "-" verbunden) — identisch zu applied-set.sh und firmaSlug()
# in lib/blacklist.js. Damit greift die Firmen-Sperre auch bei
# abweichender Schreibweise/Rechtsform.
#
# A candidate is BLOCKED when one entry matches:
# typ=url -> candidate URL (normalized) == url_norm
# typ=domain -> candidate URL host == domain (also matches subdomains)
# typ=firma -> SAME company: candidate firma_slug == firma_slug, OR one
# firma_slug is a leading hyphen-prefix of the other (>=2
# tokens); firma_norm only as fallback for legacy rows.
# typ=firma_stelle -> same company (firma_slug, wie oben) AND stelle_norm equal
# (ort_norm only narrows further when set)
# typ=auto -> treat like the concrete fields it carries (url/firma_stelle)
#
# The server already provides the *_norm fields (lowercased, tracking params
# stripped, gender/legal-form removed), so compare against those.
#
# Reuses the bewerbungs-tracker skill's helper for auth/base-URL.
set -euo pipefail
BT="${BT_SCRIPT:-$HOME/.claude/skills/bewerbungs-tracker/scripts/bt.sh}"
if [[ ! -x "$BT" ]]; then
echo "blacklist-set.sh: bewerbungs-tracker helper not found at $BT" >&2
exit 2
fi
bl="$("$BT" GET '/joboffers/blacklist')"
BL_JSON="$bl" python3 <<'PY'
import os, json, html, sys
def load(name):
raw = os.environ[name].split('<http')[0]
try:
return json.loads(raw)
except Exception:
return []
def clean(v):
return html.unescape(str(v if v is not None else '')).replace('\t', ' ').strip()
rows = load('BL_JSON')
for e in rows:
print('\t'.join(clean(e.get(k)) for k in (
'id', 'typ', 'firma', 'stelle', 'ort', 'domain',
'url_norm', 'firma_norm', 'firma_slug', 'stelle_norm', 'ort_norm', 'grund')))
print(f'# {len(rows)} Blacklist-Eintraege', file=sys.stderr)
PY
+48 -37
View File
@@ -1,50 +1,61 @@
# NextJobs Indeed Import (Browser-Erweiterung)
# NextJobs Stellen-Import (Browser-Erweiterung)
Chromium-Erweiterung (Manifest V3), die auf Indeed einen Button **direkt neben der
Stellenbeschreibung** einfügt. Ein Klick sendet alle Stelleninformationen an den
NextJobs. Dort wird die Stelle als **Entwurf** angelegt und die KI
erstellt automatisch zugeschnittene Bewerbungsunterlagen (auf Basis der hinterlegten
Vorlagen).
Chromium-Erweiterung (Manifest V3), die **auf jeder Webseite** Stellen
ausliest und an NextJobs (`https://nextjobs.cc`) sendet. Auf Indeed wird
zusätzlich ein Button *direkt neben der Stellenbeschreibung* eingeblendet.
Die Stelle wird im Tracker als **Entwurf** angelegt; die KI erstellt daraus
zugeschnittene Bewerbungsunterlagen (auf Basis der hinterlegten Vorlagen).
## Multi-User & Authentifizierung
Die Tracker-Adresse ist fest `https://nextjobs.cc` (hardcodiert). Jeder
Benutzer authentifiziert sich mit seinem **persönlichen REST-API-Key**:
- Im Tracker unter **Einstellungen → API-Token (X-API-Key)** einen Token
setzen (falls noch keiner da ist: neu vergeben).
- In der Erweiterung auf das Symbol klicken → **Einstellungen** → Token
eintragen → **Speichern** + **Verbindung testen**.
Der Key wird im `X-API-Key`-Header gesendet; der Server ordnet den Import dem
Benutzerkonto zu, das diesen Token hinterlegt hat. Ohne (oder mit ungültigem)
Key antwortet der Server mit **401**. Der Key wird pro Browser-Profil in der
`chrome.storage.sync` gespeichert — wer mehrere Konten verwaltet, kann den Key
jederzeit in den Einstellungen wechseln.
## Installation (Entwicklermodus)
1. NextJobs starten (Standard: `http://localhost:3000`).
2. In Chrome/Edge/Brave `chrome://extensions` öffnen.
3. **Entwicklermodus** oben rechts aktivieren.
4. **Entpackte Erweiterung laden** klicken und diesen `extension/`-Ordner auswählen.
## Konfiguration
Auf das Erweiterungs-Symbol klicken und die **Tracker-Adresse** setzen
(z. B. `http://localhost:3000` oder die Adresse deiner Deployment-Instanz).
Mit **Verbindung testen** prüfen, ob der Tracker erreichbar ist.
1. In Chrome/Edge/Brave `chrome://extensions` öffnen.
2. **Entwicklermodus** oben rechts aktivieren.
3. **Entpackte Erweiterung laden** klicken und diesen `extension/`-Ordner
auswählen.
4. API-Key eintragen (siehe oben).
## Verwendung
1. Auf `indeed.com` eine Stelle öffnen.
2. Neben der Überschrift **„Vollständige Stellenbeschreibung"** erscheint der grüne
Button **„An NextJobs senden"**.
3. Klicken → die Stelle wird als Entwurf angelegt, die Unterlagen werden im
Hintergrund generiert. Über den Link „Entwurf öffnen" gelangst du direkt zur
Bewerbung im Tracker.
1. Auf einer Stellenanzeige das Erweiterungs-Symbol klicken. Die Felder werden
automatisch ausgelesen (Schema.org/JSON-LD → OpenGraph → Auswahl/Text).
Text auf der Seite markieren → wird als Beschreibung übernommen.
2. Felder prüfen, ggf. korrigieren und **An Tracker senden** klicken.
Auf Indeed alternativ den grünen Button **„An NextJobs senden"** neben
der Stellenbeschreibung.
3. Über den Link **Entwurf öffnen** gelangst du direkt zur Bewerbung im Tracker.
## Voraussetzungen im Tracker
- Unter **Vorlagen** mindestens ein Basis-Dokument (z. B. Anschreiben, Lebenslauf)
hinterlegen — diese dienen der KI als Faktengrundlage.
- `OLLAMA_API_KEY` (Ollama Cloud) gesetzt z. B. in der `.env`-Datei des Trackers ,
damit die KI-Generierung läuft.
- Unter **Vorlagen** mindestens ein Basis-Dokument (z. B. Anschreiben,
Lebenslauf) hinterlegen — diese dienen der KI als Faktengrundlage.
- `OLLAMA_API_KEY` (Ollama Cloud) gesetzt z. B. in den Einstellungen des
Benutzers damit die KI-Generierung läuft.
- Ein **API-Token (X-API-Key)** für den benutzten Account.
## Ausgelesene Felder
## Berechtigungen
| Feld | Quelle (Indeed DOM) |
|------|---------------------|
| Stelle | `[data-testid="jobsearch-JobInfoHeader-title"]` |
| Firma | `[data-testid="inlineHeader-companyName"]` (im `jobsearch-CompanyInfoContainer`) |
| Ort | `[data-testid="jobsearch-JobInfoHeader-companyLocation"]` |
| Gehalt/Art | `#salaryInfoAndJobType` |
| Stellenbeschreibung | `#jobDescriptionText` |
| Quelle-URL | aus `vjk`/`jk`-Parameter bzw. aktueller URL |
| Berechtigung | Zweck |
|---|---|
| `storage` | API-Key speichern (`chrome.storage.sync`) |
| `scripting` + `activeTab` | Stelle auf der aktiven Seite auslesen (nur auf Klick) |
| `https://nextjobs.cc/*` | Import an den Tracker senden |
| `*://*.indeed.com/*` | Inline-Button auf Indeed einblenden |
Indeed ist eine Single-Page-App; die Erweiterung erkennt Wechsel der geöffneten
Stelle per `MutationObserver` und hält den Button aktuell.
Die Erweiterung liest nur die aktive Seite aus, wenn du sie aufrufst, und
sendet die Daten ausschließlich an `https://nextjobs.cc`.
+26 -15
View File
@@ -1,15 +1,18 @@
// Service worker: performs the cross-origin POST to the NextJobs.
// Routing the request through the background worker (instead of the content
// script) keeps it independent of the Indeed page's Content-Security-Policy.
// Service worker: performs the cross-origin POST to NextJobs.
// Routing the request through the background worker (instead of a content
// script) keeps it independent of the page's Content-Security-Policy.
//
// Die Tracker-Adresse ist fest (https://nextjobs.cc). Die Authentifizierung
// läuft über den persönlichen REST-API-Key (Header X-API-Key), den jeder
// Benutzer im Popup hinterlegt — der Server ordnet den Import damit dem
// richtigen Benutzerkonto zu. Ohne Key lehnt der Server mit 401 ab.
const DEFAULT_TRACKER_URL = 'http://localhost:3000';
const TRACKER_URL = 'https://nextjobs.cc';
function getTrackerUrl() {
function getApiKey() {
return new Promise((resolve) => {
chrome.storage.sync.get({ trackerUrl: DEFAULT_TRACKER_URL }, (items) => {
let url = (items.trackerUrl || DEFAULT_TRACKER_URL).trim();
url = url.replace(/\/+$/, ''); // strip trailing slashes
resolve(url);
chrome.storage.sync.get({ apiKey: '' }, (items) => {
resolve((items.apiKey || '').trim());
});
});
}
@@ -18,10 +21,14 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message && message.type === 'IMPORT_JOB') {
(async () => {
try {
const base = await getTrackerUrl();
const res = await fetch(base + '/api/indeed-import', {
const apiKey = await getApiKey();
if (!apiKey) {
sendResponse({ ok: false, needKey: true, error: 'Kein API-Key hinterlegt.' });
return;
}
const res = await fetch(TRACKER_URL + '/api/indeed-import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
body: JSON.stringify(message.payload),
});
@@ -39,6 +46,10 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
});
return;
}
if (res.status === 401) {
sendResponse({ ok: false, needKey: true, error: 'API-Key abgelehnt (401). Bitte in den Einstellungen prüfen.' });
return;
}
sendResponse({
ok: false,
error: (data && data.error) || `Server antwortete mit ${res.status}`,
@@ -50,14 +61,14 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
ok: true,
id: data.id,
message: data.message,
openUrl: data.url ? base + data.url : null,
openUrl: data.url ? TRACKER_URL + data.url : null,
});
} catch (err) {
sendResponse({
ok: false,
error:
'Konnte den NextJobs nicht erreichen. Läuft er, und ist die ' +
'Adresse in den Erweiterungs-Einstellungen korrekt? (' + String(err.message) + ')',
'Konnte NextJobs nicht erreichen (' + String(err.message) + '). ' +
'Prüfe deine Internetverbindung und ob der API-Key in den Einstellungen korrekt ist.',
});
}
})();
+4 -7
View File
@@ -1,15 +1,12 @@
{
"manifest_version": 3,
"name": "NextJobs Stellen-Import",
"version": "1.1.0",
"description": "Erfasst Stellen von jeder Webseite (nicht nur Indeed) über das Erweiterungs-Popup und sendet sie an den NextJobs, der automatisch zugeschnittene Bewerbungsunterlagen erstellt.",
"version": "2.0.0",
"description": "Erfasst Stellen von jeder Webseite (nicht nur Indeed) über das Erweiterungs-Popup und sendet sie an NextJobs (https://nextjobs.cc), der automatisch zugeschnittene Bewerbungsunterlagen erstellt. Authentifizierung über deinen persönlichen REST-API-Key.",
"permissions": ["storage", "scripting", "activeTab"],
"host_permissions": [
"*://*.indeed.com/*",
"http://localhost/*",
"http://127.0.0.1/*",
"http://*/*",
"https://*/*"
"https://nextjobs.cc/*",
"*://*.indeed.com/*"
],
"content_scripts": [
{
+4 -3
View File
@@ -94,10 +94,11 @@
<div id="status"></div>
<details id="settings">
<details id="settings" open>
<summary>Einstellungen</summary>
<label for="trackerUrl">Tracker-Adresse</label>
<input type="url" id="trackerUrl" placeholder="http://localhost:3000" />
<label for="apiKey">REST-API-Key</label>
<input type="password" id="apiKey" placeholder="API-Key aus den Tracker-Einstellungen" autocomplete="off" />
<p class="hint" style="margin:6px 0 0">Deinen persönlichen Key findest du im Tracker unter <b>Einstellungen → API-Token (X-API-Key)</b>. Pro Benutzer eigener Key.</p>
<div class="row">
<button class="primary" id="saveBtn">Speichern</button>
<button class="secondary" id="testBtn">Verbindung testen</button>
+25 -14
View File
@@ -1,36 +1,40 @@
const DEFAULT_TRACKER_URL = 'http://localhost:3000';
// Tracker-Adresse ist fest (gehostete Instanz). Jeder Benutzer gibt seinen
// eigenen REST-API-Key ein (X-API-Key), über den der Server den Import dem
// richtigen Benutzerkonto zuordnet — siehe /api/indeed-import serverseitig.
const TRACKER_URL = 'https://nextjobs.cc';
const $ = (id) => document.getElementById(id);
const statusEl = $('status');
function normalize(url) {
return (url || '').trim().replace(/\/+$/, '');
}
function setStatus(html, cls) {
statusEl.innerHTML = html;
statusEl.className = cls || '';
}
// ---- Settings (tracker URL) ------------------------------------------------
// ---- Settings (API-Key) ----------------------------------------------------
chrome.storage.sync.get({ trackerUrl: DEFAULT_TRACKER_URL }, (items) => {
$('trackerUrl').value = items.trackerUrl || DEFAULT_TRACKER_URL;
chrome.storage.sync.get({ apiKey: '' }, (items) => {
$('apiKey').value = items.apiKey || '';
});
$('saveBtn').addEventListener('click', () => {
const url = normalize($('trackerUrl').value) || DEFAULT_TRACKER_URL;
chrome.storage.sync.set({ trackerUrl: url }, () => {
$('trackerUrl').value = url;
setStatus('Einstellungen gespeichert.', 'ok');
const key = $('apiKey').value.trim();
chrome.storage.sync.set({ apiKey: key }, () => {
setStatus(key ? 'API-Key gespeichert.' : 'API-Key entfernt.', key ? 'ok' : '');
});
});
$('testBtn').addEventListener('click', async () => {
const url = normalize($('trackerUrl').value) || DEFAULT_TRACKER_URL;
const key = $('apiKey').value.trim();
if (!key) { setStatus('Bitte erst API-Key eingeben.', 'err'); return; }
setStatus('Teste Verbindung …', '');
try {
const res = await fetch(url + '/api/settings', { method: 'GET' });
setStatus(res.ok ? 'Verbindung erfolgreich ✓' : 'Erreichbar, aber unerwartete Antwort (' + res.status + ').', res.ok ? 'ok' : 'err');
const res = await fetch(TRACKER_URL + '/api/v1/health', {
headers: { 'X-API-Key': key },
});
if (res.ok) setStatus('Verbindung erfolgreich ✓ — Key gültig.', 'ok');
else if (res.status === 401) setStatus('Key abgelehnt (401). Bitte API-Key prüfen.', 'err');
else setStatus('Erreichbar, aber unerwartete Antwort (' + res.status + ').', 'err');
} catch (err) {
setStatus('Nicht erreichbar: ' + err.message, 'err');
}
@@ -158,6 +162,13 @@ function sendJob(payload) {
setStatus('Fehler: ' + chrome.runtime.lastError.message, 'err');
return;
}
if (resp && resp.needKey) {
$('sendBtn').disabled = false;
setStatus('Bitte erst in den Einstellungen deinen API-Key hinterlegen.', 'err');
const s = document.getElementById('settings'); if (s) s.open = true;
$('apiKey').focus();
return;
}
if (resp && resp.duplicate) {
$('sendBtn').disabled = false;
if (confirm(duplicateWarning(resp.matches))) {
+11 -7
View File
@@ -12,7 +12,7 @@ const path = require('path');
const fs = require('fs');
const blacklist = require('./blacklist');
const { LABEL_OPTIONS, parseLabels, serializeLabels } = require('./labels');
const { normalizeDokumente } = require('./documents');
const { normalizeDokumente, standardDokumente } = require('./documents');
const { userContext, currentUserId } = require('./context');
const config = require('./config');
@@ -26,7 +26,7 @@ function withLabels(row) {
const STATUS_OPTIONS = [
'Entwurf', 'Gesendet', 'Eingangsbestätigung',
'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
'In Bearbeitung', 'Interessiert', 'Telefonat', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
'Vorstellungsgespräch', 'Vertragsverhandlung',
'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung',
];
@@ -452,14 +452,18 @@ function createExternalApi(deps) {
[id, uid()]
);
// Optional: IDs of static attachments (basis_anhaenge) to enclose; none
// by default. Also reflected in the cover letter's "Anlagen" list.
// Optional: IDs of static attachments (basis_anhaenge) to enclose. Omitted
// means the user's preselection from the settings (GEN_ANLAGEN_DEFAULT,
// default: none); an empty array means explicitly none. Also reflected in
// the cover letter's "Anlagen" list.
const anlagenIds = Array.isArray((req.body || {}).anlagen)
? req.body.anlagen.map((v) => parseInt(v, 10)).filter((n) => !Number.isNaN(n))
: [];
: config.anlagenDefaultIds();
// Optional: which documents to produce (["anschreiben"], ["lebenslauf"] or
// both). Omitted / empty means both.
const dokumente = normalizeDokumente((req.body || {}).dokumente);
// both). Omitted / empty falls back to the user's preselection from the
// settings (GEN_DOKUMENTE_DEFAULT, default: both).
const gewuenscht = (req.body || {}).dokumente;
const dokumente = gewuenscht == null ? standardDokumente() : normalizeDokumente(gewuenscht);
await dbRun('UPDATE bewerbungen SET generierung_dokumente = ? WHERE id = ? AND user_id = ?', [dokumente.join(','), id, uid()]);
runGeneration(id, { anlagenIds, dokumente });
res.status(202).json({ success: true, dokumente });
+11
View File
@@ -76,6 +76,16 @@ function localInputToUtc(dateStr, timeStr) {
return wallToUtc(y, mo, d, h || 0, mi || 0, TZ);
}
// UTC Date -> Berliner Wanduhr-Bestandteile { datum: 'YYYY-MM-DD', zeit: 'HH:MM' }
// (für vorausgefüllte Bearbeiten-Formulare).
function utcToWallInputs(date) {
const p = new Intl.DateTimeFormat('en-CA', {
timeZone: TZ, year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', hour12: false,
}).formatToParts(date).reduce((a, x) => (a[x.type] = x.value, a), {});
return { datum: `${p.year}-${p.month}-${p.day}`, zeit: `${p.hour}:${p.minute}` };
}
// UTC Date -> iCalendar UTC stamp "YYYYMMDDTHHMMSSZ".
function toIcsUtc(date) {
return date.getUTCFullYear() + pad(date.getUTCMonth() + 1) + pad(date.getUTCDate())
@@ -335,6 +345,7 @@ module.exports = {
collectionUrl,
wallToUtc,
localInputToUtc,
utcToWallInputs,
buildICS,
parseVEvent,
getCtag,
+46 -4
View File
@@ -23,9 +23,15 @@ const { currentUser } = require('./context');
const DEFAULTS = {
// Ollama Cloud (KI text generation + chat).
OLLAMA_API_KEY: '',
OLLAMA_MODEL: 'gpt-oss:120b',
OLLAMA_MODEL: 'glm-5.2:cloud',
OLLAMA_HOST: 'https://ollama.com',
OLLAMA_TIMEOUT_MS: '300000',
// Which documents are preselected when a generation is started (comma separated,
// see DOKUMENT_TYPEN in lib/documents.js). Empty means both.
GEN_DOKUMENTE_DEFAULT: 'anschreiben,lebenslauf',
// Which static attachments (basis_anhaenge IDs, comma separated) are preselected.
// Empty = none, which is the default.
GEN_ANLAGEN_DEFAULT: '',
// E-Mail (SMTP submission + IMAP receive).
MAIL_HOST: '',
MAIL_SMTP_PORT: '587',
@@ -52,11 +58,37 @@ const FIELDS = [
beschreibung: 'Steuerung der KI, die Bewerbungsunterlagen erzeugt und den KI-Chat beantwortet. Ohne API-Schlüssel sind diese Funktionen deaktiviert.',
items: [
{ key: 'OLLAMA_API_KEY', label: 'API-Schlüssel', secret: true, help: 'Schlüssel von https://ollama.com/settings/keys' },
{ key: 'OLLAMA_MODEL', label: 'Modell', help: 'Standard: gpt-oss:120b' },
{ key: 'OLLAMA_MODEL', label: 'Modell', help: 'Standard: glm-5.2:cloud' },
{ key: 'OLLAMA_HOST', label: 'Host', help: 'Standard: https://ollama.com — lokaler Ollama: http://localhost:11434' },
{ key: 'OLLAMA_TIMEOUT_MS', label: 'Timeout (ms)', help: 'Standard: 300000 (5 Min.)' },
],
},
{
titel: 'Bewerbungsunterlagen (Vorauswahl)',
beschreibung: 'Was ist auf der Bewerbungsseite vorab angehakt, wenn die KI-Generierung gestartet wird? Pro Bewerbung lässt sich die Auswahl weiterhin ändern; die zuletzt gewählten Dokumente bleiben an der jeweiligen Bewerbung erhalten.',
items: [
{
key: 'GEN_DOKUMENTE_DEFAULT',
label: 'Standardmäßig generieren',
type: 'checkboxes',
options: [
{ value: 'anschreiben', label: 'Anschreiben' },
{ value: 'lebenslauf', label: 'Lebenslauf' },
],
help: 'Standard: beides. Die E-Mail-Begleitnachricht wird immer erzeugt. Ohne Häkchen gilt wieder beides.',
},
{
key: 'GEN_ANLAGEN_DEFAULT',
label: 'Standardmäßig beigelegte Anlagen',
type: 'checkboxes',
// Auswahlmöglichkeiten sind die eigenen Anlagen aus „Vorlagen“ und stehen
// erst zur Renderzeit fest — die Route reicht sie unter diesem Namen hinein.
optionsFrom: 'anlagen',
leerHinweis: 'Noch keine Anlagen hinterlegt unter „Vorlagen“ kannst du Zeugnisse & Co. hochladen.',
help: 'Standard: keine. Angehakte Anlagen werden bei jeder Generierung vorausgewählt und im Anschreiben unter „Anlagen“ aufgeführt.',
},
],
},
{
titel: 'E-Mail (SMTP-Versand + IMAP-Empfang)',
beschreibung: 'Versand läuft über den eigenen Mailserver (DKIM/SPF/DMARC-Alignment). Ohne Host/Benutzer/Passwort ist der E-Mail-Teil deaktiviert. Pro Benutzer eigenes Postfach.',
@@ -159,11 +191,21 @@ function getAll() {
return out;
}
// The current user's preselected static attachments (GEN_ANLAGEN_DEFAULT) as
// basis_anhaenge IDs. Deleted attachments may linger in the list; callers match
// them against the existing rows, so stale IDs simply never hit.
function anlagenDefaultIds() {
return String(get('GEN_ANLAGEN_DEFAULT') || '')
.split(',')
.map((v) => parseInt(v, 10))
.filter((n) => !Number.isNaN(n));
}
// Ollama bundle (shared by lib/documents.js + lib/chat.js), for the current user.
function ollama() {
return {
host: (get('OLLAMA_HOST') || 'https://ollama.com').replace(/\/+$/, ''),
model: get('OLLAMA_MODEL') || 'gpt-oss:120b',
model: get('OLLAMA_MODEL') || 'glm-5.2:cloud',
timeoutMs: Number(get('OLLAMA_TIMEOUT_MS')) || 300000,
apiKey: get('OLLAMA_API_KEY') || '',
};
@@ -213,5 +255,5 @@ async function setForUser(userId, key, value) {
module.exports = {
DEFAULTS, FIELDS, PREFIX,
get, getAll, ollama, init, saveAll, setForUser, ensureLoaded, invalidate,
get, getAll, ollama, anlagenDefaultIds, init, saveAll, setForUser, ensureLoaded, invalidate,
};
+170 -45
View File
@@ -3,7 +3,7 @@
// Same contract as lib/prompts.js: the defaults live here, an override is a row
// in the `design` table, and deleting the row restores the default.
//
// Two layouts ship today:
// Three layouts ship today:
//
// sidebar — the classic: neutral greys, tinted sidebar, DIN-5008 letter. The
// accent is a single restrained colour; hierarchy comes from weight
@@ -13,15 +13,28 @@
// headline, a full-page tinted background). Right for creative,
// social-media and design-adjacent roles — and deliberately loud.
//
// sunny — an editorial poster look: white page, oversized display headings
// in a deep accent, pastel accent bars that alternate left/right, a
// photo breaking out of a tinted hero band, a solid role badge and
// level bars for the languages. Bold but tidy — right for marketing,
// media and design roles that still want a readable, gridded page.
//
// tupfen — the quiet one with a wink: a white page, the name centred and set
// large in plain ink, airy body text — and scattered coral dots in
// the margins, some running off the page edge. The dots are the only
// colour on the sheet; the résumé picks the motif back up as section
// markers, bullets and five-dot language meters. Restrained enough
// for any office, but nobody mistakes it for a template.
//
// The cover letter always follows the résumé's layout, so both documents arrive
// as one coherent set — that is the whole point of picking a design here.
//
// The palette stays closed (no free colour picker): every tone is one that still
// works when an HR department prints it in greyscale.
// Accent colours. In `sidebar` the accent is the *only* colour; in `social` the
// whole page palette (background, cards, pills) is derived from it, so the
// playful layout works in pink as well as in deep blue.
// Accent colours. In `sidebar` the accent is the *only* colour; in `social` and
// `sunny` the whole page palette (bands, cards, pills) is derived from it, so
// those layouts work in berry as well as in deep blue.
const AKZENTE = [
{ key: 'anthrazit', label: 'Anthrazit', rgb: [23, 23, 23] },
{ key: 'tiefblau', label: 'Tiefblau', rgb: [30, 58, 95] },
@@ -29,6 +42,11 @@ const AKZENTE = [
{ key: 'waldgruen', label: 'Waldgrün', rgb: [27, 67, 50] },
{ key: 'stahlblau', label: 'Stahlblau', rgb: [51, 65, 85] },
{ key: 'kupfer', label: 'Kupfer', rgb: [124, 62, 24] },
{ key: 'beere', label: 'Beere (Sunny)', rgb: [109, 24, 74] },
// Deliberately deeper than the pastel dots of Tupfen's reference: in `sidebar`
// the accent *is* the text colour, and a pale coral would be unreadable there.
// Tupfen's dots are tinted up from this tone, which lands them back at pastel.
{ key: 'koralle', label: 'Koralle (Tupfen)', rgb: [222, 106, 106] },
{ key: 'pink', label: 'Pink (verspielt)', rgb: [232, 54, 143] },
{ key: 'violett', label: 'Violett (verspielt)', rgb: [147, 51, 187] },
];
@@ -36,6 +54,8 @@ const AKZENTE = [
const LAYOUTS = [
{ key: 'sidebar', label: 'Klassisch mit Sidebar (Standard)' },
{ key: 'social', label: 'Social Media (verspielt)' },
{ key: 'sunny', label: 'Sunny (plakativ, Farbbänder)' },
{ key: 'tupfen', label: 'Tupfen (dezent, verspielte Punkte)' },
];
const SIDEBAR_VARIANTEN = [
@@ -63,6 +83,12 @@ const SCHRIFT_STUFEN = [
const LAYOUT_DEFAULTS = {
sidebar: { akzent: 'anthrazit', foto_form: 'eckig' },
social: { akzent: 'pink', foto_form: 'rund' },
// Sunny is built around the berry/pastel pairing of its reference; the photo
// sits in a square frame that breaks out of the hero band, so square it is.
sunny: { akzent: 'beere', foto_form: 'eckig' },
// Tupfen scatters coral dots; a round portrait is simply the largest of them,
// so it falls in with the motif instead of fighting it.
tupfen: { akzent: 'koralle', foto_form: 'rund' },
};
const DEFAULTS = {
@@ -129,18 +155,37 @@ function settings(overrides = {}) {
function resolve(overrides = {}) {
const s = settings(overrides);
const accent = findOption(AKZENTE, s.akzent, 'anthrazit').rgb;
const social = s.layout === 'social';
let sidebarBg;
if (s.sidebar === 'weiss') sidebarBg = [255, 255, 255];
else if (s.sidebar === 'getoent') sidebarBg = tint(accent, 0.08);
else sidebarBg = NEUTRAL_SIDEBAR;
// The playful layout derives its whole palette from the accent: a tinted page,
// near-white cards with a soft border, pill chips, and a warm dark ink that is
// the accent shaded down rather than plain black.
const rc = social
? {
// Résumé palettes, one per layout. Every key exists in every palette so the
// renderer can read `t.rc.<x>` without knowing which layout it is drawing.
const RC = {
sidebar: {
ink: [33, 33, 33],
sub: [92, 96, 100],
hair: [208, 210, 214],
accent,
pageBg: null, // no page tint in the classic layout
cardBg: [255, 255, 255],
cardBorder: [208, 210, 214],
deko: [208, 210, 214], // the classic layout draws no sparkles; kept for shape parity
dekoHell: [230, 232, 235],
band: tint(accent, 0.14),
pillBg: tint(accent, 0.12),
pillInk: accent,
sidebarBg,
track: [199, 202, 208],
onSide: [38, 40, 44],
onSideSub: [96, 100, 106],
},
// The playful layout derives its whole palette from the accent: a tinted
// page, near-white cards with a soft border, pill chips, and a warm dark ink
// that is the accent shaded down rather than plain black.
social: {
ink: shade(accent, 0.35),
sub: tint(shade(accent, 0.55), 0.75),
hair: tint(accent, 0.30),
@@ -151,46 +196,66 @@ function resolve(overrides = {}) {
// Sparkles/blob sit on the tinted page, so they need more punch than the
// card border or they simply vanish.
deko: tint(accent, 0.45),
dekoHell: tint(accent, 0.22),
band: tint(accent, 0.22),
pillBg: tint(accent, 0.18),
pillInk: shade(accent, 0.6),
sidebarBg, // unused in this layout, kept so the shape stays stable
track: tint(accent, 0.45),
onSide: shade(accent, 0.35),
onSideSub: tint(shade(accent, 0.55), 0.75),
}
: {
ink: [33, 33, 33],
sub: [92, 96, 100],
hair: [208, 210, 214],
},
// Sunny keeps the page white and puts the colour into solid blocks: pastel
// bands and rails on one side, the full-strength accent in the display
// headings and the role badge. Only two tones, used at maximum contrast.
sunny: {
ink: [40, 36, 42],
sub: [124, 118, 126],
hair: tint(accent, 0.22),
accent,
pageBg: null, // no page tint in the classic layout
pageBg: null, // white page — the colour lives in the bands
cardBg: [255, 255, 255],
cardBorder: [208, 210, 214],
deko: [208, 210, 214], // the classic layout draws no sparkles; kept for shape parity
cardBorder: tint(accent, 0.22),
deko: tint(accent, 0.30),
dekoHell: tint(accent, 0.16),
band: tint(accent, 0.24), // the pastel hero band and section bars
pillBg: accent, // solid badge…
pillInk: [255, 255, 255], // …with the text knocked out white
sidebarBg, // unused
track: tint(accent, 0.16), // rail behind a language level bar
onSide: [40, 36, 42],
onSideSub: [124, 118, 126],
},
// Tupfen is a monochrome page that happens to have confetti on it. The text
// is ink on white throughout — the accent never touches a paragraph, only
// the dots: the scattered ones in the margins, the section markers, the
// bullets, the language meters. The hairline stays a *neutral* grey rather
// than a tinted one, or the restraint collapses and the page turns pink.
tupfen: {
ink: [26, 26, 28],
sub: [112, 114, 120],
hair: [226, 228, 232],
accent,
pageBg: null, // white page — the dots are the whole design
cardBg: [255, 255, 255],
cardBorder: [226, 228, 232],
deko: tint(accent, 0.62), // the dots' base tone
dekoHell: tint(accent, 0.34), // …and the paler ones behind them, for depth
band: tint(accent, 0.14),
pillBg: tint(accent, 0.12),
pillInk: accent,
sidebarBg,
track: [199, 202, 208],
onSide: [38, 40, 44],
onSideSub: [96, 100, 106],
};
pillInk: shade(accent, 0.72),
sidebarBg, // unused
track: tint(accent, 0.20), // an unfilled dot of a language meter
onSide: [26, 26, 28],
onSideSub: [112, 114, 120],
},
};
const rc = RC[s.layout] || RC.sidebar;
// The cover letter always mirrors the résumé, so the two documents read as one
// set — a playful CV never arrives with a stiff monochrome letter.
const lc = social
? {
ink: rc.ink,
muted: rc.sub,
hair: rc.hair,
accent,
pageBg: rc.pageBg,
cardBg: rc.cardBg,
cardBorder: rc.cardBorder,
deko: rc.deko,
pillBg: rc.pillBg,
pillInk: rc.pillInk,
}
: {
const LC = {
sidebar: {
ink: [26, 26, 26],
muted: [92, 96, 100],
hair: [206, 208, 212],
@@ -199,18 +264,78 @@ function resolve(overrides = {}) {
cardBg: [255, 255, 255],
cardBorder: [206, 208, 212],
deko: [206, 208, 212],
dekoHell: [230, 232, 235],
band: rc.band,
pillBg: tint(accent, 0.12),
pillInk: accent,
};
},
social: {
ink: rc.ink,
muted: rc.sub,
hair: rc.hair,
accent,
pageBg: rc.pageBg,
cardBg: rc.cardBg,
cardBorder: rc.cardBorder,
deko: rc.deko,
dekoHell: rc.dekoHell,
band: rc.band,
pillBg: rc.pillBg,
pillInk: rc.pillInk,
},
sunny: {
ink: rc.ink,
muted: rc.sub,
hair: rc.hair,
accent,
pageBg: rc.pageBg,
cardBg: rc.cardBg,
cardBorder: rc.cardBorder,
deko: rc.deko,
dekoHell: rc.dekoHell,
band: rc.band,
// The letter's role badge sits on the pastel head band, so a solid accent
// slug reads too loud next to the résumé's poster. A white pill with the
// accent knocked out keeps the motif but softens it — the letter is the
// quiet twin, not a second poster.
pillBg: rc.cardBg,
pillInk: accent,
},
// Tupfen's letter is not the quieter twin of anything — the résumé is already
// quiet. Both sheets are the same white page with the same confetti, so the
// letter simply takes the résumé's tones unchanged.
tupfen: {
ink: rc.ink,
muted: rc.sub,
hair: rc.hair,
accent,
pageBg: rc.pageBg,
cardBg: rc.cardBg,
cardBorder: rc.cardBorder,
deko: rc.deko,
dekoHell: rc.dekoHell,
band: rc.band,
pillBg: rc.pillBg,
pillInk: rc.pillInk,
},
};
const lc = LC[s.layout] || LC.sidebar;
// Which typefaces the renderer registers and draws with. The playful layout
// adds a script face for its "Hallo, ich bin" line; Sunny lives off Poppins
// Bold alone (its whole hierarchy is size, not style); the classic one stays
// on Lato and embeds nothing extra. Tupfen takes Poppins too — the reference's
// geometric grotesk, whose round, wide letterforms rhyme with the dots.
const FONTS = {
sidebar: { sans: 'Lato', script: null },
social: { sans: 'Poppins', script: 'Pacifico' },
sunny: { sans: 'Poppins', script: null },
tupfen: { sans: 'Poppins', script: null },
};
return {
layout: s.layout,
// Which typefaces the renderer registers and draws with. The playful layout
// uses a geometric sans plus a script face for the "Hallo, ich bin" line;
// the classic one stays on Lato and embeds nothing extra.
fonts: social
? { sans: 'Poppins', script: 'Pacifico' }
: { sans: 'Lato', script: null },
fonts: FONTS[s.layout] || FONTS.sidebar,
rc,
lc,
foto: {
+1184 -13
View File
File diff suppressed because it is too large Load Diff
+14 -5
View File
@@ -9,7 +9,7 @@ const ART_OPTIONS = [
];
const STATUS_OPTIONS = [
'Entwurf', 'Gesendet', 'Eingangsbestätigung',
'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
'In Bearbeitung', 'Interessiert', 'Telefonat', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
'Vorstellungsgespräch', 'Vertragsverhandlung',
'Absage', 'Absage von meiner Seite', 'Einstellung', 'Keine Rückmeldung',
];
@@ -373,15 +373,16 @@ function buildOpenApiSpec(baseUrl = '') {
items: { type: 'integer' },
description:
'IDs der zusätzlich beizulegenden statischen Anlagen (basis_anhaenge). ' +
'Standard: keine. Die Auswahl erscheint auch im Anschreiben unter "Anlagen".',
'Weglassen = Vorauswahl aus den Einstellungen (GEN_ANLAGEN_DEFAULT, Standard: keine), ' +
'leeres Array = ausdrücklich keine. Die Auswahl erscheint auch im Anschreiben unter "Anlagen".',
},
dokumente: {
type: 'array',
items: { type: 'string', enum: ['anschreiben', 'lebenslauf'] },
description:
'Welche Unterlagen erzeugt werden sollen. Weglassen oder leer = beide (Standard). ' +
'Wird der Lebenslauf nicht erzeugt, führt ihn das Anschreiben auch nicht unter ' +
'"Anlagen" auf, und der E-Mail-Begleittext kündigt ihn nicht an.',
'Welche Unterlagen erzeugt werden sollen. Weglassen = Vorauswahl aus den Einstellungen ' +
'(GEN_DOKUMENTE_DEFAULT, Standard: beide). Wird der Lebenslauf nicht erzeugt, führt ihn ' +
'das Anschreiben auch nicht unter "Anlagen" auf, und der E-Mail-Begleittext kündigt ihn nicht an.',
example: ['anschreiben'],
},
},
@@ -944,6 +945,14 @@ function buildOpenApiSpec(baseUrl = '') {
anzeige_datum: { type: 'string', format: 'date', nullable: true, description: 'Datum der eigentlichen Stellenanzeige (vom Drittanbieter übergeben)' },
kontakt_email: { type: 'string', format: 'email', nullable: true, description: 'Kontakt-E-Mail-Adresse der Stelle' },
status: { type: 'string', enum: ['offen', 'uebernommen', 'abgelehnt'] },
favorit: {
type: 'integer',
enum: [0, 1],
readOnly: true,
description: 'Vom Nutzer in der Weboberfläche gemerktes Angebot (Stern). Wird nur dort gesetzt ' +
'und beim erneuten Einspielen desselben Angebots nicht überschrieben. Unabhängig vom ' +
'Favoriten-Merkmal einer Bewerbung.',
},
labels: { ...labelsSchema, nullable: true },
verknuepfte_bewerbung_id: { type: 'integer', nullable: true },
url_norm: { type: 'string', nullable: true, description: 'Normalisierte URL für die De-Duplizierung (serverseitig gesetzt)' },
+104
View File
@@ -0,0 +1,104 @@
// Minimal, dependency-free ZIP (STORE, no compression) for serving the
// browser extension as a single downloadable file. The files are tiny, so
// skipping DEFLATE keeps the code small and needs no native tooling in the
// container (the slim runtime image ships no `zip` binary).
//
// Returns a Buffer containing a valid .zip whose entries sit at the archive
// root (flat — matching "load unpacked extension" on an extracted folder).
const fs = require('fs');
const path = require('path');
const CRC_TABLE = (() => {
const t = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
t[n] = c >>> 0;
}
return t;
})();
function crc32(buf) {
let c = 0xffffffff;
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
// DOS time/date for a fixed deterministic stamp (1980-01-01 00:00:00).
// We avoid per-build timestamps so the archive is byte-identical for a given
// source state (cache-friendly, reproducible).
const DOS_TIME = 0x0000;
const DOS_DATE = 0x0021; // 1980-01-01
function u16(n) { return Buffer.from([n & 0xff, (n >>> 8) & 0xff]); }
function u32(n) { return Buffer.from([n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]); }
// Files: array of { name, data: Buffer } (root-relative, no leading slash).
function zipFiles(files) {
const localParts = [];
const centralParts = [];
let offset = 0;
for (const f of files) {
const nameBuf = Buffer.from(f.name, 'utf8');
const crc = crc32(f.data);
const size = f.data.length;
const local = Buffer.concat([
u32(0x04034b50), // local file header signature
u16(20), // version needed to extract
u16(0), // general purpose bit flag
u16(0), // compression method (0 = store)
u16(DOS_TIME), u16(DOS_DATE),
u32(crc), u32(size), u32(size),
u16(nameBuf.length), u16(0),
nameBuf,
f.data,
]);
const central = Buffer.concat([
u32(0x02014b50), // central directory header signature
u16(20), // version made by
u16(20), // version needed to extract
u16(0), u16(0),
u16(DOS_TIME), u16(DOS_DATE),
u32(crc), u32(size), u32(size),
u16(nameBuf.length), u16(0), u16(0), // name len, extra len, comment len
u16(0), // disk number start
u16(0), // internal attributes
u32(0), // external attributes
u32(offset), // local header offset
nameBuf,
]);
localParts.push(local);
centralParts.push(central);
offset += local.length;
}
const centralBuf = Buffer.concat(centralParts);
const end = Buffer.concat([
u32(0x06054b50), // end of central directory signature
u16(0), u16(0), // disk number, disk with CD
u16(files.length), u16(files.length), // entries on this disk, total
u32(centralBuf.length), u32(offset),
u16(0), // comment length
]);
return Buffer.concat([...localParts, centralBuf, end]);
}
// Read every file in a directory (non-recursive, flat) and return a zip Buffer.
function zipDir(dir) {
const names = fs.readdirSync(dir).sort();
const files = [];
for (const name of names) {
const full = path.join(dir, name);
if (!fs.statSync(full).isFile()) continue;
files.push({ name, data: fs.readFileSync(full) });
}
return zipFiles(files);
}
module.exports = { zipDir, zipFiles, crc32 };
+2 -1
View File
@@ -8,7 +8,8 @@
"dev": "nodemon server.js",
"docker:build": "docker build -t git.hackner.dev/thomas/jobbi-bewerbung:latest .",
"docker:push": "docker push git.hackner.dev/thomas/jobbi-bewerbung:latest",
"docker:deploy": "npm run docker:build && npm run docker:push"
"docker:deploy": "npm run docker:build && npm run docker:push",
"docker:build-agent": "docker build -t jobbi-bewerbung-agent:latest agent"
},
"dependencies": {
"ejs": "^3.1.9",
+29 -11
View File
@@ -452,19 +452,37 @@ button.loading::after {
============================================ */
/* Modals: the overlay locks body scroll (overflow:hidden on <body>), so a panel
taller than the viewport would have its bottom cut off and unreachable. Cap
the panel to the viewport and let it scroll internally; align the wrapper to
the top so the close button stays in reach instead of being clipped. */
taller than the viewport must scroll. The scroll container is the INNER
wrapper, not the backdrop, and it gets a DEFINITE height:100vh + overflow-y:auto.
A mere min-height:100vh (Tailwind's min-h-screen) is not enough: in Safari the
flex wrapper then does NOT grow to fit the panel, the panel overflows visibly
("card geht aus dem Bild") but the container reports no overflow — so nothing
scrolls and the Save button is unreachable. A definite height + overflow
forces a real scroll container in every browser. The panel keeps margin:auto
so a SHORT modal stays centered, while a tall one starts at the top (no
top-clip) and scrolls to the bottom. -webkit-overflow-scrolling gives older
iOS momentum touch scrolling. */
.fixed.inset-0 {
overflow: hidden !important;
}
.fixed.inset-0 > .min-h-screen {
height: 100vh !important;
min-height: 0 !important;
overflow-y: auto !important;
-webkit-overflow-scrolling: touch;
align-items: flex-start !important;
}
.fixed.inset-0 > .min-h-screen > .max-w-lg,
.fixed.inset-0 > .min-h-screen > .max-w-md,
.fixed.inset-0 > .min-h-screen > .max-w-2xl,
.fixed.inset-0 > .min-h-screen > .max-w-3xl,
.fixed.inset-0 > .min-h-screen > .max-w-4xl {
margin: auto !important;
}
@media (max-width: 640px) {
.max-w-lg,
.max-w-md {
.fixed.inset-0 > .min-h-screen > .max-w-lg,
.fixed.inset-0 > .min-h-screen > .max-w-md {
max-width: calc(100vw - 2rem) !important;
max-height: calc(100vh - 2rem) !important;
overflow-y: auto !important;
margin: 0 !important;
}
.fixed.inset-0 > .min-h-screen {
align-items: flex-start !important;
}
}
+2 -1
View File
@@ -355,7 +355,7 @@ function generatePdfDocument(settings, applications, month, year) {
// ---- Group entries by status (order kept in sync with views/index.ejs) ----
const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung',
'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
'In Bearbeitung', 'Interessiert', 'Telefonat', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
'Vorstellungsgespräch', 'Vertragsverhandlung', 'Einstellung', 'Absage', 'Absage von meiner Seite', 'Keine Rückmeldung'];
// Accent colour per status (RGB) reused by the chart and the list headings
const statusColors = {
@@ -364,6 +364,7 @@ function generatePdfDocument(settings, applications, month, year) {
'Eingangsbestätigung': [14, 165, 233],
'In Bearbeitung': [20, 184, 166],
'Interessiert': [236, 72, 153],
'Telefonat': [244, 114, 182],
'Warten auf Rückmeldung': [249, 115, 22],
'Warten auf meine Antwort': [217, 70, 239],
'Vorstellungsgespräch': [245, 158, 11],
+119 -65
View File
@@ -2,29 +2,39 @@
// 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.
// `claude` nor `ollama` nor a docker socket. 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.
//
// Isolation: every run executes in its OWN throwaway Docker container
// (AGENT_IMAGE), with the requesting user's dedicated home directory mounted
// as /home/agent. That home IS that user's ~/.claude (memories, session
// contexts, history, settings) — never shared with another user. The three
// search skills are shared code baked into the image and symlinked into
// ~/.claude/skills by the image entrypoint.
//
// 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),
// 2. claims queued runs (status angefordert -> laeuft) and runs them in
// parallel up to MAX_PARALLEL — different users have different Ollama
// keys and thus separate rate limits, so parallelism is safe across users,
// 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.
// 4. starts a container that 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,
// - ANTHROPIC_* = the user's own Ollama endpoint+key, so the search is
// billed to whoever requested it,
// 5. parses the agent's final "ERGEBNIS neu=… dubletten=… verworfen=…" line
// and writes status + counts back, so the UI can show what happened.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { spawn } = require('child_process');
const { spawn, spawnSync } = require('child_process');
const sqlite3 = require('sqlite3');
const suchprofil = require('../lib/suchprofil');
@@ -32,16 +42,25 @@ 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;
// Agent-Container. Das Image wird lokal gebaut (npm run docker:build-agent),
// nicht über die Registry verteilt — es läuft nur auf diesem Host.
const AGENT_IMAGE = process.env.JOBSUCHE_AGENT_IMAGE || 'jobbi-bewerbung-agent:latest';
// uid/gid, als die der Container läuft und der das pro-Benutzer-Home gehört.
const AGENT_UID = Number(process.env.JOBSUCHE_AGENT_UID) || 1000;
const AGENT_GID = Number(process.env.JOBSUCHE_AGENT_GID) || AGENT_UID;
// Pro-Benutzer-Verzeichnisse (je HOME = je ~/.claude).
const AGENTS_DIR = process.env.JOBSUCHE_AGENTS_DIR || '/opt/jobbi-bewerbung/agents';
// Gleichzeitig laufende Such-Container. Verschiedene Benutzer haben getrennte
// Ollama-Keys -> getrennte Rate-Limits, deshalb ist pro-Benutzer-Parallelität
// sicher. Der Wert begrenzt lediglich die Host-Last.
const MAX_PARALLEL = Number(process.env.JOBSUCHE_MAX_PARALLEL) || 4;
const CFG = 'cfg:';
const db = new sqlite3.Database(DB_PATH);
@@ -88,6 +107,16 @@ async function profilText(userId) {
return rows.map((r) => (r.inhalt || '').trim()).filter(Boolean).join('\n\n---\n\n');
}
// Legt das pro-Benutzer-Home an (falls noch nicht vorhanden) und übergibt es
// an den Container-Agent. Bleibt über Läufe hinweg bestehen, damit Memories
// und Contexte erhalten bleiben.
function ensureAgentDir(userId) {
const dir = path.join(AGENTS_DIR, String(userId));
fs.mkdirSync(dir, { recursive: true });
spawnSync('chown', ['-R', `${AGENT_UID}:${AGENT_GID}`, dir]);
return dir;
}
// 1. Queue scheduled runs that are due.
async function faelligeEinreihen() {
const jetzt = new Date();
@@ -120,7 +149,7 @@ async function faelligeEinreihen() {
async function verwaisteAufraeumen() {
const res = await run(
`UPDATE suchlaeufe SET status = 'fehler', beendet_at = CURRENT_TIMESTAMP,
fehler = 'Lauf abgebrochen (Prozess nicht mehr vorhanden).'
fehler = 'Lauf abgebrochen (Container nicht mehr vorhanden).'
WHERE status = 'laeuft'
AND gestartet_at IS NOT NULL
AND (julianday('now') - julianday(gestartet_at)) * 86400000 > ?`,
@@ -152,26 +181,30 @@ async function laufAbschliessen(id, status, felder = {}) {
);
}
// Spawn the headless agent and collect its output.
function agentStarten(prompt, env, logStream) {
// Start the headless agent inside a throwaway container for this user. Only the
// curated env below is passed in (not process.env) so no host secrets leak.
function agentStarten(prompt, envObj, logStream, containerName, agentDir) {
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 args = [
'run', '--rm',
'--name', containerName,
// --network host: der Agent erreicht den Tracker (localhost:4327), das
// Internet (WebSearch/WebFetch/Arbeitsagentur) und den Ollama-Endpoint
// des Benutzers — alles über die Host-Netzwerk-Sicht.
'--network', 'host',
'--user', `${AGENT_UID}:${AGENT_GID}`,
'-e', 'HOME=/home/agent',
'-e', 'IS_SANDBOX=1',
];
for (const [k, v] of Object.entries(envObj)) args.push('-e', `${k}=${v}`);
args.push(
'-v', `${agentDir}:/home/agent`,
'-w', '/work',
AGENT_IMAGE,
'claude', '--dangerously-skip-permissions', '-p', prompt,
);
const kind = spawn(argv.cmd, argv.args, {
env,
cwd: '/opt/jobbi-bewerbung',
stdio: ['ignore', 'pipe', 'pipe'],
});
const kind = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
let ausgabe = '';
const sammeln = (buf) => {
@@ -182,14 +215,17 @@ function agentStarten(prompt, env, logStream) {
kind.stdout.on('data', sammeln);
kind.stderr.on('data', sammeln);
// Beim Timeout den `docker run`-Client killen UND den Container per Name
// stoppen — ein kill des Clients allein ließe den Container sonst weiterlaufen.
const timer = setTimeout(() => {
spawnSync('docker', ['kill', containerName], { stdio: 'ignore' });
kind.kill('SIGTERM');
setTimeout(() => kind.kill('SIGKILL'), 60000);
setTimeout(() => kind.kill('SIGKILL'), 10000);
}, RUN_TIMEOUT_MS);
kind.on('error', (err) => {
clearTimeout(timer);
resolve({ code: -1, ausgabe, fehler: `Agent nicht startbar: ${err.message}` });
resolve({ code: -1, ausgabe, fehler: `Agent-Container nicht startbar: ${err.message}` });
});
kind.on('close', (code, signal) => {
clearTimeout(timer);
@@ -207,7 +243,8 @@ 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 containerName = `jobbi-agent-${userId}-${lauf.id}`;
log(`Lauf ${lauf.id} (Benutzer ${name}, ${lauf.ausloeser}) startet im Container ${containerName}.`);
const profil = suchprofil.fromRow(await get('SELECT * FROM suchprofil WHERE user_id = ?', [userId]));
const fehler = suchprofil.validate(profil);
@@ -218,7 +255,7 @@ async function laufAusfuehren(lauf) {
}
const ollamaKey = await cfgWert(userId, 'OLLAMA_API_KEY');
if (KI_AUTH !== 'host' && !ollamaKey) {
if (!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}.`);
@@ -235,27 +272,21 @@ async function laufAusfuehren(lauf) {
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`);
logStream.write(`Modell: ${modell} | Container: ${containerName}\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.
// Nur kuratierte Env-Vars in den Container — keine Host-Secrets.
const envObj = {
ANTHROPIC_BASE_URL: (await cfgWert(userId, 'OLLAMA_HOST')) || 'https://ollama.com',
ANTHROPIC_AUTH_TOKEN: ollamaKey,
ANTHROPIC_MODEL: modell,
ANTHROPIC_SMALL_FAST_MODEL: modell,
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 agentDir = ensureAgentDir(userId);
const { code, ausgabe, fehler: laufFehler } = await agentStarten(prompt, envObj, logStream, containerName, agentDir);
const ergebnis = suchprofil.parseErgebnis(ausgabe);
logStream.write(`\n${'-'.repeat(50)}\nExit-Code: ${code}\n`);
@@ -286,6 +317,27 @@ async function laufAusfuehren(lauf) {
log(` Lauf ${lauf.id} fertig: ${ergebnis.neu} neu, ${ergebnis.dubletten} Dubletten, ${ergebnis.verworfen} verworfen.`);
}
// Führt eine Liste von Läufen mit höchstens `parallel` gleichzeitig aus. Jeder
// Lauf läuft in seinem eigenen Container; verschiedene Benutzer haben getrennte
// Ollama-Keys, deshalb ist pro-Benutzer-Parallelität sicher.
async function runPool(items, parallel, fn) {
const queue = items.slice();
const workers = Array.from({ length: Math.min(parallel, queue.length || 1) }, async () => {
while (queue.length) {
const item = queue.shift();
try {
await fn(item);
} catch (e) {
log(` Lauf ${item.id} unerwartet abgebrochen: ${e && e.message ? e.message : e}`);
try {
await laufAbschliessen(item.id, 'fehler', { fehler: `Runner-Ausnahme: ${e && e.message ? e.message : e}` });
} catch (_) { /* Status ist dann zwar offen, aber ein Folge-Tick räumt auf. */ }
}
}
});
await Promise.all(workers);
}
function alteLogsAufraeumen() {
try {
const grenze = Date.now() - LOG_RETENTION_DAYS * 86400000;
@@ -307,15 +359,17 @@ async function main() {
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.
// Alle wartenden Läufe einsammeln (pro Benutzer steht ohnehin nur einer an)
// und mit begrenzter Parallelität abarbeiten.
const laeufe = [];
let lauf;
let anzahl = 0;
while ((lauf = await naechstenLaufClaimen())) {
await laufAusfuehren(lauf);
anzahl += 1;
while ((lauf = await naechstenLaufClaimen())) laeufe.push(lauf);
if (!laeufe.length) {
log('Nichts zu tun.');
} else {
log(`${laeufe.length} Lauf/Läufe eingereiht, starte bis zu ${MAX_PARALLEL} parallel.`);
await runPool(laeufe, MAX_PARALLEL, laufAusfuehren);
}
if (!anzahl) log('Nichts zu tun.');
alteLogsAufraeumen();
db.close();
+11 -4
View File
@@ -9,7 +9,8 @@
# 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.
# API-Schlüssel. Jeder Suchlauf läuft isoliert im eigenen Docker-Container pro
# Benutzer; der Runner selbst bleibt auf dem Host (Docker-CLI + DB-Zugriff).
#
# Cron (alle 5 Minuten — die Uhrzeit steuert der Benutzer, nicht der Cron):
# */5 * * * * /opt/jobbi-bewerbung/bin/jobsuche-runner.sh
@@ -26,13 +27,13 @@ export PATH="/root/.local/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
# wartenden Suchläufe blieben ewig im Status 'angefordert' hängen.
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
# 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"
# Agent-Image (pro Suchlauf ein frischer Container). Wird lokal gebaut:
# npm --prefix "$PROJECT_DIR/source" run docker:build-agent
AGENT_IMAGE="${JOBSUCHE_AGENT_IMAGE:-jobbi-bewerbung-agent:latest}"
mkdir -p "$LOG_DIR"
LOG="$LOG_DIR/jobsuche-runner.log"
@@ -52,6 +53,12 @@ if ! curl -fsS --max-time 10 http://localhost:4327/api/v1/health >/dev/null 2>&1
exit 1
fi
# Agent-Image muss vorhanden sein, sonst stapeln sich fehlgeschlagene Läufe.
if ! docker image inspect "$AGENT_IMAGE" >/dev/null 2>&1; then
echo "$(date -Is) FEHLER: Agent-Image '$AGENT_IMAGE' fehlt bitte bauen: npm --prefix $PROJECT_DIR/source run docker:build-agent" >> "$LOG"
exit 1
fi
node "$RUNNER" >> "$LOG" 2>&1
STATUS=$?
+594 -104
View File
File diff suppressed because it is too large Load Diff
+229
View File
@@ -0,0 +1,229 @@
<!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-5xl">
<a href="/admin" class="inline-flex items-center gap-2 text-sm text-blue-600 dark:text-blue-400 hover:underline mb-6">
<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="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
Zur Benutzerverwaltung
</a>
<%
const istIch = user.id === currentUserId;
const letzterAdmin = user.is_admin && adminCount <= 1;
%>
<div class="flex flex-wrap items-center gap-4 mb-8">
<span class="grid h-14 w-14 place-items-center rounded-full text-lg font-semibold <%= user.is_admin ? 'bg-blue-600/10 text-blue-700 dark:bg-blue-500/15 dark:text-blue-300' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300' %>">
<%= String(user.username || '?').charAt(0).toUpperCase() %>
</span>
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2.5">
<h1 class="text-2xl font-bold text-gray-800 dark:text-white"><%= user.username %></h1>
<% if (user.is_admin) { %>
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">Admin</span>
<% } else { %>
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300">Benutzer</span>
<% } %>
<% if (istIch) { %><span class="text-xs text-gray-400">(du)</span><% } %>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
<%= anzahlBewerbungen %> <%= anzahlBewerbungen === 1 ? 'Bewerbung' : 'Bewerbungen' %> ·
angelegt <time data-dt="<%= user.created_at %>"><%= user.created_at %></time>
</p>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
<!-- Verwaltung -->
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">Benutzer verwalten</h2>
<div class="space-y-5">
<!-- Admin-Status -->
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-gray-800 dark:text-gray-100">Admin-Status</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
<% if (letzterAdmin) { %>Letzter Admin kann nicht abgesetzt werden.<% } else { %>Admins können Benutzer verwalten und andere Konten übernehmen.<% } %>
</p>
</div>
<form method="POST" action="/admin/users/<%= user.id %>/toggle-admin" class="inline">
<button type="submit" <%= letzterAdmin ? 'disabled' : '' %>
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed
<%= user.is_admin ? 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600' : 'bg-blue-600 hover:bg-blue-700 text-white' %>">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0a1.724 1.724 0 002.573 1.066c.78-.448 1.707.479 1.26 1.26a1.724 1.724 0 001.065 2.572c.921.3.921 1.603 0 1.902a1.724 1.724 0 00-1.066 2.573c.448.78-.479 1.707-1.26 1.26a1.724 1.724 0 00-2.572 1.065c-.3.921-1.603.921-1.902 0a1.724 1.724 0 00-2.573-1.066c-.78.448-1.707-.479-1.26-1.26a1.724 1.724 0 00-1.065-2.572c-.921-.3-.921-1.603 0-1.902a1.724 1.724 0 001.066-2.573c-.448-.78.479-1.707 1.26-1.26.996.608 2.296.07 2.572-1.065z"></path><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path></svg>
<%= user.is_admin ? 'Zum Benutzer herabstufen' : 'Zum Admin machen' %>
</button>
</form>
</div>
<hr class="border-gray-100 dark:border-gray-700">
<!-- Suchläufe starten -->
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-gray-800 dark:text-gray-100">Suchläufe selber starten</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Manueller „Jetzt suchen"-Knopf auf der Jobsuche. Der automatische Zeitplan läuft davon unberührt.</p>
</div>
<form method="POST" action="/admin/users/<%= user.id %>/toggle-suchlauf" class="inline">
<button type="submit"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors
<%= user.darf_suchlauf_starten ? 'bg-green-600 hover:bg-green-700 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600' %>">
<span class="inline-block w-2 h-2 rounded-full <%= user.darf_suchlauf_starten ? 'bg-white' : 'bg-gray-400' %>"></span>
<%= user.darf_suchlauf_starten ? 'Erlaubt' : 'Gesperrt' %>
</button>
</form>
</div>
<hr class="border-gray-100 dark:border-gray-700">
<!-- Zeitplan ändern -->
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-gray-800 dark:text-gray-100">Zeitplan ändern dürfen</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Wochentage, Uhrzeit und „aktiv". Standard: MoFr 17:00, verwaltet vom Administrator.</p>
</div>
<form method="POST" action="/admin/users/<%= user.id %>/toggle-zeitplan" class="inline">
<button type="submit"
class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-md transition-colors
<%= user.darf_zeitplan_aendern ? 'bg-green-600 hover:bg-green-700 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-200 hover:bg-gray-200 dark:hover:bg-gray-600' %>">
<span class="inline-block w-2 h-2 rounded-full <%= user.darf_zeitplan_aendern ? 'bg-white' : 'bg-gray-400' %>"></span>
<%= user.darf_zeitplan_aendern ? 'Erlaubt' : 'Gesperrt' %>
</button>
</form>
</div>
<hr class="border-gray-100 dark:border-gray-700">
<!-- Passwort zurücksetzen -->
<div>
<div class="flex flex-wrap items-center justify-between gap-3 mb-2">
<div>
<p class="text-sm font-medium text-gray-800 dark:text-gray-100">Passwort zurücksetzen</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Alle Sitzungen dieses Benutzers werden ungültig.</p>
</div>
<button type="button" onclick="document.getElementById('resetForm').classList.toggle('hidden')"
class="act-btn act-blue">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path></svg>
<span>Neues Passwort</span>
</button>
</div>
<form id="resetForm" method="POST" action="/admin/users/<%= user.id %>/reset-password" class="hidden flex items-end gap-3 flex-wrap rounded-lg border border-blue-200 bg-blue-50/50 p-4 dark:border-blue-500/20 dark:bg-blue-500/5">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
Neues Passwort für <%= user.username %>
<input name="password" type="password" required
class="mt-1.5 block w-full sm:w-72 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-gray-800 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none" />
</label>
<button type="submit" class="rounded-lg bg-blue-600 hover:bg-blue-700 transition-colors text-white font-medium px-4 py-2 shadow-sm">Speichern</button>
<button type="button" onclick="document.getElementById('resetForm').classList.toggle('hidden')"
class="rounded-lg px-3 py-2 text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">Abbrechen</button>
</form>
</div>
<hr class="border-gray-100 dark:border-gray-700">
<!-- Anmelden als / Suchprofil -->
<div class="flex flex-wrap gap-2">
<% if (!istIch) { %>
<form method="POST" action="/admin/users/<%= user.id %>/impersonate" class="inline"
onsubmit="return confirm('Als „<%= user.username %>“ anmelden? Du siehst dann dessen Konto. Admin-Rechte sind dabei pausiert; über das Banner oben kannst du zurückwechseln.');">
<button type="submit" class="act-btn act-emerald" title="Dieses Konto übernehmen (Impersonation)">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14v7m-4-4h8"></path></svg>
<span>Anmelden als</span>
</button>
</form>
<% } %>
<a href="/jobsuche?user=<%= user.id %>" class="act-btn act-neutral" title="Suchprofil & Zeitplan dieses Benutzers verwalten">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
<span>Jobsuche</span>
</a>
</div>
<% if (!istIch) { %>
<hr class="border-gray-100 dark:border-gray-700">
<!-- Löschen -->
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<p class="text-sm font-medium text-rose-700 dark:text-rose-400">Benutzer löschen</p>
<p class="text-xs text-gray-500 dark:text-gray-400">Löscht das Konto inkl. aller Bewerbungen und Dateien. Unumkehrbar.</p>
</div>
<form method="POST" action="/admin/users/<%= user.id %>/delete" class="inline"
onsubmit="return confirm('Benutzer „<%= user.username %>“ inkl. aller Daten wirklich löschen? Das lässt sich nicht rückgängig machen.');">
<button type="submit" class="act-btn act-danger" title="Benutzer inkl. aller Daten löschen">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
<span>Löschen</span>
</button>
</form>
</div>
<% } %>
</div>
</section>
<!-- Letzte Bewerbungen -->
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
<h2 class="text-lg font-semibold text-gray-800 dark:text-white mb-4">Letzte Bewerbungen</h2>
<% if (bewerbungen.length) { %>
<ul class="divide-y divide-gray-100 dark:divide-gray-700">
<% bewerbungen.forEach(function(b){ %>
<li class="py-3 flex items-center gap-3">
<span class="inline-block w-2.5 h-2.5 rounded-full flex-shrink-0
<%= b.status === 'Einstellung' ? 'bg-green-500' :
b.status === 'Absage' ? 'bg-red-500' :
b.status === 'Vorstellungsgespräch' ? 'bg-yellow-500' :
'bg-gray-300' %>"></span>
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 truncate"><%= b.firma %></p>
<p class="text-xs text-gray-500 dark:text-gray-400 truncate"><%= b.stelle %></p>
</div>
<span class="text-xs text-gray-400 dark:text-gray-500 whitespace-nowrap shrink-0"><%= new Date(b.datum).toLocaleDateString('de-DE') %></span>
</li>
<% }); %>
</ul>
<% if (anzahlBewerbungen > bewerbungen.length) { %>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-3">… und <%= anzahlBewerbungen - bewerbungen.length %> weitere.</p>
<% } %>
<% } else { %>
<p class="text-sm text-gray-500 dark:text-gray-400">Noch keine Bewerbungen.</p>
<% } %>
</section>
</div>
</main>
<%- include('partials/footer') %>
<style>
.act-btn{display:inline-flex;align-items:center;gap:.375rem;border-radius:.5rem;padding:.375rem .625rem;font-size:.75rem;font-weight:500;transition:background-color .15s,color .15s;line-height:1;}
.act-neutral{color:#4b5563}.act-neutral:hover{background:#f3f4f6}
.dark .act-neutral{color:#d1d5db}.dark .act-neutral:hover{background:rgba(255,255,255,.06)}
.act-emerald{color:#047857}.act-emerald:hover{background:#ecfdf5}
.dark .act-emerald{color:#6ee7b7}.dark .act-emerald:hover{background:rgba(16,185,129,.12)}
.act-blue{color:#2563eb}.act-blue:hover{background:#eff6ff}
.dark .act-blue{color:#93c5fd}.dark .act-blue:hover{background:rgba(59,130,246,.12)}
.act-danger{color:#e11d48}.act-danger:hover{background:#fff1f2}
.dark .act-danger{color:#fda4af}.dark .act-danger:hover{background:rgba(244,63,94,.12)}
</style>
<script>
// Relative Zeitangaben (data-dt) wie in der Admin-Übersicht formatieren.
(function () {
document.querySelectorAll('time[data-dt]').forEach(function (t) {
var s = t.getAttribute('data-dt');
if (!s) return;
var d = new Date(s.replace(' ', 'T') + 'Z');
if (isNaN(d.getTime())) return;
t.textContent = d.toLocaleDateString('de-DE') + ', ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
});
})();
</script>
<%- include('partials/favorit-script') %>
</body>
</html>
+4 -17
View File
@@ -104,13 +104,14 @@
<% users.forEach(function(u) { %>
<tr>
<td class="py-3 px-6 font-medium text-gray-800 dark:text-gray-100">
<div class="flex items-center gap-2.5">
<a href="/admin/users/<%= u.id %>" class="flex items-center gap-2.5 group -mx-1.5 -my-1 px-1.5 py-1 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700/60 transition-colors">
<span class="grid h-8 w-8 place-items-center rounded-full text-xs font-semibold <%= u.is_admin ? 'bg-blue-600/10 text-blue-700 dark:bg-blue-500/15 dark:text-blue-300' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300' %>">
<%= String(u.username || '?').charAt(0).toUpperCase() %>
</span>
<span><%= u.username %></span>
<span class="group-hover:text-blue-600 dark:group-hover:text-blue-400"><%= u.username %></span>
<% if (u.id === currentUserId) { %><span class="text-xs text-gray-400">(du)</span><% } %>
</div>
<svg class="w-4 h-4 text-gray-300 dark:text-gray-600 group-hover:text-blue-500 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
</a>
</td>
<td class="py-3 pr-4">
<% if (u.is_admin) { %>
@@ -128,11 +129,6 @@
</td>
<td class="py-3 pr-6">
<div class="flex flex-wrap items-center justify-end gap-1.5">
<a href="/jobsuche?user=<%= u.id %>"
class="act-btn act-neutral" title="Suchprofil & Zeitplan dieses Benutzers verwalten">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
<span>Jobsuche</span>
</a>
<% if (u.id !== currentUserId) { %>
<form method="POST" action="/admin/users/<%= u.id %>/impersonate" class="inline"
onsubmit="return confirm('Als „<%= u.username %>“ anmelden? Du siehst dann dessen Konto. Admin-Rechte sind dabei pausiert; über das Banner oben kannst du zurückwechseln.');">
@@ -148,15 +144,6 @@
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path></svg>
<span>Passwort</span>
</button>
<% if (u.id !== currentUserId) { %>
<form method="POST" action="/admin/users/<%= u.id %>/delete" class="inline"
onsubmit="return confirm('Benutzer „<%= u.username %>“ inkl. aller Daten wirklich löschen? Das lässt sich nicht rückgängig machen.');">
<button type="submit" class="act-btn act-danger" title="Benutzer inkl. aller Daten löschen">
<svg class="h-4 w-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
<span>Löschen</span>
</button>
</form>
<% } %>
</div>
</td>
</tr>
+23 -11
View File
@@ -15,6 +15,7 @@
'Eingangsbestätigung': 'bg-blue-500',
'In Bearbeitung': 'bg-teal-500',
'Interessiert': 'bg-pink-500',
'Telefonat': 'bg-sky-500',
'Warten auf Rückmeldung': 'bg-orange-500',
'Warten auf meine Antwort': 'bg-fuchsia-500',
'Vorstellungsgespräch': 'bg-yellow-500',
@@ -69,28 +70,39 @@
<% if (aktivitaet.length) { %>
<ul class="divide-y divide-gray-100 dark:divide-gray-700/60">
<% aktivitaet.forEach(function (a) { %>
<% aktivitaet.forEach(function (a) {
var dotCls = a.typ === 'email'
? (a.direction === 'out' ? 'bg-sky-500' : 'bg-blue-500')
: (statusDot[a.status] || 'bg-gray-300');
var titel = '', titelSub = '';
if (a.bewerbung_id) {
titel = a.firma || '(ohne Firma)';
titelSub = a.stelle || '';
} else if (a.typ === 'email') {
titel = a.direction === 'out' ? ('An: ' + (a.to_addr || '?')) : (a.from_addr || a.subject || 'E-Mail');
} else {
titel = 'Gelöschte Bewerbung';
}
%>
<li>
<% if (a.bewerbung_id) { %>
<a href="/bewerbung/<%= a.bewerbung_id %>" class="flex items-start gap-3 px-6 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<% } else { %>
<div class="flex items-start gap-3 px-6 py-3">
<% } %>
<span class="mt-1.5 inline-block w-2.5 h-2.5 rounded-full shrink-0 <%= statusDot[a.status] || 'bg-gray-300' %>"></span>
<span class="mt-1.5 inline-block w-2.5 h-2.5 rounded-full shrink-0 <%= dotCls %>"></span>
<span class="min-w-0 flex-1">
<span class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<% if (a.bewerbung_id) { %>
<span class="text-sm font-medium text-gray-900 dark:text-white break-words"><%= a.firma || '(ohne Firma)' %></span>
<% if (a.stelle) { %>
<span class="text-sm text-gray-500 dark:text-gray-400 break-words">· <%= a.stelle %></span>
<% } %>
<% } else { %>
<span class="text-sm font-medium text-gray-500 dark:text-gray-400">Gelöschte Bewerbung</span>
<span class="text-sm font-medium <%= a.bewerbung_id ? 'text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400' %> break-words"><%= titel %></span>
<% if (titelSub) { %>
<span class="text-sm text-gray-500 dark:text-gray-400 break-words">· <%= titelSub %></span>
<% } %>
</span>
<span class="mt-0.5 flex flex-wrap items-baseline gap-x-2 text-sm">
<span class="font-medium text-gray-700 dark:text-gray-200"><%= a.status %></span>
<span class="text-gray-400 dark:text-gray-500 whitespace-nowrap">· <%= new Date(a.datum).toLocaleDateString('de-DE') %></span>
<% if (a.datum) { %>
<span class="text-gray-400 dark:text-gray-500 whitespace-nowrap">· <%= new Date(a.datum).toLocaleDateString('de-DE') %></span>
<% } %>
<% if (a.kommentar && a.kommentar.trim()) { %>
<span class="text-gray-500 dark:text-gray-400 break-words">· <%= a.kommentar %></span>
<% } %>
@@ -102,7 +114,7 @@
</ul>
<% } else { %>
<div class="px-6 py-16 text-center text-sm text-gray-500 dark:text-gray-400">
Noch keine Statusänderungen protokolliert.
Noch keine Aktivitäten — Statusänderungen und E-Mails erscheinen hier.
</div>
<% } %>
+562 -426
View File
File diff suppressed because it is too large Load Diff
+55 -92
View File
@@ -8,7 +8,6 @@
<main class="flex-1 container mx-auto px-4 py-8">
<% var zeigeDashboard = !(typeof setupWizard !== 'undefined' && setupWizard && setupWizard.aktiv); %>
<%- include('partials/favoriten') %>
<% if (!zeigeDashboard) { %>
<%- include('partials/einrichtung') %>
<% } %>
@@ -110,12 +109,15 @@
</div>
<% } %>
<!-- Gemerkte Bewerbungen — direkt unter den anstehenden Terminen -->
<%- include('partials/bewerbung-favoriten') %>
<!-- Applications -->
<style>
/* Shared column grid used by the header labels and every application line */
.app-grid {
display: grid;
grid-template-columns: 1.25rem 5.5rem minmax(7rem, 1.6fr) minmax(8rem, 2fr) 5.5rem 8.5rem 4.5rem;
grid-template-columns: 1.25rem 5.5rem minmax(7rem, 1.6fr) minmax(8rem, 2fr) 5.5rem 8.5rem 5.5rem;
gap: 0 0.75rem;
align-items: center;
}
@@ -143,7 +145,7 @@
// Status-Reihenfolge synchron zu public/js/main.js halten. Steht hier oben,
// weil sowohl die Liste als auch die Aktivitäten weiter unten sie brauchen.
const statusOrder = ['Entwurf', 'Gesendet', 'Eingangsbestätigung',
'In Bearbeitung', 'Interessiert', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
'In Bearbeitung', 'Interessiert', 'Telefonat', 'Warten auf Rückmeldung', 'Warten auf meine Antwort',
'Vorstellungsgespräch', 'Vertragsverhandlung', 'Einstellung', 'Absage', 'Absage von meiner Seite', 'Keine Rückmeldung'];
const statusDot = {
'Entwurf': 'bg-purple-500',
@@ -151,6 +153,7 @@
'Eingangsbestätigung': 'bg-blue-500',
'In Bearbeitung': 'bg-teal-500',
'Interessiert': 'bg-pink-500',
'Telefonat': 'bg-sky-500',
'Warten auf Rückmeldung': 'bg-orange-500',
'Warten auf meine Antwort': 'bg-fuchsia-500',
'Vorstellungsgespräch': 'bg-yellow-500',
@@ -210,22 +213,10 @@
<div class="group-body collapsible">
<div class="collapsible-inner">
<% groups[statusKey].forEach(app => { %>
<%
const hatNotizen = app.notizen && app.notizen.trim().length > 0;
const hatInterne = app.interne_notizen && app.interne_notizen.trim().length > 0;
const hatVerlauf = app.verlauf && app.verlauf.length > 0;
const hatDetails = hatNotizen || hatInterne || hatVerlauf;
%>
<div class="app-row border-t border-gray-100 dark:border-gray-700/60 <%= hatDetails ? 'has-details' : '' %>">
<!-- Eine Zeile pro Bewerbung -->
<div class="app-line app-grid px-6 py-3 text-sm transition-colors <%= hatDetails ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50' : '' %>">
<span class="flex items-center justify-center">
<% if (hatDetails) { %>
<svg class="chevron w-4 h-4 text-gray-400 dark:text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
<% } %>
</span>
<div class="app-row border-t border-gray-100 dark:border-gray-700/60">
<!-- Eine Zeile pro Bewerbung Klick öffnet die ganze Bewerbung -->
<div class="app-line app-grid px-6 py-3 text-sm transition-colors cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50" data-bewerbung="<%= app.id %>">
<span class="flex items-center justify-center"></span>
<span class="whitespace-nowrap text-gray-900 dark:text-white"><%= new Date(app.datum).toLocaleDateString('de-DE') %></span>
<span class="min-w-0 truncate font-medium text-gray-900 dark:text-white" title="<%= app.firma %>"><%= app.firma %></span>
<span class="min-w-0">
@@ -244,6 +235,7 @@
app.status === 'Vertragsverhandlung' ? 'bg-lime-100 text-lime-800 dark:bg-lime-800 dark:text-lime-100' :
app.status === 'In Bearbeitung' ? 'bg-teal-100 text-teal-800 dark:bg-teal-800 dark:text-teal-100' :
app.status === 'Interessiert' ? 'bg-pink-100 text-pink-800 dark:bg-pink-800 dark:text-pink-100' :
app.status === 'Telefonat' ? 'bg-sky-100 text-sky-800 dark:bg-sky-800 dark:text-sky-100' :
app.status === 'Warten auf Rückmeldung' ? 'bg-orange-100 text-orange-800 dark:bg-orange-800 dark:text-orange-100' :
app.status === 'Warten auf meine Antwort' ? 'bg-fuchsia-100 text-fuchsia-800 dark:bg-fuchsia-800 dark:text-fuchsia-100' :
app.status === 'Entwurf' ? 'bg-purple-100 text-purple-800 dark:bg-purple-800 dark:text-purple-100' :
@@ -252,6 +244,7 @@
</span>
</span>
<span class="app-actions flex items-center gap-3">
<%- include('partials/favorit-button', { j: app, endpoint: '/api/bewerbungen/' + app.id + '/favorit', compact: true }) %>
<a href="/bewerbung/<%= app.id %>"
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
aria-label="Bearbeiten">
@@ -268,58 +261,6 @@
</button>
</span>
</div>
<% if (hatDetails) { %>
<!-- Klappbarer Detailbereich unter der Zeile -->
<div class="app-details collapsible">
<div class="collapsible-inner">
<div class="px-6 pb-5 pt-1 space-y-3">
<% if (hatVerlauf) { %>
<div class="rounded-lg border-l-4 border-indigo-400 dark:border-indigo-500 bg-indigo-50/60 dark:bg-gray-700/40 px-4 py-3">
<div class="flex items-center gap-2 mb-2">
<svg class="w-4 h-4 text-indigo-500 dark:text-indigo-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span class="text-xs font-semibold uppercase tracking-wider text-indigo-600 dark:text-indigo-400">Status-Verlauf</span>
</div>
<ol class="space-y-1.5">
<% app.verlauf.forEach(v => { %>
<li class="flex flex-wrap gap-x-2 text-sm">
<span class="text-gray-500 dark:text-gray-400 whitespace-nowrap"><%= new Date(v.datum).toLocaleDateString('de-DE') %></span>
<span class="font-semibold text-gray-800 dark:text-gray-100"><%= v.status %></span>
<% if (v.kommentar && v.kommentar.trim()) { %>
<span class="text-gray-600 dark:text-gray-300 whitespace-pre-wrap break-words"> <%= v.kommentar %></span>
<% } %>
</li>
<% }); %>
</ol>
</div>
<% } %>
<% if (hatNotizen) { %>
<div class="rounded-lg border-l-4 border-blue-400 dark:border-blue-500 bg-blue-50/70 dark:bg-gray-700/40 px-4 py-3">
<div class="flex items-center gap-2 mb-2">
<svg class="w-4 h-4 text-blue-500 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<span class="text-xs font-semibold uppercase tracking-wider text-blue-600 dark:text-blue-400">Notizen</span>
</div>
<p class="whitespace-pre-wrap break-words text-base leading-relaxed text-gray-800 dark:text-gray-200"><%= app.notizen %></p>
</div>
<% } %>
<% if (hatInterne) { %>
<div class="rounded-lg border-l-4 border-amber-400 dark:border-amber-500 bg-amber-50/70 dark:bg-gray-700/40 px-4 py-3">
<div class="flex items-center gap-2 mb-2">
<svg class="w-4 h-4 text-amber-500 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
<span class="text-xs font-semibold uppercase tracking-wider text-amber-600 dark:text-amber-400">Interne Notizen · nicht im PDF</span>
</div>
<p class="whitespace-pre-wrap break-words text-base leading-relaxed text-gray-800 dark:text-gray-200"><%= app.interne_notizen %></p>
</div>
<% } %>
</div>
</div>
</div>
<% } %>
</div>
<% }); %>
</div>
@@ -347,27 +288,42 @@
<% if (aktivitaet && aktivitaet.length) { %>
<ul class="divide-y divide-gray-100 dark:divide-gray-700/60">
<% aktivitaet.forEach(function (a) { %>
<% aktivitaet.forEach(function (a) {
// E-Mail-Einträge bekommen einen eigenen Farbpunkt; bei Status-
// änderungen greift die Status-Farbpalette.
var dotCls = a.typ === 'email'
? (a.direction === 'out' ? 'bg-sky-500' : 'bg-blue-500')
: (statusDot[a.status] || 'bg-gray-300');
// Kopzeile: verknüpfte Bewerbung -> Firma·Stelle, sonst bei E-Mail
// der Absender/Empfänger, sonst Hinweis auf gelöschte Bewerbung.
var titel = '', titelSub = '';
if (a.bewerbung_id) {
titel = a.firma || '(ohne Firma)';
titelSub = a.stelle || '';
} else if (a.typ === 'email') {
titel = a.direction === 'out' ? ('An: ' + (a.to_addr || '?')) : (a.from_addr || a.subject || 'E-Mail');
titelSub = '';
} else {
titel = 'Gelöschte Bewerbung';
}
%>
<li>
<!-- Eintrag zu einer noch existierenden Bewerbung ist ein Link, einer zu
einer gelöschten bleibt ein Block. Bewusst zwei ausgeschriebene Zweige:
den Tag-Namen samt href per <%%= %> zusammenzusetzen escaped die
Anführungszeichen und erzeugt Links wie /%22/bewerbung/121%22. -->
einer gelöschten oder einer nicht zugeordneten E-Mail bleibt ein Block.
Bewusst zwei ausgeschriebene Zweige: den Tag-Namen samt href per <%%= %>
zusammenzusetzen escaped die Anführungszeichen und erzeugt Links wie
/%22/bewerbung/121%22. -->
<% if (a.bewerbung_id) { %>
<a href="/bewerbung/<%= a.bewerbung_id %>" class="flex items-start gap-3 px-6 py-3 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors">
<% } else { %>
<div class="flex items-start gap-3 px-6 py-3">
<% } %>
<span class="mt-1.5 inline-block w-2.5 h-2.5 rounded-full shrink-0 <%= statusDot[a.status] || 'bg-gray-300' %>"></span>
<span class="mt-1.5 inline-block w-2.5 h-2.5 rounded-full shrink-0 <%= dotCls %>"></span>
<span class="min-w-0 flex-1">
<span class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<% if (a.bewerbung_id) { %>
<span class="text-sm font-medium text-gray-900 dark:text-white break-words"><%= a.firma || '(ohne Firma)' %></span>
<% if (a.stelle) { %>
<span class="text-sm text-gray-500 dark:text-gray-400 break-words">· <%= a.stelle %></span>
<% } %>
<% } else { %>
<span class="text-sm font-medium text-gray-500 dark:text-gray-400">Gelöschte Bewerbung</span>
<span class="text-sm font-medium <%= a.bewerbung_id ? 'text-gray-900 dark:text-white' : 'text-gray-500 dark:text-gray-400' %> break-words"><%= titel %></span>
<% if (titelSub) { %>
<span class="text-sm text-gray-500 dark:text-gray-400 break-words">· <%= titelSub %></span>
<% } %>
</span>
<!-- Status, Datum und Kommentar in einer Zeile: ein rechtsbündiges
@@ -375,7 +331,9 @@
dort ohne Bezug stehen. -->
<span class="mt-0.5 flex flex-wrap items-baseline gap-x-2 text-sm">
<span class="font-medium text-gray-700 dark:text-gray-200"><%= a.status %></span>
<span class="text-gray-400 dark:text-gray-500 whitespace-nowrap">· <%= new Date(a.datum).toLocaleDateString('de-DE') %></span>
<% if (a.datum) { %>
<span class="text-gray-400 dark:text-gray-500 whitespace-nowrap">· <%= new Date(a.datum).toLocaleDateString('de-DE') %></span>
<% } %>
<% if (a.kommentar && a.kommentar.trim()) { %>
<span class="text-gray-500 dark:text-gray-400 break-words">· <%= a.kommentar %></span>
<% } %>
@@ -387,7 +345,7 @@
</ul>
<% } else { %>
<div class="px-6 py-10 text-center text-sm text-gray-500 dark:text-gray-400">
Noch keine Statusänderungen protokolliert.
Noch keine Aktivitäten — Statusänderungen und E-Mails erscheinen hier.
</div>
<% } %>
</section>
@@ -414,14 +372,13 @@
});
});
// Detailbereich einer einzelnen Bewerbung auf-/zuklappen.
qsa('.app-row.has-details').forEach(function (row) {
var line = row.querySelector('.app-line');
// Klick auf eine Bewerbungs-Zeile öffnet die ganze Bewerbung
// (/bewerbung/:id). Klicks auf die Aktions-Buttons (Merken,
// Bearbeiten, Löschen) werden nicht abgefangen.
qsa('.app-line[data-bewerbung]').forEach(function (line) {
line.addEventListener('click', function (e) {
if (e.target.closest('.app-actions')) return; // Bearbeiten/Löschen nicht abfangen
var details = row.querySelector('.app-details');
var chev = line.querySelector('.chevron');
setOpen(details, chev, !details.classList.contains('open'));
if (e.target.closest('.app-actions')) return;
window.location.href = '/bewerbung/' + line.getAttribute('data-bewerbung');
});
});
@@ -441,6 +398,7 @@
var fehler = document.getElementById('searchError');
var listCard = document.getElementById('listCard');
var termineCard = document.getElementById('termineCard');
var bewerbungFavoritenCard = document.getElementById('bewerbungFavoritenCard');
var aktivitaetCard = document.getElementById('aktivitaetCard');
var MIN = 2; // kürzere Eingaben liefern nur Rauschen
@@ -557,6 +515,7 @@
panel.classList.add('hidden');
if (listCard) listCard.classList.remove('hidden');
if (termineCard) termineCard.classList.remove('hidden');
if (bewerbungFavoritenCard) bewerbungFavoritenCard.classList.remove('hidden');
if (aktivitaetCard) aktivitaetCard.classList.remove('hidden');
input.setAttribute('aria-expanded', 'false');
aktiv = -1;
@@ -577,6 +536,7 @@
panel.classList.remove('hidden');
if (listCard) listCard.classList.add('hidden');
if (termineCard) termineCard.classList.add('hidden');
if (bewerbungFavoritenCard) bewerbungFavoritenCard.classList.add('hidden');
if (aktivitaetCard) aktivitaetCard.classList.add('hidden');
input.setAttribute('aria-expanded', 'true');
aktiv = -1;
@@ -591,6 +551,7 @@
panel.classList.remove('hidden');
if (listCard) listCard.classList.add('hidden');
if (termineCard) termineCard.classList.add('hidden');
if (bewerbungFavoritenCard) bewerbungFavoritenCard.classList.add('hidden');
if (aktivitaetCard) aktivitaetCard.classList.add('hidden');
}
@@ -666,6 +627,8 @@
<%- include('partials/footer') %>
<%- include('partials/favorit-script') %>
<!-- Add/Edit Application Modal -->
<div id="applicationModal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden">
<div class="flex items-center justify-center min-h-screen p-4">
+41 -19
View File
@@ -15,26 +15,35 @@
Zurück zur Übersicht
</a>
<!-- Tabs: open offers vs. taken-over offers -->
<div class="flex flex-wrap items-center gap-2 mb-6">
<a href="/jobangebote" class="px-3 py-1.5 rounded-md text-sm font-medium bg-blue-600 text-white shadow-sm">
Offen (<%= jobangebote.length %>)
</a>
<a href="/jobangebote/uebernommen" class="px-3 py-1.5 rounded-md text-sm font-medium bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
Übernommen (<%= uebernommenCount %>)
</a>
</div>
<!-- Tabs: open offers · favorites · taken-over offers -->
<% var favAnsicht = (typeof ansicht !== 'undefined' && ansicht === 'favoriten'); %>
<%- include('partials/jobangebote-tabs', {
aktiv: favAnsicht ? 'favoriten' : 'offen',
offenCount: offenCount,
favoritenCount: favoritenCount,
uebernommenCount: uebernommenCount,
}) %>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
<%- include('partials/favorit-script') %>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8" data-fav-card data-fav-view="<%= favAnsicht ? 'favoriten' : 'offen' %>">
<div class="flex flex-wrap items-start justify-between gap-3 mb-2">
<div>
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Jobangebote</h2>
<h2 class="text-lg font-semibold text-gray-800 dark:text-white"><%= favAnsicht ? 'Gemerkte Jobangebote' : 'Jobangebote' %></h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Hier landen Stellen, die von Drittanbietersoftware über die
<code class="px-1 py-0.5 rounded bg-gray-100 dark:bg-gray-700">POST /api/v1/joboffers</code>
REST-Schnittstelle eingespielt werden. Ein Angebot lässt sich per Klick
als Bewerbung-Entwurf übernehmen. Hier siehst du nur <strong>offene</strong>
Angebote — bereits übernommene findest du im Tab „Übernommen“.
<% if (favAnsicht) { %>
Angebote, die du dir mit dem Stern gemerkt hast — unabhängig davon, ob sie
noch offen oder bereits übernommen sind. Ein erneuter Klick auf den Stern
entfernt das Angebot wieder aus dieser Liste. Die Merkliste gilt nur für
Jobangebote und ist von den gemerkten Bewerbungen getrennt.
<% } else { %>
Hier landen Stellen, die von Drittanbietersoftware über die
<code class="px-1 py-0.5 rounded bg-gray-100 dark:bg-gray-700">POST /api/v1/joboffers</code>
REST-Schnittstelle eingespielt werden. Ein Angebot lässt sich per Klick
als Bewerbung-Entwurf übernehmen. Hier siehst du nur <strong>offene</strong>
Angebote, die du dir <em>nicht</em> gemerkt hast — bereits übernommene
findest du im Tab „Übernommen“, gemerkte im Tab „Gemerkt“.
<% } %>
</p>
</div>
<div class="shrink-0 flex items-center gap-2">
@@ -52,7 +61,9 @@
<% if (jobangebote && jobangebote.length) { %>
<ul class="mt-4 space-y-4">
<% jobangebote.forEach(function(j){ %>
<li class="rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40 px-4 py-4">
<li data-fav-item class="rounded-lg border px-4 py-4 <%= j.favorit
? 'border-amber-300 dark:border-amber-400/40 bg-amber-50/60 dark:bg-amber-500/5'
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40' %>">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2 mb-1">
@@ -158,6 +169,7 @@
<% } %>
<form action="/jobangebote/<%= j.id %>/delete" method="POST"
onsubmit="return confirm('Jobangebot „<%= j.firma %> · <%= j.stelle %>“ löschen? Es kommt auf die Blacklist und wird nicht erneut eingespielt.');">
<% if (favAnsicht) { %><input type="hidden" name="zurueck" value="favoriten"><% } %>
<button type="submit"
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs 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 w-full">
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
@@ -174,6 +186,7 @@
Beschreibung / Details bearbeiten
</summary>
<form action="/jobangebote/<%= j.id %>/bearbeiten" method="POST" class="mt-3 space-y-3">
<% if (favAnsicht) { %><input type="hidden" name="zurueck" value="favoriten"><% } %>
<div class="grid gap-3 sm:grid-cols-2">
<label class="block text-xs">
<span class="text-gray-500 dark:text-gray-400">Firma</span>
@@ -229,6 +242,17 @@
</li>
<% }); %>
</ul>
<% } else if (favAnsicht) { %>
<div class="text-center py-12">
<svg class="w-14 h-14 mx-auto text-gray-300 dark:text-gray-600 mb-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.5l2.6 5.27 5.82.85-4.21 4.1.99 5.79-5.2-2.73-5.2 2.73.99-5.79-4.21-4.1 5.82-.85 2.6-5.27z"></path>
</svg>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-1">Noch keine Jobangebote gemerkt.</p>
<p class="text-xs text-gray-400 dark:text-gray-500">
Klicke bei einem Angebot auf <strong>Merken</strong> — es erscheint dann hier
und wird aus der offenen Liste ausgeblendet.
</p>
</div>
<% } else { %>
<div class="text-center py-12">
<svg class="w-14 h-14 mx-auto text-gray-300 dark:text-gray-600 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -298,7 +322,5 @@
});
})();
</script>
<%- include('partials/favorit-script') %>
</body>
</html>
+12 -12
View File
@@ -15,15 +15,15 @@
Zurück zu den Jobangeboten
</a>
<!-- Tabs: open offers vs. taken-over offers -->
<div class="flex flex-wrap items-center gap-2 mb-6">
<a href="/jobangebote" class="px-3 py-1.5 rounded-md text-sm font-medium bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
Offen (<%= offenCount %>)
</a>
<a href="/jobangebote/uebernommen" class="px-3 py-1.5 rounded-md text-sm font-medium bg-blue-600 text-white shadow-sm">
Übernommen (<%= uebernommen.length %>)
</a>
</div>
<!-- Tabs: open offers · favorites · taken-over offers -->
<%- include('partials/jobangebote-tabs', {
aktiv: 'uebernommen',
offenCount: offenCount,
favoritenCount: favoritenCount,
uebernommenCount: uebernommenCount,
}) %>
<%- include('partials/favorit-script') %>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">
<div class="flex flex-wrap items-start justify-between gap-3 mb-2">
@@ -43,7 +43,9 @@
<% if (uebernommen && uebernommen.length) { %>
<ul class="mt-4 space-y-4">
<% uebernommen.forEach(function(j){ %>
<li class="rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40 px-4 py-4">
<li class="rounded-lg border px-4 py-4 <%= j.favorit
? 'border-amber-300 dark:border-amber-400/40 bg-amber-50/60 dark:bg-amber-500/5'
: 'border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-700/40' %>">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2 mb-1">
@@ -141,7 +143,5 @@
if (cy) cy.textContent = new Date().getFullYear();
})();
</script>
<%- include('partials/favorit-script') %>
</body>
</html>
+52 -22
View File
@@ -54,22 +54,6 @@
<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>
@@ -157,15 +141,35 @@
</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 items-start justify-between gap-4">
<div>
<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">
Ist er aktiv, sucht die KI automatisch an den gewählten Tagen zur eingestellten Uhrzeit
und legt neue Treffer als Jobangebote an. Deaktivierst du ihn, läuft keine automatische
Suche mehr — die eingestellten Wochentage und die Uhrzeit bleiben aber erhalten.
</p>
</div>
<label class="inline-flex items-center gap-2 shrink-0">
<input type="checkbox" name="aktiv" value="1" id="zeitplan_aktiv" <%= profil.aktiv ? 'checked' : '' %>
<%= !darfZeitplanAendern ? 'disabled' : '' %>
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 disabled:opacity-60">
<span class="text-sm text-gray-700 dark:text-gray-300">aktiv</span>
</label>
</div>
<% if (!darfZeitplanAendern) { %>
<p class="text-xs text-amber-700 dark:text-amber-300 mt-3">
Der Zeitplan (aktiv, Wochentage, Uhrzeit) wird vom Administrator verwaltet.
</p>
<% } %>
<div class="flex flex-wrap gap-2 mb-5">
<div id="zeitplan_felder" class="flex flex-wrap gap-2 mb-5 mt-5 <%= !profil.aktiv ? 'opacity-60' : '' %>">
<% 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">
<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 <%= darfZeitplanAendern ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/40' : 'opacity-70 cursor-not-allowed' %>">
<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">
<%= !darfZeitplanAendern ? 'disabled' : '' %>
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 disabled:opacity-60">
<span class="text-gray-700 dark:text-gray-300"><%= t.kurz %></span>
</label>
<% }); %>
@@ -173,7 +177,8 @@
<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">
<%= !darfZeitplanAendern ? 'disabled' : '' %>
class="rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm disabled:opacity-60">
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
Der Lauf startet innerhalb weniger Minuten nach dieser Uhrzeit.
</p>
@@ -203,6 +208,7 @@
<a href="/jobangebote" class="text-blue-600 dark:text-blue-400 hover:underline">Jobangebote</a> an.
</p>
</div>
<% if (darfSuchlaufStarten) { %>
<form action="/jobsuche/start" method="POST" class="shrink-0">
<% if (fremd) { %><input type="hidden" name="ziel_user" value="<%= zielUser.id %>"><% } %>
<button type="submit"
@@ -213,6 +219,11 @@
Jetzt suchen
</button>
</form>
<% } else if (!fremd) { %>
<p class="text-xs text-gray-400 dark:text-gray-500 shrink-0 text-right max-w-[12rem]">
Manuelle Suchläufe werden vom Administrator verwaltet.
</p>
<% } %>
</div>
<div id="laeufe">
@@ -307,6 +318,25 @@
}
});
})();
// Zeitplan aktiv/deaktiv: stimmt die Sichtbarkeit der Wochentage + Uhrzeit
// mit dem Hauptschalter ab. Die Felder werden nur abgedunkelt, nicht
// deaktiviert — so bleiben die eingestellten Wochentage erhalten, wenn der
// Zeitplan später wieder aktiviert wird. Ist der Schalter gesperrt (Admin
// verwaltet den Zeitplan), greift die serverseitige Vorgabe unverändert.
(function () {
const aktiv = document.getElementById('zeitplan_aktiv');
if (!aktiv || aktiv.disabled) return;
const felder = document.getElementById('zeitplan_felder');
const zeit = document.getElementById('zeitplan_zeit');
function sync() {
const aus = !aktiv.checked;
if (felder) felder.classList.toggle('opacity-60', aus);
if (zeit) zeit.classList.toggle('opacity-60', aus);
}
sync();
aktiv.addEventListener('change', sync);
})();
</script>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
<%
// Gemerkte Bewerbungen — Sektion auf der Startseite, direkt unter den
// anstehenden Terminen. Zeigt alle favorisierten Bewerbungen unabhängig von
// ihrem Status. Wird nur gerendert, wenn mindestens ein Favorit vorhanden ist.
//
// Platzsparend als Chip-Wolke: nur der Firmenname, nebeneinander (flex-wrap),
// jeder Chip ein Link zur Bewerbung. Status/Datum/Stelle entfallen hier — die
// Details stehen auf der Bewerbungsseite. Entmerken geht ebenfalls dort (Stern).
// Die id wird von der Such-Logik in index.ejs zum Ein-/Ausblenden verwendet.
%>
<% if (bewerbungFavoriten && bewerbungFavoriten.length) { %>
<section id="bewerbungFavoritenCard" class="bg-white dark:bg-gray-800/60 rounded-xl border border-gray-200/80 dark:border-gray-700/60 shadow-sm mb-8 overflow-hidden">
<div class="flex flex-wrap items-center gap-2 px-5 py-3.5 border-b border-gray-100 dark:border-gray-700/60">
<svg class="h-4 w-4 text-amber-400 dark:text-amber-500" viewBox="0 0 24 24" fill="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.5l2.6 5.27 5.82.85-4.21 4.1.99 5.79-5.2-2.73-5.2 2.73.99-5.79-4.21-4.1 5.82-.85 2.6-5.27z"/></svg>
<h2 class="text-sm font-semibold text-gray-700 dark:text-gray-200">Gemerkte Bewerbungen</h2>
<span class="ml-auto inline-flex items-center rounded-full bg-gray-100 dark:bg-gray-700/70 px-2 py-0.5 text-xs font-medium text-gray-500 dark:text-gray-400">
<%= bewerbungFavoriten.length %>
</span>
</div>
<div class="flex flex-wrap gap-2 p-5">
<% bewerbungFavoriten.forEach(function(b){ %>
<a href="/bewerbung/<%= b.id %>"
title="<%= b.firma %>"
class="gemerkt-chip inline-flex items-center max-w-[18rem] px-3 py-1.5 rounded-lg border border-gray-200 dark:border-gray-700/70 bg-gray-50 dark:bg-gray-700/30 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-white dark:hover:bg-gray-700/60 hover:border-gray-300 dark:hover:border-gray-600 hover:shadow-sm transition-all">
<span class="truncate"><%= b.firma %></span>
</a>
<% }); %>
</div>
</section>
<% } %>
+1 -1
View File
@@ -212,7 +212,7 @@
<label for="einr-model" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Modell</label>
<input type="text" id="einr-model" name="OLLAMA_MODEL"
class="mt-1 w-full rounded-md border border-gray-300 bg-white px-3 py-2 text-sm text-gray-800 dark:border-gray-600 dark:bg-gray-700 dark:text-white"
value="<%= setupWizard.ollamaModel || 'gpt-oss:120b' %>">
value="<%= setupWizard.ollamaModel || 'glm-5.2:cloud' %>">
</div>
<div>
<label for="einr-host" class="block text-xs font-medium text-gray-500 dark:text-gray-400">Host</label>
+17 -8
View File
@@ -1,16 +1,25 @@
<%
// Stern-Button zum Merken/Entfernen eines Jobangebots. Lokale `j` muss das
// Angebot (mindestens id + favorit) sein. Zustand wird über die Klasse
// .is-fav gesteuert (definiert in partials/favorit-script). Das Skript dort
// bedient Klicks per Delegation — also nur einmal pro Seite einbinden.
var favAktiv = !!(typeof j !== 'undefined' && j && j.favorit);
// Stern-Button zum Merken/Entmerken. Generisch einsetzbar für Jobangebote
// (Standard-Endpunkt /jobangebote/:id/favorit) und Bewerbungen — dann `endpoint`
// mitgeben. Entweder `j` (legacy, braucht id + favorit) oder direkt `id`/`favorit`.
// Zustand wird über die Klasse .is-fav gesteuert (definiert in favorit-script).
// Mit `compact` wird ein reines Icon-Button (für Listenzeilen). Das Skript aus
// partials/favorit-script bedient Klicks per Delegation — nur einmal pro Seite
// einbinden.
var favItem = (typeof item !== 'undefined' && item) ? item : (typeof j !== 'undefined' ? j : null);
var favId = (typeof id !== 'undefined' && id != null) ? id : (favItem && favItem.id);
var favAktiv = !!(typeof favorit !== 'undefined' ? favorit : (favItem && favItem.favorit));
var favEndpoint = (typeof endpoint !== 'undefined' && endpoint)
? endpoint
: ('/jobangebote/' + encodeURIComponent(favId) + '/favorit');
var favCompact = !!(typeof compact !== 'undefined' && compact);
%>
<button type="button" class="fav-star<%= favAktiv ? ' is-fav' : '' %> inline-flex items-center justify-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors"
data-favorit="<%= j.id %>" data-aktiv="<%= favAktiv ? 1 : 0 %>"
<button type="button" class="fav-star<%= favAktiv ? ' is-fav' : '' %> inline-flex items-center justify-center gap-1.5 rounded-md <%= favCompact ? 'p-1' : 'px-3 py-1.5 border' %> text-xs font-medium transition-colors"
data-favorit="<%= favId %>" data-aktiv="<%= favAktiv ? 1 : 0 %>" data-endpoint="<%= favEndpoint %>"
title="<%= favAktiv ? 'Favorit entfernen' : 'Als Favorit merken' %>"
aria-pressed="<%= favAktiv ? 'true' : 'false' %>">
<svg class="fav-ico h-4 w-4" viewBox="0 0 24 24" fill="<%= favAktiv ? 'currentColor' : 'none' %>" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.5l2.6 5.27 5.82.85-4.21 4.1.99 5.79-5.2-2.73-5.2 2.73.99-5.79-4.21-4.1 5.82-.85 2.6-5.27z"/>
</svg>
<span class="fav-label"><%= favAktiv ? 'Gemerkt' : 'Merken' %></span>
<% if (!favCompact) { %><span class="fav-label"><%= favAktiv ? 'Gemerkt' : 'Merken' %></span><% } else { %><span class="fav-label hidden"><%= favAktiv ? 'Gemerkt' : 'Merken' %></span><% } %>
</button>
+18 -7
View File
@@ -27,11 +27,12 @@
e.preventDefault();
var id = btn.getAttribute('data-favorit');
var aktiv = btn.getAttribute('data-aktiv') === '1';
var endpoint = btn.getAttribute('data-endpoint') || ('/jobangebote/' + encodeURIComponent(id) + '/favorit');
// Optimistisch umschalten, sofort sichtbar.
setzeBtn(btn, !aktiv);
fetch('/jobangebote/' + encodeURIComponent(id) + '/favorit', {
fetch(endpoint, {
method: 'POST',
headers: { 'X-Requested-With': 'fetch' }
})
@@ -39,9 +40,10 @@
.then(function (r) {
if (r.ok && r.json && r.json.success) {
setzeBtn(btn, !!r.json.favorit);
// Auf der Startseite: beim Entfernen die Zeile ausblenden
// und die ganze Sektion einräumen, wenn sie leer wird.
if (!r.json.favorit) entferneFavoritZeile(btn);
// Zeile ausblenden, sobald sie nicht mehr in die aktuelle
// Ansicht gehört: auf der Offen-Seite beim Merken (favorit=1),
// auf der Gemerkt-Seite / Startseite beim Entfernen (favorit=0).
entferneFavoritZeile(btn, !!r.json.favorit);
} else {
setzeBtn(btn, aktiv); // Server hat abgelehnt -> zurückrollen
if (r.json && r.json.error) console.warn('Favorit:', r.json.error);
@@ -61,11 +63,20 @@
if (lbl) lbl.textContent = aktiv ? 'Gemerkt' : 'Merken';
}
function entferneFavoritZeile(btn) {
function entferneFavoritZeile(btn, istFavorit) {
var item = btn.closest('[data-fav-item]');
if (!item) return; // nicht auf der Startseite -> nur optisch umschalten
var card = document.getElementById('favoritenCard');
if (!item) return; // keine ausblendbare Zeile -> nur optisch umschalten
var card = btn.closest('[data-fav-card]') || document.getElementById('favoritenCard');
// Offen-Seite: Zeile verschwindet beim Merken (favorit=1).
// Gemerkt-Seite / Startseite: Zeile verschwindet beim Entfernen (favorit=0).
var view = card && card.getAttribute('data-fav-view');
var entfernenBeiFavorit = (view === 'offen');
if (istFavorit !== entfernenBeiFavorit) return;
item.remove();
// Auf der Offen-Seite bleibt die Sektion samt Überschrift stehen —
// dort wird nur die einzelne Zeile entfernt. Auf der Gemerkt-Seite
// und Startseite räumen wir die leere Sektion ganz ein.
if (view === 'offen') return;
if (card && !card.querySelectorAll('[data-fav-item]').length) {
card.style.transition = 'opacity 0.2s ease';
card.style.opacity = '0';
-72
View File
@@ -1,72 +0,0 @@
<%
// Gemerkte Stellen — Sektion ganz oben auf der Startseite. Zeigt alle
// favorisierten Jobangebote unabhängig von ihrem Status (offen/uebernommen/…).
// Wird nur gerendert, wenn mindestens ein Favorit vorhanden ist.
function favStatusBadge(status) {
if (status === 'offen') return 'bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-200';
if (status === 'uebernommen') return 'bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-200';
if (status === 'abgelehnt') return 'bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-200';
return 'bg-gray-200 text-gray-700 dark:bg-gray-600 dark:text-gray-200';
}
function favStatusLabel(status) {
if (status === 'offen') return 'Offen';
if (status === 'uebernommen') return 'Übernommen';
if (status === 'abgelehnt') return 'Abgelehnt';
return status || '—';
}
%>
<% if (favoriten && favoriten.length) { %>
<%- include('favorit-script') %>
<section id="favoritenCard" class="bg-white dark:bg-gray-800 rounded-lg shadow-md mb-8 overflow-hidden">
<div class="flex flex-wrap items-center gap-2 px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<svg class="h-5 w-5 text-amber-500" viewBox="0 0 24 24" fill="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.5l2.6 5.27 5.82.85-4.21 4.1.99 5.79-5.2-2.73-5.2 2.73.99-5.79-4.21-4.1 5.82-.85 2.6-5.27z"/></svg>
<h2 class="text-lg font-semibold text-gray-800 dark:text-white">Gemerkte Stellen</h2>
<span class="ml-auto inline-flex items-center gap-1.5 rounded-full bg-amber-50 px-2.5 py-1 text-xs font-medium text-amber-700 dark:bg-amber-500/10 dark:text-amber-300">
<%= favoriten.length %>
</span>
</div>
<ul class="divide-y divide-gray-100 dark:divide-gray-700/60">
<% favoriten.forEach(function(j){ %>
<li data-fav-item class="flex flex-wrap items-start justify-between gap-3 px-6 py-4">
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2 mb-1">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium <%= favStatusBadge(j.status) %>"><%= favStatusLabel(j.status) %></span>
<% if (j.gehalt) { %>
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300"><%= j.gehalt %></span>
<% } %>
</div>
<% if (j.quelle_url) { %>
<a href="<%= safeUrl(j.quelle_url) %>" target="_blank" rel="noopener noreferrer"
class="text-base font-semibold text-gray-800 dark:text-gray-100 break-words hover:underline">
<%= j.firma %>
</a>
<% } else { %>
<p class="text-base font-semibold text-gray-800 dark:text-gray-100 break-words"><%= j.firma %></p>
<% } %>
<p class="text-sm text-gray-700 dark:text-gray-200 break-words"><%= j.stelle %></p>
<% if (j.ort) { %>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5"><%= j.ort %></p>
<% } %>
<% if (j.verknuepfte_bewerbung_id) { %>
<p class="mt-1.5">
<a href="/bewerbung/<%= j.verknuepfte_bewerbung_id %>" class="inline-flex items-center gap-1 text-xs text-blue-600 dark:text-blue-400 hover:underline">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 9l3 3m0 0l-3 3m3-3H8m13 0a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
Zur Bewerbung
</a>
</p>
<% } %>
</div>
<div class="shrink-0 flex flex-col gap-2 self-start items-end">
<% if (j.verknuepfte_bewerbung_id) { %>
<a href="/bewerbung/<%= j.verknuepfte_bewerbung_id %>"
class="inline-flex items-center justify-center gap-1.5 px-3 py-1.5 text-xs bg-blue-600 hover:bg-blue-700 text-white rounded-md transition-colors">
Bewerbung
</a>
<% } %>
<%- include('favorit-button', { j: j }) %>
</div>
</li>
<% }); %>
</ul>
</section>
<% } %>
+10
View File
@@ -3,6 +3,16 @@
<p class="text-xs text-gray-400 dark:text-slate-500">
NextJobs · &copy; nextjobs.cc <span id="currentYear"><%= new Date().getFullYear() %></span>
</p>
<p class="mt-2 text-xs">
<a href="/plugin/nextjobs-stellen-import.zip"
class="inline-flex items-center gap-1 text-gray-500 dark:text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 underline underline-offset-2"
title="Browser-Erweiterung (Chrome/Edge) herunterladen">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v2a2 2 0 002 2h12a2 2 0 002-2v-2M7 10l5 5m0 0l5-5m-5 5V4"></path>
</svg>
Browser-Plugin herunterladen
</a>
</p>
</div>
</footer>
+1 -1
View File
@@ -20,4 +20,4 @@
tailwind.config = { darkMode: 'class' };
</script>
<!-- Custom CSS -->
<link rel="stylesheet" href="/css/styles.css">
<link rel="stylesheet" href="/css/styles.css?v=<%= assetVersion %>">
+25
View File
@@ -0,0 +1,25 @@
<%
// Tab-Leiste der Jobangebote-Seiten. `aktiv` ist einer von 'offen',
// 'favoriten', 'uebernommen'. Der Favoriten-Tab zeigt gemerkte Angebote quer
// über alle Status und wird nur eingeblendet, wenn es Favoriten gibt (oder er
// gerade offen ist) — leere Tabs braucht niemand.
var tabs = [
{ key: 'offen', href: '/jobangebote', label: 'Offen', count: offenCount },
{ key: 'favoriten', href: '/jobangebote/favoriten', label: 'Gemerkt', count: favoritenCount, stern: true },
{ key: 'uebernommen', href: '/jobangebote/uebernommen', label: 'Übernommen', count: uebernommenCount },
].filter(function (t) { return t.key !== 'favoriten' || t.count > 0 || aktiv === 'favoriten'; });
%>
<div class="flex flex-wrap items-center gap-2 mb-6">
<% tabs.forEach(function (t) { %>
<a href="<%= t.href %>"
<%= aktiv === t.key ? 'aria-current="page"' : '' %>
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors <%= aktiv === t.key
? 'bg-blue-600 text-white shadow-sm'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700' %>">
<% if (t.stern) { %>
<svg class="h-4 w-4 <%= aktiv === t.key ? 'text-white' : 'text-amber-500' %>" viewBox="0 0 24 24" fill="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M11.48 3.5l2.6 5.27 5.82.85-4.21 4.1.99 5.79-5.2-2.73-5.2 2.73.99-5.79-4.21-4.1 5.82-.85 2.6-5.27z"/></svg>
<% } %>
<%= t.label %> (<%= t.count %>)
</a>
<% }); %>
</div>
+1 -1
View File
@@ -57,7 +57,7 @@
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-600 text-white">Empfangen</span>
<span class="text-sm text-gray-700 dark:text-gray-200 truncate">Von: <%= e.from_addr || '(unbekannt)' %></span>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400 shrink-0"><%= e.email_date ? new Date(e.email_date).toLocaleString('de-DE') : '' %></span>
<span class="text-xs text-gray-500 dark:text-gray-400 shrink-0"><%= e.email_date ? new Date(e.email_date).toLocaleString('de-DE', { timeZone: 'Europe/Berlin' }) : '' %></span>
</div>
<p class="text-sm font-medium text-gray-800 dark:text-gray-100 mb-1"><%= e.subject || '(kein Betreff)' %></p>
<% if (e.display_srcdoc) { %>
+31
View File
@@ -48,7 +48,38 @@
var val = werte[f.key] != null ? werte[f.key] : '';
var inputType = f.secret ? 'password' : 'text';
var inputClass = 'w-full rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm pr-10';
var gewaehlt = String(val).split(',').map(function(s){ return s.trim(); });
// Feste Optionen aus der Feld-Definition oder bei „optionsFrom“
// die zur Renderzeit geladenen Listen (z. B. die eigenen Anlagen).
var optionen = f.options || (f.optionsFrom ? (auswahllisten[f.optionsFrom] || []) : []);
%>
<% if (f.type === 'checkboxes') { %>
<div class="sm:col-span-2">
<span class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<%= f.label %>
<code class="ml-1 text-[11px] font-normal text-gray-400 dark:text-gray-500"><%= f.key %></code>
</span>
<!-- Leerer Wert, damit „nichts angehakt“ auch gespeichert wird. -->
<input type="hidden" name="<%= f.key %>" value="">
<% if (!optionen.length) { %>
<p class="text-sm text-gray-400 dark:text-gray-500"><%= f.leerHinweis || 'Keine Auswahl verfügbar.' %></p>
<% } else { %>
<div class="flex flex-wrap gap-x-6 gap-y-2">
<% optionen.forEach(function(o){ %>
<label class="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input type="checkbox" name="<%= f.key %>" value="<%= o.value %>"
<%= gewaehlt.includes(String(o.value)) ? 'checked' : '' %>
class="rounded border-gray-300 dark:border-gray-600 text-blue-600">
<%= o.label %>
</label>
<% }); %>
</div>
<% } %>
<% if (f.help) { %>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2"><%= f.help %></p>
<% } %>
</div>
<% return; } %>
<div class="<%= f.secret ? 'sm:col-span-2' : '' %>">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1" for="cfg-<%= f.key %>">
<%= f.label %>
+18 -11
View File
@@ -238,7 +238,7 @@
const maxS = Math.max(...byStatus.map(s => s.count));
const statusColor = (s) => ({
'Entwurf':'#94a3b8','Gesendet':'#3b82f6','Eingangsbestätigung':'#0ea5e9','In Bearbeitung':'#6366f1',
'Interessiert':'#8b5cf6','Warten auf Rückmeldung':'#a3a3a3','Warten auf meine Antwort':'#a3a3a3',
'Interessiert':'#8b5cf6','Telefonat':'#f472b6','Warten auf Rückmeldung':'#a3a3a3','Warten auf meine Antwort':'#a3a3a3',
'Vorstellungsgespräch':'#10b981','Vertragsverhandlung':'#84cc16','Absage':'#f43f5e','Absage von meiner Seite':'#f97316',
'Einstellung':'#059669','Keine Rückmeldung':'#cbd5e1'
}[s] || '#2563eb');
@@ -309,39 +309,46 @@
<div class="stat-card">
<h2 id="aktivitaet" class="text-gray-900 dark:text-white">Letzte Aktivität</h2>
<p class="sub mb-5">
Die jüngsten Statusänderungen deiner Bewerbungen.
Die jüngsten Statusänderungen und E-Mails deiner Bewerbungen.
<a href="/aktivitaeten" class="text-blue-600 dark:text-sky-400 hover:underline">Alle</a>
</p>
<% if (aktivitaet.length) {
const statusColor = (s) => ({
'Entwurf':'#94a3b8','Gesendet':'#3b82f6','Eingangsbestätigung':'#0ea5e9','In Bearbeitung':'#6366f1',
'Interessiert':'#8b5cf6','Warten auf Rückmeldung':'#a3a3a3','Warten auf meine Antwort':'#a3a3a3',
'Interessiert':'#8b5cf6','Telefonat':'#f472b6','Warten auf Rückmeldung':'#a3a3a3','Warten auf meine Antwort':'#a3a3a3',
'Vorstellungsgespräch':'#10b981','Vertragsverhandlung':'#84cc16','Absage':'#f43f5e','Absage von meiner Seite':'#f97316',
'Einstellung':'#059669','Keine Rückmeldung':'#cbd5e1'
}[s] || '#2563eb');
const emailColor = (a) => a.direction === 'out' ? '#0ea5e9' : '#3b82f6';
%>
<div class="tl">
<% aktivitaet.forEach(a => { %>
<div class="tl-item" style="--stat-accent:<%= statusColor(a.status) %>">
<% aktivitaet.forEach(a => {
const accent = a.typ === 'email' ? emailColor(a) : statusColor(a.status);
let titel = '', titelSub = '';
if (a.bewerbung_id) { titel = a.firma || '(ohne Firma)'; titelSub = a.stelle || ''; }
else if (a.typ === 'email') { titel = a.direction === 'out' ? ('An: ' + (a.to_addr || '?')) : (a.from_addr || a.subject || 'E-Mail'); }
else { titel = 'Gelöschte Bewerbung'; }
%>
<div class="tl-item" style="--stat-accent:<%= accent %>">
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<% if (a.bewerbung_id) { %>
<a href="/bewerbung/<%= a.bewerbung_id %>" class="text-sm font-medium text-gray-900 dark:text-white hover:underline"><%= a.firma || '(ohne Firma)' %></a>
<span class="text-sm text-gray-500 dark:text-slate-400">· <%= a.stelle || '' %></span>
<a href="/bewerbung/<%= a.bewerbung_id %>" class="text-sm font-medium text-gray-900 dark:text-white hover:underline"><%= titel %></a>
<% if (titelSub) { %><span class="text-sm text-gray-500 dark:text-slate-400">· <%= titelSub %></span><% } %>
<% } else { %>
<span class="text-sm font-medium text-gray-700 dark:text-gray-200">Gelöschte Bewerbung</span>
<span class="text-sm font-medium text-gray-700 dark:text-gray-200"><%= titel %></span>
<% } %>
</div>
<div class="mt-0.5 flex flex-wrap items-center gap-x-2 text-sm">
<span class="inline-flex items-center gap-1.5 font-medium" style="color:<%= statusColor(a.status) %>">
<span class="inline-flex items-center gap-1.5 font-medium" style="color:<%= accent %>">
<span class="swatch" style="margin:0"></span><%= a.status %>
</span>
<span class="text-gray-400 dark:text-slate-500">· <%= a.datum %></span>
<% if (a.datum) { %><span class="text-gray-400 dark:text-slate-500">· <%= a.datum %></span><% } %>
<% if (a.kommentar) { %><span class="text-gray-500 dark:text-slate-400 truncate">· <%= a.kommentar %></span><% } %>
</div>
</div>
<% }); %>
</div>
<% } else { %><div class="stat-empty">Noch keine Statusänderungen protokolliert.</div><% } %>
<% } else { %><div class="stat-empty">Noch keine Aktivitäten — Statusänderungen und E-Mails erscheinen hier.</div><% } %>
</div>
<% } %>
+16 -1
View File
@@ -15,6 +15,14 @@
Zurück zur Übersicht
</a>
<!-- Abgewiesener Upload (zu groß, falscher Dateityp): ohne diesen Banner
käme die Seite kommentarlos ohne die Datei zurück. -->
<% if (typeof fehler !== 'undefined' && 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" role="alert">
<p class="text-sm text-red-800 dark:text-red-200"><%= fehler %></p>
</div>
<% } %>
<h1 class="text-2xl font-bold text-gray-800 dark:text-white mb-2">Basis-Unterlagen (Vorlagen)</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">
Diese Unterlagen dienen der KI als Faktengrundlage. Beim Import einer Stelle über die Browser-Erweiterung
@@ -240,7 +248,9 @@
<form action="/anlagen" method="POST" enctype="multipart/form-data" class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-4 space-y-3">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Datei (PDF)</label>
<label class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">
Datei (PDF) &middot; max. <%= typeof anhangMaxMb !== 'undefined' ? anhangMaxMb : 15 %> MB
</label>
<input type="file" name="datei" accept="application/pdf,image/*" required
class="w-full text-sm text-gray-700 dark:text-gray-300 file:mr-3 file:py-2 file:px-3 file:rounded-md file:border-0 file:bg-blue-600 file:text-white hover:file:bg-blue-700">
</div>
@@ -340,6 +350,11 @@
<p><strong>Klassisch mit Sidebar</strong> zurückhaltend, monochrom, für klassische Bewerbungen (Standard).</p>
<p class="mt-1"><strong>Social Media</strong> verspielt: getönte Seite, runde Karten, Herzchen, Schreibschrift.
Bewusst auffällig passend für kreative und Social-Media-Stellen, nicht für die Bank.</p>
<p class="mt-1"><strong>Sunny</strong> plakativ: weiße Seite, sehr große Überschriften, pastellige Farbbänder,
ein Foto, das aus dem Kopfband ragt. Auffällig, aber aufgeräumt für Marketing, Medien und Design.</p>
<p class="mt-1"><strong>Tupfen</strong> dezent mit Augenzwinkern: weiße Seite, Name groß und mittig, luftiger
Blocksatz und verstreute Punkte in den Rändern als einzige Farbe. Der Lebenslauf greift sie als
Abschnittsmarker und Sprach-Skala wieder auf. Passt überall, wirkt trotzdem nicht wie von der Stange.</p>
</div>
<section class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-8">