diff --git a/agent/Dockerfile b/agent/Dockerfile new file mode 100644 index 0000000..3bacd44 --- /dev/null +++ b/agent/Dockerfile @@ -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"] \ No newline at end of file diff --git a/agent/entrypoint.sh b/agent/entrypoint.sh new file mode 100644 index 0000000..1a86790 --- /dev/null +++ b/agent/entrypoint.sh @@ -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 "$@" \ No newline at end of file diff --git a/agent/skills/bewerbungs-tracker/SKILL.md b/agent/skills/bewerbungs-tracker/SKILL.md new file mode 100644 index 0000000..be0c0ea --- /dev/null +++ b/agent/skills/bewerbungs-tracker/SKILL.md @@ -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: ` 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=` + (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 + `` 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`(01–12), `year`(YYYY), `status`, `art`, `label` (`Regional`\|`Remote-Deutschlandweit`\|`Homeoffice-Deutschlandweit`), `search` (Firma/Stelle), `limit`(1–500, 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`. diff --git a/agent/skills/bewerbungs-tracker/scripts/bt.sh b/agent/skills/bewerbungs-tracker/scripts/bt.sh new file mode 100755 index 0000000..aa2dc00 --- /dev/null +++ b/agent/skills/bewerbungs-tracker/scripts/bt.sh @@ -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\n') + +if [[ -n "$BODY" ]]; then + args+=(-H "Content-Type: application/json" --data-binary "$BODY") +fi + +curl "${args[@]}" "$url" diff --git a/agent/skills/it-stellensuche-remote/SKILL.md b/agent/skills/it-stellensuche-remote/SKILL.md new file mode 100644 index 0000000..7504c60 --- /dev/null +++ b/agent/skills/it-stellensuche-remote/SKILL.md @@ -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. „2–3 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 ", 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_slugfirmastelleortquelle_urlexternal_id + scripts/blacklist-set.sh # idtypfirmastelleortdomainurl_normfirma_normfirma_slugstelle_normort_normgrund + ``` + - **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 "" "" 0 30 100 ho + # -> firma_slugfirmatitelortdatumrefnrangebotsart + ``` + 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 + ``` + 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 " Karriere remote"` bzw. `" 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 + (3–7). 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` „ Karriere Kontakt Bewerbung E-Mail", `WebFetch` der Seite); + (c) allgemeine Bewerbungsadresse (`bewerbung@`/`jobs@`/`karriere@`) 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` „ 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--", + // 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": "", + "gehalt": "…", + "beschreibung": "", + // 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": "", + "art": "Firmenwebsite", + "anzeige_datum": "YYYY-MM-DD", + "kontakt_email": "", + "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": "", + "grund": "auto: stellensuche-remote-import " + }' + ``` + 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`. diff --git a/agent/skills/it-stellensuche-remote/scripts/applied-set.sh b/agent/skills/it-stellensuche-remote/scripts/applied-set.sh new file mode 120000 index 0000000..bfc868a --- /dev/null +++ b/agent/skills/it-stellensuche-remote/scripts/applied-set.sh @@ -0,0 +1 @@ +../../it-stellensuche/scripts/applied-set.sh \ No newline at end of file diff --git a/agent/skills/it-stellensuche-remote/scripts/arbeitsagentur.sh b/agent/skills/it-stellensuche-remote/scripts/arbeitsagentur.sh new file mode 120000 index 0000000..e781b49 --- /dev/null +++ b/agent/skills/it-stellensuche-remote/scripts/arbeitsagentur.sh @@ -0,0 +1 @@ +../../it-stellensuche/scripts/arbeitsagentur.sh \ No newline at end of file diff --git a/agent/skills/it-stellensuche-remote/scripts/blacklist-set.sh b/agent/skills/it-stellensuche-remote/scripts/blacklist-set.sh new file mode 120000 index 0000000..7375d37 --- /dev/null +++ b/agent/skills/it-stellensuche-remote/scripts/blacklist-set.sh @@ -0,0 +1 @@ +../../it-stellensuche/scripts/blacklist-set.sh \ No newline at end of file diff --git a/agent/skills/it-stellensuche/SKILL.md b/agent/skills/it-stellensuche/SKILL.md new file mode 100644 index 0000000..9c9d0a1 --- /dev/null +++ b/agent/skills/it-stellensuche/SKILL.md @@ -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_slugfirmastelleortquelle_urlexternal_id + scripts/blacklist-set.sh # idtypfirmastelleortdomainurl_normfirma_normfirma_slugstelle_normort_normgrund + ``` + - **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 "" "" 15 30 50 + # -> firma_slugfirmatitelortdatumrefnrangebotsart + ``` + Rollen aus dem Suchprofil durchrotieren (Systemadministrator, IT-Administrator, + Fachinformatiker Systemintegration, IT-Support, IT-Techniker, System Engineer, + Netzwerkadministrator, …). Städte: **Gladbeck** deckt per Umkreis 15–20 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 + ``` + 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 10–15 verschiedene Queries ab statt 1–2. + + **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 `, + `IT-Systembetreuer `, `Cloud Administrator `, `Linux Administrator `, + `Netzwerkadministrator `, `IT-Consultant `, `IT-Support `. + - **Buzzword × Ort** (holt Anzeigen, die im Titel anders heißen, inhaltlich aber + passen — s. Skills/Buzzwords oben): `Active Directory Stellenangebot`, + `Microsoft 365 Administrator `, `VMware IT Job`, `Proxmox `, + `Hyper-V Administrator `, `Veeam IT`, `PowerShell Administrator `. + - **Deutsche Jobbörsen zusätzlich anzapfen** (breitere Abdeckung, jeweils als + eigene, einfache Query — nicht stapeln): ` meinestadt`, + ` kimeta`, ` yourfirm`, ` arbeitsagentur`, + ` indeed`. Falls doch ein `site:`-Filter, dann **einzeln**: + `site:arbeitsagentur.de Systemadministrator `, `site:de.indeed.com + IT-Administrator `, `site:de.linkedin.com/jobs Systemadministrator `. + **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 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 " Karriere"` bzw. `" 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..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 + (3–7). 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` „ Karriere Kontakt Bewerbung E-Mail“, `WebFetch` der Seite); + (c) allgemeine Bewerbungsadresse (`bewerbung@`/`jobs@`/`karriere@`) 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` „ Impressum Adresse “, `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--", + // 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": "", + "gehalt": "…", + "beschreibung": "", + // 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": "", + "art": "Firmenwebsite", + "anzeige_datum": "YYYY-MM-DD", + "kontakt_email": "", + "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": "", + "grund": "auto: stellensuche-import " + }' + ``` + 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). diff --git a/agent/skills/it-stellensuche/scripts/applied-set.sh b/agent/skills/it-stellensuche/scripts/applied-set.sh new file mode 100755 index 0000000..cb1541f --- /dev/null +++ b/agent/skills/it-stellensuche/scripts/applied-set.sh @@ -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 firma stelle ort quelle_url 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(' 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 diff --git a/agent/skills/it-stellensuche/scripts/arbeitsagentur.sh b/agent/skills/it-stellensuche/scripts/arbeitsagentur.sh new file mode 100755 index 0000000..7a3f7a0 --- /dev/null +++ b/agent/skills/it-stellensuche/scripts/arbeitsagentur.sh @@ -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 [umkreis_km] [tage] [size] [arbeitszeit] +# Lists postings for role around location . One API call. +# umkreis_km default 15, tage (veröffentlicht seit) default 30, size default 50. +# 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 firma titel ort datum refnr 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 +# 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: 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: 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 diff --git a/agent/skills/it-stellensuche/scripts/blacklist-set.sh b/agent/skills/it-stellensuche/scripts/blacklist-set.sh new file mode 100755 index 0000000..2514b8b --- /dev/null +++ b/agent/skills/it-stellensuche/scripts/blacklist-set.sh @@ -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 typ firma stelle ort domain url_norm firma_norm firma_slug stelle_norm ort_norm 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(' 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(); @@ -324,4 +378,4 @@ async function main() { main().catch((e) => { console.error('Runner-Fehler:', e); process.exit(1); -}); +}); \ No newline at end of file diff --git a/scripts/jobsuche-runner.sh b/scripts/jobsuche-runner.sh index 799dacc..0f5d353 100755 --- a/scripts/jobsuche-runner.sh +++ b/scripts/jobsuche-runner.sh @@ -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=$?