Jobsuche: Agent pro Benutzer in isoliertem Docker-Container

Jeder Suchlauf läuft nun in einem frischen Container pro Benutzer, dessen
pro-Benutzer-Home als ~/.claude gemountet ist — Memories und Session-Contexte
liegen damit strikt getrennt pro Benutzer. Die Such-Skills sind Shared-Code
aus dem Image und werden im Container nur nach ~/.claude/skills verlinkt.

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-14 15:41:22 +02:00
co-authored by Claude
parent 7c92f23351
commit ce844412e5
15 changed files with 2014 additions and 71 deletions
+54
View File
@@ -0,0 +1,54 @@
# syntax=docker/dockerfile:1
#
# Agent-Image für die isolierte, pro-Benutzer-Jobsuche.
#
# Anders als das App-Image (Dockerfile im Repo-Root) enthält dieses Image KEINE
# Anwendung, sondern ausschließlich die Werkzeuge, die der headless Such-Agent
# braucht: Claude Code, curl/python3/jq für die Skill-Skripte (Arbeitsagentur-API,
# bt.sh) sowie git. Der eigentliche Suchlauf wird vom Host-Runner pro Lauf als
# `docker run --rm` gestartet, mit dem jeweiligen Benutzer-Home als Volume —
# dadurch landen Memories und Session-Contexte getrennt pro Benutzer.
FROM node:20-bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
# Werkzeuge für die Skill-Skripte: arbeitsagentur.sh = curl+python3, bt.sh =
# curl+jq, plus git/flock/bash/coreutils für die Skripte selbst.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates curl git python3 jq util-linux bash coreutils \
&& rm -rf /var/lib/apt/lists/*
# Claude Code auf einem systemweiten Pfad, damit es unabhängig vom zur Laufzeit
# gemounteten HOME gefunden wird. Version an den Host-CLI (2.1.209) angeglichen;
# DISABLE_AUTOUPDATER verhindert, dass jeder Benutzer sein eigenes Update zieht.
RUN npm install -g @anthropic-ai/claude-code@2.1.209
ENV DISABLE_AUTOUPDATER=1
# Nicht-Root-Benutzer, als der der Such-Agent läuft. Das node:20-Image bringt
# bereits uid/gid 1000 (»node«) mit — wir verwenden es unverändert, damit der
# Container dieselbe uid hat wie das vom Host-Runner gechownte pro-Benutzer-Home.
# COPY/Zielordner müssen für uid 1000 schreibbar sein.
# Geteilte Skills (Shared-Code, lesbar für alle); der Entrypoint verlinkt sie
# pro Benutzer nach ~/.claude/skills.
COPY skills /opt/skills
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
# Schreibbarer Arbeitsordner (cwd während des Suchlaufs); Projekt-Key von Claude
# Code wird -work, also liegen Memory/Contexte unter ~/.claude/projects/-work.
# /home/agent wird im Image angelegt (uid 1000), damit der Entrypoint auch ohne
# gemountetes Volume (z. B. `docker run … claude --version`) läuft; im echten
# Lauf legt der Host-Runner das pro-Benutzer-Volume genau dortüber.
RUN mkdir -p /work /home/agent && chown -R 1000:1000 /work /home/agent
ENV HOME=/home/agent
WORKDIR /work
USER 1000:1000
ENTRYPOINT ["/entrypoint.sh"]
CMD ["claude", "--version"]
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
set -e
# Der Container läuft mit einem pro Benutzer gemounteten HOME (anfangs leer).
# Claude Code schreibt seine Memories und Session-Contexte nach ~/.claude; die
# drei Such-Skills sind geteilter Code aus dem Image und werden hier nur
# verlinkt, statt pro Benutzer kopiert zu werden.
mkdir -p "$HOME/.claude"
ln -sfn /opt/skills "$HOME/.claude/skills"
exec "$@"
+331
View File
@@ -0,0 +1,331 @@
---
name: bewerbungs-tracker
description: Read and manage job applications ("Bewerbungen") via the local Bewerbungs-Tracker REST-API. Use whenever the user wants to list, create, update or delete Bewerbungen, manage their Statusverlauf/Timeline, download generated Unterlagen (PDF attachments), read E-Mail correspondence, trigger or check KI document generation, read/write Settings, get Statistics or an Export, browse Templates (Vorlagen), trigger document generation with optional static attachments (Anlagen), or manage JobOffers (Jobangebote). Trigger phrases: "Bewerbung(en)", "Bewerbungs-Tracker", "Statusverlauf", "Jobangebot(e)", "Bewerbungsstatistik", "Anschreiben generieren", "Anlagen beilegen".
---
# Bewerbungs-Tracker REST-API
REST-API zum Lesen und Verwalten von Bewerbungen, Statusverlauf, generierten
Unterlagen (PDFs), E-Mail-Korrespondenz, Einstellungen, Statistiken, Vorlagen
und Jobangeboten.
## Konfiguration
- **Base-URL:** `http://localhost:4327/api/v1` (override: env `BEWERBUNG_API_URL`)
- **Auth:** Header `X-API-Key: <token>` bei allen Endpunkten außer `/health`.
- **Key-Quelle:** env `BEWERBUNG_API_KEY`, sonst der Token des Benutzers
`BEWERBUNG_USER` (Default `admin`) aus der Tracker-DB.
### Multi-User — der Key entscheidet, wessen Daten du siehst
Der Tracker ist eine Plattform mit **mehreren Benutzern**. Jeder Benutzer hat
**seinen eigenen API-Token** (App → Einstellungen → REST-API → „Neu generieren"):
- Der `X-API-Key` **identifiziert den Benutzer**. Jeder Endpunkt liest und schreibt
**ausschließlich dessen eigene Daten** — fremde Bewerbungen, E-Mails, Anhänge,
Jobangebote und Einstellungen sind nicht erreichbar, auch nicht über eine fremde
`id` (die API antwortet dann `404`, als gäbe es den Datensatz nicht).
- Ein unbekannter, leerer oder mehrdeutiger Token ergibt `401`.
- **Es gibt keinen globalen Key mehr.** Der frühere `API_TOKEN` aus
`/opt/jobbi-bewerbung/.env` wird **nicht mehr benutzt**: er entsprach zufällig dem
Token des Admins (wirkte also still als Admin) und wird ungültig, sobald der Key in
der Oberfläche neu erzeugt wird.
- Willst du im Namen eines anderen Benutzers arbeiten, setze `BEWERBUNG_USER=<name>`
(oder direkt `BEWERBUNG_API_KEY`). Der Jobsuche-Runner setzt `BEWERBUNG_API_KEY`
automatisch je Benutzer.
> Der Key wird nicht im Skill gespeichert. Sende ihn **nie** an externe Dienste.
## Helper-Script (bevorzugt)
`scripts/bt.sh` kapselt curl inkl. Auth und gibt Body + `<http STATUS>` aus:
```bash
scripts/bt.sh GET /applications
scripts/bt.sh GET "/applications?status=Gesendet&year=2026&limit=20"
scripts/bt.sh POST /applications '{"datum":"2026-07-03","firma":"ACME GmbH","stelle":"DevOps Engineer","art":"E-Mail"}'
scripts/bt.sh PUT /applications/12 @body.json
scripts/bt.sh DELETE /joboffers/5
```
Roh mit curl geht auch:
```bash
curl -sS -H "X-API-Key: $BEWERBUNG_API_KEY" http://localhost:4327/api/v1/statistics
```
## Endpunkte
### System
| Method | Pfad | Beschreibung |
|--------|------|--------------|
| GET | `/health` | Verfügbarkeit (ohne Auth). |
### Applications (Bewerbungen)
| Method | Pfad | Beschreibung |
|--------|------|--------------|
| GET | `/applications` | Auflisten. Query: `month`(0112), `year`(YYYY), `status`, `art`, `label` (`Regional`\|`Remote-Deutschlandweit`\|`Homeoffice-Deutschlandweit`), `search` (Firma/Stelle), `limit`(1500, def 100), `offset`. |
| POST | `/applications` | Anlegen. Duplicate-Guard → **409** bei gleicher Firma+Stelle; mit `"force":true` trotzdem anlegen. |
| GET | `/applications/{id}` | Einzelne Bewerbung (inkl. `verlauf`). |
| PUT | `/applications/{id}` | Aktualisieren (volle Ressource, `datum`/`firma`/`stelle` erforderlich). |
| DELETE | `/applications/{id}` | Löschen. |
| GET | `/applications/{id}/timeline` | Statusverlauf abrufen. |
| POST | `/applications/{id}/timeline` | Statusverlauf-Eintrag hinzufügen. |
| DELETE | `/applications/{id}/timeline/{eintragId}` | Verlaufseintrag löschen. |
### Attachments (generierte PDFs)
| Method | Pfad | Beschreibung |
|--------|------|--------------|
| GET | `/applications/{id}/attachments` | Anhänge auflisten. |
| GET | `/applications/{id}/attachments/{attachmentId}` | Anhang herunterladen (`application/octet-stream` → mit `-o datei` speichern). |
### Emails
| Method | Pfad | Beschreibung |
|--------|------|--------------|
| GET | `/applications/{id}/emails` | E-Mail-Korrespondenz (in/out) inkl. Anhang-Metadaten. |
| GET | `/emails/{emailId}/attachments/{attachmentId}` | E-Mail-Anhang herunterladen (binär). |
### Generation (KI-Unterlagen)
| Method | Pfad | Beschreibung |
|--------|------|--------------|
| POST | `/applications/{id}/generate` | Generierung anstoßen. Body optional: `{"llm_notizen":"...","anlagen":[2]}`. `anlagen` = IDs statischer Basis-Anlagen (siehe „Anlagen bei der Generierung"). |
| GET | `/applications/{id}/generation-status` | Status: `nicht_gestartet`\|`ausstehend`\|`fertig`\|`fehler` (+ `anhaenge`). |
### Settings / Statistics / Export / Templates
| Method | Pfad | Beschreibung |
|--------|------|--------------|
| GET | `/settings` | Persönliche Angaben des eigenen Benutzers: `name`, `adresse`, `kundennummer`, `email`, `telefon`, `ort`, `webseite`, `geburtsdatum`. Noch nichts gespeichert → `{}` (gültiger Zustand). |
| PUT | `/settings` | Persönliche Angaben speichern. Es werden **nur die Felder geändert, die im Body stehen** — weggelassene behalten ihren Wert. Antwort enthält den neuen Stand (`{success, settings}`). |
| GET | `/statistics` | `total`, `byArt[]`, `byStatus[]` — nur eigene Bewerbungen. |
| GET | `/export` | Export (ohne `interne_notizen`). Query: `month`, `year`. |
| GET | `/templates` | Basis-Unterlagen / Vorlagen des Benutzers. Enthält den **Lebenslauf** — daraus leitet die Stellensuche Rolle und Skills ab. |
### JobOffers (Jobangebote)
| Method | Pfad | Beschreibung |
|--------|------|--------------|
| GET | `/joboffers` | Auflisten (neueste zuerst). |
| POST | `/joboffers` | Upsert (per `(quelle, external_id)`, sonst normalisierte `quelle_url`) → Response `action: created\|updated`. **409** wenn geblacklistet (siehe unten). |
| GET | `/joboffers/{id}` | Einzelnes Jobangebot. |
| DELETE | `/joboffers/{id}` | Löschen. **Setzt das Angebot standardmäßig zuvor auf die Blacklist** (Typ `auto`), damit dieselbe Stelle nie erneut eingespielt wird. Mit `?blacklist=false` hart löschen ohne Blockierung. Response `{success, blacklisted}`. |
### Blacklist (blockierte Jobangebote)
| Method | Pfad | Beschreibung |
|--------|------|--------------|
| GET | `/joboffers/blacklist` | Blacklist-Einträge auflisten (neueste zuerst). |
| POST | `/joboffers/blacklist` | Block anlegen (per URL, Domain, Firma oder Firma+Stelle) → **201** `{success, entry}`. |
| DELETE | `/joboffers/blacklist/{id}` | Block entfernen — betroffene Angebote sind danach wieder einspielbar. |
## JobOffers einspielen (Drittanbieter → Upsert)
`POST /joboffers` ist der Einspiel-Endpunkt für Drittanbietersoftware (CRM,
Scraper, Job-Board-Sync). Er **upsertet** anhand `(quelle, external_id)`, und
falls keine `external_id` passt, anhand der **normalisierten `quelle_url`**:
- Treffer vorhanden → Datensatz wird aktualisiert → Response
`"action":"updated"`, HTTP **200**.
- kein Treffer → neuer Datensatz → `"action":"created"`, HTTP **201**.
- Angebot steht auf der **Blacklist** → HTTP **409**, `BlacklistConflict`-Body,
Angebot wird **nicht** aufgenommen (siehe Abschnitt „Blacklist“).
> Damit ist der Import **idempotent**: dieselbe Quelle kann beliebig oft
> eingespielt werden, ohne Dubletten zu erzeugen — Voraussetzung ist eine
> stabile `external_id` pro Quelle.
> ⚠️ **Upsert ersetzt, er merged nicht.** Beim Update wird der Datensatz mit dem
> gesendeten Body überschrieben; weggelassene optionale Felder werden **geleert**.
> Beim Re-Import daher immer den **vollständigen** Datensatz senden, nicht nur die
> geänderten Felder.
### Felder
| Feld | Pflicht | Beschreibung |
|------|:---:|--------------|
| `firma` | ✅ | Arbeitgeber. |
| `stelle` | ✅ | Stellenbezeichnung. |
| `firma_slug` | ✅ | **Pflicht.** Firmenname als `lowercase` + `kebab-case` (z. B. `Acme GmbH``acme-gmbh`). Dient der eindeutigen Identifizierung beim Blacklist-Abgleich. |
| `external_id` | | ID des Angebots in der Quellsoftware. **Upsert-Schlüssel** — für idempotenten Import immer setzen. |
| `quelle` | | Herkunftssystem/Kanal (z. B. `mein-crm`). Default serverseitig `drittanbieter`. |
| `ort` | | Arbeitsort. |
| `adresse` | | Anschrift des Arbeitgebers (Straße, Hausnummer, PLZ, Ort). |
| `ansprechpartner` | | Name des Ansprechpartners für die Bewerbung. |
| `gehalt` | | Freitext (z. B. `55.000 €`). |
| `beschreibung` | | Volltext der Stellenanzeige. |
| `quelle_url` | | Deeplink zum Original-Inserat. |
| `art` | | Bewerbungsart-Enum (siehe unten). |
| `status` | | `offen` \| `uebernommen` \| `abgelehnt`. Default `offen`. |
| `anzeige_datum` | | Veröffentlichungsdatum des Inserats (`YYYY-MM-DD`). Wird gespeichert. |
| `kontakt_email` | | Kontakt-/Bewerbungs-E-Mail-Adresse der Stelle. |
| `labels` | | Array von Arbeitsort-Labels (Enum, mehrere möglich): `Regional`, `Remote-Deutschlandweit`, `Homeoffice-Deutschlandweit`. Kennzeichnet den Arbeitsort-Typ der Stelle. |
| ~~`created_at`~~ | ⛔ | **Nicht mitsenden** — Einspiel-Zeitpunkt wird serverseitig gesetzt; ein mitgesendeter Wert wird ignoriert. |
### Beispiel
```bash
scripts/bt.sh POST /joboffers '{
"external_id": "job-12345",
"quelle": "mein-crm",
"firma": "Acme GmbH",
"stelle": "Softwareentwickler (m/w/d)",
"firma_slug": "acme-gmbh",
"ort": "Berlin",
"adresse": "Musterstraße 1, 10115 Berlin",
"gehalt": "55.000 €",
"beschreibung": "…",
"quelle_url": "https://acme.example/job/12345",
"art": "Online-Portal",
"anzeige_datum": "2026-07-01",
"kontakt_email": "bewerbung@acme.example",
"labels": ["Regional"]
}'
```
Roh mit curl:
```bash
curl -X POST "$BEWERBUNG_API_URL/joboffers" \
-H "X-API-Key: $BEWERBUNG_API_KEY" -H "Content-Type: application/json" \
-d '{"external_id":"job-12345","quelle":"mein-crm","firma":"Acme GmbH","stelle":"Softwareentwickler (m/w/d)","firma_slug":"acme-gmbh","art":"Online-Portal","anzeige_datum":"2026-07-01"}'
```
> JSON erlaubt **keine Kommentare** — die `// created_at …`-Zeile aus Notizen
> vor dem Absenden entfernen, sonst schlägt das Parsen fehl (HTTP 400).
### Batch-Einspielung
Kein Bulk-Endpunkt: pro Angebot ein `POST`. Für viele Angebote in einer Schleife
importieren und `action` je Antwort auswerten:
```bash
# offers.json = JSON-Array von JobOfferCreate-Objekten
jq -c '.[]' offers.json | while read -r offer; do
scripts/bt.sh POST /joboffers "$offer" | head -1
done
```
## Blacklist (Angebote dauerhaft blockieren)
Über `/joboffers/blacklist` lassen sich Angebote sperren. Ein geblacklistetes
Angebot wird beim `POST /joboffers` **nicht** aufgenommen, sondern mit **409**
und `BlacklistConflict`-Body abgelehnt (`blacklisted:true`, `matched_by`,
`blacklist_entry`). Der Filter greift auf **künftige** Einspielungen; bereits
importierte Angebote werden nicht rückwirkend gelöscht.
### Block-Typen (`typ`)
| `typ` | Body-Felder | Blockiert |
|-------|-------------|-----------|
| `url` | `wert` = konkrete URL | genau dieses Inserat (URL wird normalisiert, Tracking-Parameter entfernt). |
| `domain` | `wert` = Domain (z. B. `stellenbetrug.example`) | alle Angebote dieser Domain. |
| `firma` | `wert` = Firmenname | alle Angebote dieser Firma. |
| `firma_stelle` | `firma` + `stelle` (+ optional `ort`) | eine bestimmte Firma-Stelle-Kombination. |
`grund` (Freitext) ist überall optional. Firma/Stelle/URL werden serverseitig
normalisiert, der Vergleich ist also unabhängig von Groß-/Kleinschreibung und
Tracking-Parametern. GET-Antworten enthalten die normalisierten Vergleichsfelder
zusätzlich: `url_norm`, `domain`, `firma_norm`, `firma_slug`, `stelle_norm`,
`ort_norm` (nur lesbar, serverseitig berechnet — nicht mitsenden). (Der Typ `auto`
in Antworten markiert automatisch erzeugte Einträge — aus einer **Ablehnung** oder
aus dem **Löschen eines Jobangebots** (`DELETE /joboffers/{id}`); er kann nicht
direkt angelegt werden.)
### Beispiele
```bash
# Ganze Domain sperren
scripts/bt.sh POST /joboffers/blacklist '{"typ":"domain","wert":"stellenbetrug.example","grund":"Fake-Inserate"}'
# Eine Firma sperren
scripts/bt.sh POST /joboffers/blacklist '{"typ":"firma","wert":"Dubios GmbH"}'
# Konkrete Firma+Stelle sperren
scripts/bt.sh POST /joboffers/blacklist '{"typ":"firma_stelle","firma":"Acme GmbH","stelle":"Vertrieb (m/w/d)","ort":"Berlin"}'
# Auflisten und wieder entfernen
scripts/bt.sh GET /joboffers/blacklist
scripts/bt.sh DELETE /joboffers/blacklist/3
```
> ⚠️ Vor dem `POST /joboffers` (v. a. Batch-Import) kann ein 409 auftreten, weil
> das Angebot geblacklistet ist. In Schleifen den Statuscode auswerten und solche
> Angebote überspringen statt abzubrechen.
## Anlagen bei der Generierung (statische Basis-Anlagen)
Beim Anstoßen der KI-Generierung (`POST /applications/{id}/generate`) lassen sich
optional **statische Basis-Anlagen** (`basis_anhaenge`) beilegen — z. B. Zeugnisse,
Zertifikate. Feld **`anlagen`**: ein Array von **Integer-IDs** dieser Basis-Anlagen.
- **Default: keine.** Fehlt `anlagen` oder ist das Array leer, werden **keine**
statischen Anlagen beigelegt.
- Die gewählten Anlagen erscheinen zusätzlich **im Anschreiben unter „Anlagen"**.
- IDs verweisen auf die im Tracker hinterlegten Basis-Anlagen (`basis_anhaenge`).
Für die aktuell verfügbaren IDs/Namen im Tracker nachsehen (die Basis-Anlagen
werden dort verwaltet); nur belegte IDs senden, keine raten.
```bash
# Mit einer statischen Anlage (z. B. Abschlusszeugnis = ID 2) generieren
scripts/bt.sh POST /applications/12/generate '{"anlagen":[2]}'
# Mehrere Anlagen + KI-Kontext
scripts/bt.sh POST /applications/12/generate '{"llm_notizen":"…","anlagen":[2,5]}'
# Ohne Anlagen (Default) — Feld einfach weglassen
scripts/bt.sh POST /applications/12/generate '{}'
```
## Enums
- **art:** `E-Mail`, `Online-Portal`, `Indeed`, `StepStone`, `Firmenwebsite`, `Post`, `Initiativbewerbung`, `Arbeitsagentur`, `Sonstiges`
- **status (Bewerbung/Timeline):** `Entwurf`, `Gesendet`, `Eingangsbestätigung`, `In Bearbeitung`, `Interessiert`, `Warten auf Rückmeldung`, `Warten auf meine Antwort`, `Vorstellungsgespräch`, `Absage`, `Einstellung`, `Keine Rückmeldung`
- **joboffer.status:** `offen`, `uebernommen`, `abgelehnt`
- **labels (Arbeitsort, Array, mehrere möglich):** `Regional`, `Remote-Deutschlandweit`, `Homeoffice-Deutschlandweit` — auf `JobOffer` **und** `Application`; als `?label=…`-Filter auf `GET /applications` nutzbar.
- **blacklist.typ (anlegen):** `url`, `domain`, `firma`, `firma_stelle` (Antworten können zusätzlich `auto` enthalten)
- **email.direction:** `in`, `out`
- **generation.status:** `nicht_gestartet`, `ausstehend`, `fertig`, `fehler`
## Feld-Referenz (wichtigste)
**ApplicationCreate** — erforderlich `datum` (YYYY-MM-DD), `firma`, `stelle`;
optional `art`, `status`, `notizen`, `interne_notizen`, `ort`,
`stellenbeschreibung`, `quelle_url`, `llm_notizen`, `kommentar`, `force`,
`labels` (Array: `Regional`\|`Remote-Deutschlandweit`\|`Homeoffice-Deutschlandweit`;
auch als `?label=`-Filter beim Auflisten). `ApplicationUpdate` kennt `labels` ebenso.
**TimelineEntryCreate** — erforderlich `status`; optional `datum`, `kommentar`.
**GenerateRequest** (`POST /applications/{id}/generate`) — alle Felder optional:
`llm_notizen` (Freitext-Kontext für die KI), `anlagen` (Array von Integer-IDs
statischer Basis-Anlagen; Default **keine**, siehe Abschnitt „Anlagen bei der
Generierung“).
**JobOfferCreate** — erforderlich `firma`, `stelle`, `firma_slug` (Firmenname als
`lowercase` + `kebab-case`, z. B. `acme-gmbh` — eindeutige ID für den
Blacklist-Abgleich); optional `external_id`, `quelle`, `ort`, `adresse`,
`ansprechpartner`, `gehalt`, `beschreibung`, `quelle_url`, `art`, `status`,
`anzeige_datum`, `kontakt_email`, `labels` (Array:
`Regional`\|`Remote-Deutschlandweit`\|`Homeoffice-Deutschlandweit`, mehrere
möglich) (siehe Abschnitt „JobOffers einspielen“).
`created_at` wird **serverseitig** gesetzt und darf nicht mitgesendet werden.
**BlacklistEntryCreate** — erforderlich `typ` (`url`\|`domain`\|`firma`\|
`firma_stelle`); bei `url`/`domain`/`firma` das Feld `wert`; bei `firma_stelle`
die Felder `firma`+`stelle` (optional `ort`); `grund` überall optional (siehe
Abschnitt „Blacklist“).
## Hinweise / Fehlerbehandlung
- **401** `{"error":"Ungültiger oder fehlender API-Key ..."}` → Key falsch, leer oder
keinem Benutzer zugeordnet. Der Key liegt **in der Datenbank** (pro Benutzer), nicht
mehr in der `.env` — ein Serverneustart ist nach einer Key-Änderung **nicht** nötig,
ein neuer Key gilt sofort und macht den alten ungültig. Häufigste Ursache: Der Token
wurde in der Oberfläche neu generiert, die Umgebung nutzt noch den alten.
- **404 obwohl die ID existiert** → Die ID gehört einem **anderen Benutzer**. Die API
zeigt fremde Datensätze grundsätzlich als „nicht vorhanden". Prüfe, mit wessen Key du
arbeitest (`BEWERBUNG_USER` / `BEWERBUNG_API_KEY`).
- **409** bei `POST /applications` → mögliche Dublette (`matches[]`); mit `force:true` erzwingen.
- **409** bei `POST /joboffers` → Angebot steht auf der **Blacklist**
(`BlacklistConflict`: `blacklisted`, `matched_by`, `blacklist_entry`); nicht
erzwingbar — erst den Blacklist-Eintrag entfernen (`DELETE /joboffers/blacklist/{id}`).
- **404** → ID existiert nicht.
- Binär-Downloads (Attachments) mit `curl ... -o datei` speichern, nicht ins Terminal.
- Vor Löschungen (DELETE) beim User rückversichern.
Vollständige OpenAPI-Definition: `http://localhost:4327/swagger.json`.
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# Helper for the Bewerbungs-Tracker REST-API.
# Usage:
# bt.sh GET /applications
# bt.sh GET "/applications?status=Gesendet&limit=20"
# bt.sh POST /applications '{"datum":"2026-07-03","firma":"ACME","stelle":"DevOps"}'
# bt.sh PUT /applications/12 @/path/to/body.json
# bt.sh DELETE /joboffers/5
#
# Config via env (with sensible defaults):
# BEWERBUNG_API_URL base URL (default http://localhost:4327/api/v1)
# BEWERBUNG_API_KEY API key (X-API-Key header)
# BEWERBUNG_USER username whose key to use (default: admin)
#
# MULTI-USER: the tracker has one API key PER USER; the key decides whose data you
# see and change. There is no global key any more. The old fallback (API_TOKEN from
# /opt/jobbi-bewerbung/.env) is gone on purpose: it happened to hold the admin's
# token, so it silently acted as admin — and it goes stale the moment a key is
# rotated in the UI (Einstellungen -> "Neu generieren").
#
# Key resolution:
# 1. $BEWERBUNG_API_KEY if set (this is what the Jobsuche-Runner passes per user)
# 2. otherwise: look up the key of $BEWERBUNG_USER (default admin) in the tracker DB
set -euo pipefail
BASE="${BEWERBUNG_API_URL:-http://localhost:4327/api/v1}"
DB="${BEWERBUNG_DB:-/opt/jobbi-bewerbung/data/bewerbungen.db}"
USER_NAME="${BEWERBUNG_USER:-admin}"
KEY="${BEWERBUNG_API_KEY:-}"
if [[ -z "$KEY" && -f "$DB" ]]; then
KEY="$(node -e "
const s = require('/opt/jobbi-bewerbung/source/node_modules/sqlite3');
const db = new s.Database(process.argv[1]);
db.get(
\"SELECT a.value FROM app_state a JOIN users u ON u.id = a.user_id \" +
\"WHERE u.username = ? AND a.key = 'cfg:API_TOKEN' AND a.value != ''\",
[process.argv[2]],
(e, r) => process.stdout.write(r && r.value ? r.value : '')
);
" "$DB" "$USER_NAME" 2>/dev/null || true)"
fi
if [[ -z "$KEY" ]]; then
echo "bt.sh: kein API-Key für Benutzer '$USER_NAME'." >&2
echo " -> In der App unter Einstellungen -> REST-API einen Token erzeugen," >&2
echo " oder BEWERBUNG_API_KEY / BEWERBUNG_USER setzen." >&2
exit 2
fi
METHOD="${1:?method required (GET/POST/PUT/DELETE)}"
PATH_ARG="${2:?path required, e.g. /applications}"
BODY="${3:-}"
url="${BASE}${PATH_ARG}"
args=(-sS -X "$METHOD" -H "X-API-Key: ${KEY}" -w $'\n<http %{http_code}>\n')
if [[ -n "$BODY" ]]; then
args+=(-H "Content-Type: application/json" --data-binary "$BODY")
fi
curl "${args[@]}" "$url"
@@ -0,0 +1,548 @@
---
name: it-stellensuche-remote
description: Search the web for NEW 100% remote / homeoffice IT / system administrator jobs anywhere in Germany (Deutschland-weit, ortsunabhängig) that the user has NOT applied to yet, then present them and optionally import them as JobOffers into the Bewerbungs-Tracker. Use whenever the user wants to find new fully-remote IT/sysadmin postings, "Remote-Stellensuche", "Homeoffice-Jobs suchen", "100% Remote finden", "ortsunabhängige Systemadministrator-Stellen", or refresh open remote job leads. Deduplicates against existing Bewerbungen and Jobangebote via the bewerbungs-tracker skill.
---
# Stellensuche REMOTE (100 % Homeoffice, deutschlandweit)
Findet **neue** Stellen im Web, die **100 % remote / vollständig im Homeoffice** und
**deutschlandweit ortsunabhängig** ausübbar sind, filtert alle raus, für die sich der
Nutzer schon beworben hat **oder die auf der Blacklist stehen**, und spielt sie auf
Wunsch als JobOffers in den Bewerbungs-Tracker ein.
> **Multi-User (WICHTIG):** Der Tracker hat mehrere Benutzer, jeder mit **eigenem
> Suchprofil und eigenem API-Key**. Der Remote-Modus ist inzwischen Teil des
> Suchprofils (`/jobsuche` → Modus „100 % Remote" bzw. „Regional + Remote"), und der
> Jobsuche-Runner ruft dafür den Skill `it-stellensuche` mit einem entsprechenden
> Prompt auf. Dieser Skill hier ist der **manuelle Einstieg** für eine reine
> Remote-Suche.
>
> Rolle und Technologien **nicht raten**: Sie kommen aus dem Lebenslauf des Benutzers,
> dem der `BEWERBUNG_API_KEY` gehört (`GET /templates`, typ `Lebenslauf`) — bzw. aus dem
> Profil, das der Prompt mitliefert. Die Rollen-/Buzzword-Listen unten sind **Beispiele
> für ein IT-Infrastruktur-Profil**, kein Default für jeden Benutzer.
> **Abgrenzung zum regionalen Skill:** Dieser Skill sucht **ausschließlich
> ortsunabhängige 100-%-Remote-Stellen deutschlandweit** — **ohne** Bindung an die
> sechs Städte. Der **Arbeitsort/Firmensitz spielt keine Rolle**, solange die
> Tätigkeit vollständig aus dem Homeoffice irgendwo in Deutschland erbracht werden
> kann. Regionale, orts­gebundene Stellen sind Sache des Skills `it-stellensuche`
> (sechs Städte) und werden hier **nicht** behandelt.
> **Blacklist-Prinzip (zentral):** Jeder erfolgreich importierte Job wird
> **unmittelbar danach geblacklistet**. So taucht dieselbe Stelle in späteren
> Suchläufen nie wieder auf — die Dublett-Vermeidung bleibt bestehen, selbst wenn
> das JobOffer später gelöscht oder in eine Bewerbung überführt wird. Umgekehrt
> werden Kandidaten, die schon auf der Blacklist stehen, gar nicht erst gelistet
> oder importiert. Die Blacklist/Dedup ist **gemeinsam** mit dem regionalen Skill —
> derselbe Job wird also über beide Suchen hinweg nicht doppelt erfasst.
## Suchprofil (Default)
**Rolle:** breit fassen — dieselbe Tätigkeit läuft je nach Firma unter vielen Titeln.
Alle folgenden Bezeichnungen als Query-Varianten und für die Relevanzbewertung nutzen
(zutreffend, sobald der Schwerpunkt auf IT-Infrastruktur/Systembetrieb liegt):
- **Kern (deckt sich mit dem CV: 2nd-Level-Sysadmin + IT-Consultant):**
Systemadministrator, IT-Administrator, IT-Systemadministrator, IT-Systemadministration,
IT-Systemadministrator 2nd Level, 2nd-Level-Administrator, System-Administrator (m/w/d),
Fachinformatiker Systemintegration, Administrator (m/w/d).
- **Support/Betrieb:** 1st/2nd/3rd-Level-Support, 2nd-Level-Support, IT-Support,
IT-Supporter, IT-Techniker, IT-Systemtechniker, Systemtechniker, IT-Systembetreuer,
IT-Betreuer, IT-Allrounder, IT-Koordinator, IT-Mitarbeiter, IT-Operations / IT-Betrieb,
Remote-Support, Remote-Systemadministrator, Service-Desk, Application Manager (remote).
(Rein ortsgebundene Onsite-/Field-Service-Rollen entfallen hier — sie sind nicht
remote ausübbar.)
- **Infrastruktur/Netz:** System Engineer, Systems Engineer, Infrastructure Engineer,
IT-Infrastruktur, Infrastruktur-Administrator, Netzwerkadministrator, Network Engineer,
Virtualisierungsadministrator, Storage-/Backup-Administrator.
- **Cloud/Modern Workplace:** Cloud-Administrator, Cloud Engineer, Cloud-Operations-Engineer,
Microsoft-365-Administrator, M365-/Modern-Workplace-Administrator,
Azure-/Entra-Administrator, DevOps-Engineer (Infrastruktur-lastig),
Site Reliability Engineer (SRE), Platform Engineer, Linux-Administrator,
Linux System Engineer, Windows-Administrator.
- **Managed Services / Hosting / Betrieb (passt zu Hetzner/OVH im CV):**
IT-Consultant (remote), IT-Berater (technisch), IT-Systemberater,
Managed-Services-Engineer, MSP-Engineer, NOC-Engineer, Cloud Operations,
Hosting-Administrator, Hosting Engineer, Systemadministrator Rechenzentrum (remote).
Ausschließen bleiben reine Softwareentwickler-, Data-Science-, Vertriebs- und
SAP-only-Stellen, außer sie passen klar zum Infrastruktur-Profil.
> **Ex-Arbeitgeber IMMER ausschließen (harte Regel, unabhängig von der Schreibweise):**
> Stellen der **„IT-Problemlöser"** (Essen) — voller Firmenname u. a.
> **„IT Problemlöser Verwaltungs- und Handels GmbH"**, auch **„IT-Problemlöser GmbH"**,
> **„IT Problemlöser"** o. Ä. — werden **nie** gelistet oder importiert. Es ist der
> frühere Arbeitgeber des Nutzers (siehe Lebenslauf). Diese Firma taucht immer wieder
> mit **wechselnden Stellentiteln** auf; die `firma_stelle`-Blacklist greift dann nicht,
> weil der Titel abweicht. Deshalb **jeden Treffer verwerfen, dessen Firmenname `IT
> Problemlöser` / `IT-Problemlöser` (in jeder Rechtsform-/Schreibvariante) enthält** —
> ohne auf die Blacklist zu warten. (Firmenweite `firma`-Blacklist-Einträge greifen
> inzwischen schreibweisen-robust über `firma_slug` inkl. Präfix-Match — diese
> Modell-Regel bleibt trotzdem als zusätzliche Sicherung bestehen.)
**Arbeitsmodell (harter Filter — das Kernkriterium dieses Skills):**
Nur **100 % Remote / vollständig ortsunabhängige** Stellen, die **deutschlandweit
aus dem Homeoffice** erbracht werden können.
- **Zulässig** (behalten): Anzeigen, die klar als „100 % Remote", „Full Remote",
„vollständig remote", „remote-first", „ortsunabhängig", „deutschlandweit im
Homeoffice", „remote (Deutschland)" o. Ä. ausgeschrieben sind — die Tätigkeit ist
im Alltag komplett aus dem Homeoffice möglich. **Gelegentliche** Vor-Ort-Termine
(Onboarding, wenige Team-Events/Kick-offs pro Quartal/Jahr) sind unschädlich.
- **Verwerfen** (nicht listen, nicht importieren):
- **Reine Vor-Ort-/Präsenz-Stellen** (kein oder nur „nach Absprache"-Homeoffice).
- **Klassische Hybrid-Modelle mit festen Präsenz-/Bürotagen** (z. B. „23 Tage/
Woche vor Ort", „überwiegend Präsenz", „Home­office anteilig/tageweise möglich") —
das ist **nicht** 100 % remote.
- Stellen, die **Wohnsitz/Anwesenheit in einem bestimmten Ort oder Umkreis**
verlangen („Einsatzort …", „Wohnsitz im Raum …", „regelmäßig beim Kunden vor
Ort", „Rufbereitschaft vor Ort", Onsite-Field-Service).
- Stellen ohne **Anstellung in Deutschland** (z. B. „remote nur aus <anderes
Land>", Arbeitsvertrag/Steueransässigkeit außerhalb DE). Der Job muss für einen
in **Deutschland ansässigen** Bewerber als reguläre Anstellung offenstehen.
- **Kein geografischer Prioritäts-Ranking** (deutschlandweit gleichwertig) — statt
nach Ort **nach Relevanz** (Profil-/Buzzword-Passung, s. u.) und **Klarheit des
100-%-Remote-Versprechens** priorisieren.
> Bei Unsicherheit, ob eine Stelle wirklich 100 % remote ist, die Anzeige per
> `WebFetch` genau lesen (Abschnitte „Arbeitsmodell", „Das bieten wir", „Standort").
> Steht dort nur „Homeoffice möglich"/„flexibel" ohne klares 100-%-Remote, im Zweifel
> **verwerfen** — dieser Skill nimmt ausschließlich eindeutig vollständig remote
> Stellen auf.
**Skills/Buzzwords** (für Query-Varianten & Relevanzbewertung): erste Gruppe stammt
direkt aus dem Lebenslauf (Kernkompetenzen, höchste Gewichtung), die weiteren sind
naheliegende, zum Profil passende Technologien — als Suchbegriffe und zum Erkennen
passender Anzeigen nutzen, auch wenn sie nicht wörtlich im CV stehen:
- **Kern (aus dem CV, höchste Gewichtung):** Windows Server, Active Directory,
Exchange, Microsoft 365 / MS365, Linux-Administration (Debian/Ubuntu),
Docker/Container, Proxmox/Virtualisierung, Netzwerk (OPNsense, pfSense,
VPN: WireGuard/OpenVPN/IPsec, LAN/WAN, TCP/IP), Firewall, Monitoring
(Prometheus, Grafana, Beszel, Uptime Kuma), Hetzner/OVH Cloud, Self-Hosting,
Automatisierung, AI/Claude/Agent-Workflows.
- **Microsoft-/Windows-Umfeld:** Entra ID / Azure AD, Intune, Endpoint Manager,
Group Policy / GPO, WSUS, PowerShell, Hyper-V, SharePoint, Teams, Windows 10/11,
Client-Management, MDM.
- **Virtualisierung/Server:** VMware vSphere / ESXi, Hyper-V, Citrix, KVM,
Terminalserver / RDS, Server-Hardware (Dell/HPE/Lenovo).
- **Backup/Storage:** Veeam, Backup & Recovery, Datensicherung, NAS/SAN, TrueNAS,
ZFS, Ceph, Synology, QNAP.
- **Netzwerk/Security:** VLAN, Routing & Switching, Cisco, Fortinet/FortiGate,
Sophos, Ubiquiti/UniFi, DNS/DHCP, Reverse Proxy (Nginx, Traefik), Let's Encrypt,
IT-Security, Patch-Management, ISO 27001, IT-Grundschutz.
- **Cloud/DevOps/Automatisierung:** Microsoft Azure, AWS, Kubernetes, Ansible,
Terraform, GitLab / CI/CD, Bash-/Shell-Scripting, Portainer, Nextcloud.
- **Datenbanken/Web:** MySQL/MariaDB, PostgreSQL, MS SQL Server, Apache, Nginx, IIS.
- **Betrieb/Organisation:** Ticketsystem (Jira, OTRS, Zammad), Helpdesk,
Rechenzentrum, On-Premise, Managed Services, IT-Dienstleister, Systemhaus,
Cloud-Provider, Hosting-Anbieter.
> Aktuelles Profil (Titel, Skills, Ort) steht in der Lebenslauf-Vorlage der API:
> `bewerbungs-tracker` → `GET /templates` (typ `Lebenslauf`). Bei Bedarf dort
> gegenlesen, statt zu raten.
## Quellen-Politik (WICHTIG)
Ziel ist **immer die Original-Stellenausschreibung direkt beim echten Arbeitgeber**
— bevorzugt auf dessen **Firmen-/Karriereseite**.
- **Ausschließen (nicht listen, nicht importieren):**
- **Headhunter / Personalvermittler / Personalberatungen** (recruiten für Dritte).
- **Zeitarbeit / Arbeitnehmerüberlassung / Personaldienstleister** (z. B. Randstad,
Hays, GULP, Amadeus FiRe, Adecco, Manpower, Piening, Tempton, DIS AG, Robert Half
u. ä. — auch unbekannte, wenn die Anzeige „Arbeitnehmerüberlassung",
„im Kundenauftrag", „für unseren Kunden", „Personaldienstleister" o. Ä. nennt).
- **Stepstone** als Quelle — nicht als Board nutzen, keine `site:stepstone.de`-Query,
keine Stepstone-Links importieren.
- **Erkennungsmerkmale eines Vermittlers/Zeitarbeit** (bei Unsicherheit die
Firmen-/Impressumsseite per `WebFetch` prüfen): Formulierungen wie „für unseren
Kunden", „im Auftrag unseres Mandanten", „Arbeitnehmerüberlassung", „Direktvermittlung",
„Personaldienstleistung", „Recruiting-Partner"; oder die inserierende Firma ist erkennbar
eine Personal-/Recruiting-Agentur. → **verwerfen**.
- **Zulässig als Quelle**, wenn es zur Original-Anzeige des Arbeitgebers führt:
Firmen-Karriereseite (bevorzugt), Arbeitsagentur/Bundesagentur für Arbeit, Indeed
oder LinkedIn **nur** wenn die Anzeige eindeutig vom echten Arbeitgeber (nicht von
einem Vermittler) stammt. Führt ein Board-Treffer zu einer Firma, deren eigene
Karriereseite dieselbe Stelle direkt listet, **immer den Firmen-Direktlink** nehmen.
- **Remote-spezifische Boards** (nur als Ergänzung, immer zur Arbeitgeber-Original­anzeige
durchklicken): z. B. `germantechjobs.de`, `remotarr`, `remote.io`, `remoteok.com`,
`4dayweek.io`, `join.com`, `arbeitsagentur.de` (Filter „Homeoffice/Telearbeit"),
`de.indeed.com` (Filter „Remote"), `de.linkedin.com/jobs` (Filter „Remote") — auch hier
Vermittler/Zeitarbeit/Stepstone ausschließen und den Firmen-Direktlink bevorzugen.
## Workflow
1. **Bereits erfasste Jobs + Blacklist laden** (Dedup-Basis). Dieser Schritt ist
**immer zuerst** auszuführen, damit Stellen aus früheren Suchläufen — **auch aus
dem regionalen Skill** — **nicht erneut** gefunden/importiert werden. Zwei Quellen:
```bash
scripts/applied-set.sh # firma_slug<TAB>firma<TAB>stelle<TAB>ort<TAB>quelle_url<TAB>external_id
scripts/blacklist-set.sh # id<TAB>typ<TAB>firma<TAB>stelle<TAB>ort<TAB>domain<TAB>url_norm<TAB>firma_norm<TAB>firma_slug<TAB>stelle_norm<TAB>ort_norm<TAB>grund
```
- **applied-set** (Bewerbungen **und** importierte Jobangebote): **eine Zeile
pro bereits erfasster Firma** (company-level, nicht mehr pro Stelle). Je Zeile
drei Dublett-Merkmale merken: **`firma_slug`** (robuster Firmen-Schlüssel:
lowercase, Umlaute→ae/oe/ue/ss, `(m/w/d)` raus, End-Rechtsform wie gmbh/ag/kg
entfernt, mit `-` verbunden), **`quelle_url`** und **`external_id`**.
- **blacklist-set** (gesperrte Muster): je Zeile den **`typ`** und die dazu
passenden Felder merken (`url_norm`, `domain`, `firma_norm`, **`firma_slug`**,
`stelle_norm`). Die `*_norm`/`firma_slug`-Felder liefert der Server bereits
normalisiert (lowercase, Tracking-Parameter entfernt, `(m/w/d)`/Rechtsform
bereinigt).
2. **Kandidaten sammeln — strukturierte Quelle zuerst, dann WebSearch.**
**(a) Arbeitsagentur-API mit Homeoffice-Filter (primär, strukturiert, frisch).**
Vor der Freitext-Suche die Jobsuche-API der Bundesagentur für Arbeit abfragen —
**deutschlandweit** (`wo` leer) mit `arbeitszeit=ho` (Homeoffice-Flag):
```bash
scripts/arbeitsagentur.sh search "<Rolle>" "" 0 30 100 ho
# -> firma_slug<TAB>firma<TAB>titel<TAB>ort<TAB>datum<TAB>refnr<TAB>angebotsart
```
Rollen aus dem Suchprofil durchrotieren (Systemadministrator, IT-Administrator,
Linux/Windows-Administrator, Cloud Administrator, System Engineer, DevOps, SRE, …).
Jede Zeile sofort per `firma_slug` gegen applied-set/blacklist prüfen (Firmen-Dedup,
Schritt 1). Für überlebende Treffer die Details holen:
```bash
scripts/arbeitsagentur.sh detail <refnr>
```
Detail liefert **Volltext** (→ `beschreibung`), **`EXTERNE_URL`** (→ bevorzugt
`quelle_url`), **`HOMEOFFICE_MOEGLICH`**, **Vergütung**, Firmensitz und die Flags
**`ZEITARBEIT_AUE` / `PRIVATE_ARBEITSVERMITTLUNG`** — `True` → Zeitarbeit/Vermittler
→ **verwerfen**. ⚠️ `arbeitszeit=ho`/`HOMEOFFICE_MOEGLICH` heißt nur „Homeoffice
möglich", **nicht** automatisch „100 % remote" — die harte 100-%-Remote-Prüfung
(Schritt 4) gilt unverändert am Volltext. „Keine aktive Anzeige" → verwerfen; `refnr`
stabil → Existenz-Check & `external_id`. (Die API ist ortsorientiert und deshalb hier
nur **eine** Quelle — WebSearch bleibt für Remote-Boards & remote-first-Firmen wichtig.)
**(b) WebSearch (ergänzend).** Mit dem `WebSearch`-Tool **viele** Queries fahren —
Rolle × **Remote/Homeoffice**, **deutschlandweit** (kein Ort anhängen), mit Fokus
auf **Original-Anzeigen der Arbeitgeber**.
> **Query-Strategie (Recall maximieren — WICHTIG, findet die Suche „kaum noch
> Stellen"):** Lieber **viele einfache, natürlichsprachliche Einzel-Queries** als
> wenige überladene. **Boolesche Operatoren sparsam einsetzen** — `OR`, `"…"`,
> `site:`, `inurl:` und `-minus`-Ausschlüsse **senken bei WebSearch oft drastisch
> die Trefferzahl**. Statt `A OR B "…" inurl:jobs`-Monsterqueries lieber **getrennte,
> kurze Queries**; Vermittler/Stepstone/Hybrid erst **nachträglich beim Filtern**
> (Schritt 3) rauswerfen. Jede Rolle mit **mehreren Remote-Synonymen** und Suffixen
> durchvariieren: Remote-Wörter `remote`, `100% Remote`, `Homeoffice`,
> `home office`, `ortsunabhängig`, `deutschlandweit`, `remote-first`, `full remote`;
> Suffixe `Stellenangebot`, `Job`, `Jobs`, `Festanstellung`, `unbefristet`,
> `Vollzeit`, `2026`.
**Rolle × Remote (mehrere Synonyme/Suffixe durchspielen), Beispiele:**
- `Systemadministrator 100% Remote Stellenangebot Deutschland`,
`Systemadministrator remote Homeoffice Job`, `Systemadministrator ortsunabhängig Festanstellung`
- `IT-Administrator remote deutschlandweit Job`, `IT-Administrator Homeoffice Vollzeit`,
`IT-Systemadministrator full remote 2026`
- `IT-Systemadministrator vollständig remote Stellenangebot`,
`2nd Level Support remote deutschlandweit`, `Remote-Systemadministrator Festanstellung`
- `Linux Administrator remote Deutschland`, `Linux System Engineer Homeoffice`,
`Windows Administrator remote deutschlandweit`
- `Microsoft 365 Administrator remote Homeoffice`, `Cloud Administrator 100% Homeoffice`,
`Cloud Engineer remote Deutschland`, `Azure Administrator remote Festanstellung`
- `System Engineer remote Deutschland`, `Infrastructure Engineer Homeoffice`,
`Platform Engineer remote deutschlandweit`, `SRE remote Deutschland`
- `Hosting Administrator remote`, `Managed Services Engineer remote Homeoffice`,
`NOC Engineer remote Deutschland`, `IT-Consultant remote Homeoffice`
- **Buzzword-getrieben** (holt Anzeigen mit abweichendem Titel, passendem Inhalt):
`VMware Administrator remote Deutschland`, `Proxmox remote Homeoffice Job`,
`Docker Kubernetes remote Administrator`, `Active Directory remote Homeoffice`,
`Veeam Backup remote Job`, `Hetzner OVH remote Administrator`,
`OPNsense pfSense Firewall remote Job`, `Ansible Automatisierung remote Deutschland`.
- **Remote-Boards zusätzlich** (jeweils **einzeln**, nicht stapeln): `germantechjobs
systemadministrator remote`, `remoteok IT administrator`, `4dayweek.io sysadmin`,
`join.com systemadministrator remote`, `Systemadministrator Homeoffice arbeitsagentur`,
`IT Administrator remote indeed`. Falls doch `site:`, dann einzeln:
`site:germantechjobs.de systemadministrator remote`,
`site:de.indeed.com IT Administrator remote`,
`site:arbeitsagentur.de Systemadministrator Telearbeit`. **Kein** `site:stepstone.de`.
Für Details/Arbeitsmodell/Firma einer Trefferseite `WebFetch` nutzen. Sieht ein
Treffer nach Firmen-Karriereseite aus: dort direkt nach der Einzelanzeige suchen.
2b. **Remote-freundliche Arbeitgeber gezielt finden** (Hauptquelle für „unentdeckte"
Stellen). Board-Suchen finden nur, was breit ausgeschrieben ist — viele
remote-first-Arbeitgeber posten IT-Stellen **ausschließlich auf der eigenen
Website**. Deshalb parallel zur Rolle-×-Remote-Suche **remote-affine Arbeitgeber
deutschlandweit identifizieren** und deren Karriereseite direkt öffnen:
- **Arbeitgeber-Typen recherchieren:** Cloud-/Hosting-Provider, Managed-Service-
Provider (MSP), SaaS-/Tech-Unternehmen, IT-Dienstleister/Systemhäuser mit
Remote-Kultur, verteilte/remote-first-Firmen. Beispiel-Queries:
`remote-first Unternehmen Deutschland IT`, `MSP remote Systemadministrator Karriere`,
`SaaS Unternehmen Deutschland remote Stellenangebote`,
`Cloud Provider Deutschland Karriere remote`.
- **Karriereseite direkt anfahren:** zu jeder gefundenen Firma
`WebSearch "<Firma> Karriere remote"` bzw. `"<Firma> Jobs remote"` und die
Jobliste per `WebFetch` öffnen; auf offene **100-%-Remote IT-/Administrator-/
Support-Stellen** prüfen. Auch `inurl:karriere`, `inurl:jobs`, `inurl:stellen`
gezielt einsetzen und gängige Bewerber-Portale erkennen (softgarden/onlyfy/
Personio/Workday/join.com).
- **Buzzwords als Türöffner:** findet die reine Titel-Suche wenig, mit den
Technologie-Buzzwords (oben) + „remote" suchen, z. B.
`Active Directory remote Administrator Deutschland`, `Veeam remote Job Homeoffice`.
- Jede so gefundene Stelle läuft durch dieselben Prüf-/Filter-/Dedup-Schritte
(37). Der Firmen-Direktlink ist hier ohnehin schon die bevorzugte `quelle_url`.
3. **Filtern & bewerten:**
- **Arbeitsmodell (harter Ausschluss):** **nur** eindeutig **100 % Remote /
vollständig ortsunabhängige** Stellen, deutschlandweit aus dem Homeoffice
ausübbar (Anstellung in Deutschland). Alles andere — reine Präsenz, Hybrid mit
festen Bürotagen, ortsgebundene/„Homeoffice möglich"-ohne-100%-Zusage,
Anstellung außerhalb DE — **verwerfen**. Kein geografisches Ranking; nach
Relevanz priorisieren.
- **Rolle:** Systemadministration/IT-Infrastruktur; keine reinen Entwickler-,
Vertriebs- oder SAP-only-Stellen (außer sie passen klar zum Profil).
- **Quelle (harter Ausschluss):** Headhunter/Personalvermittler, Zeitarbeit/
Arbeitnehmerüberlassung und Stepstone werden **verworfen** — siehe
„Quellen-Politik". Nur Original-Anzeigen echter Arbeitgeber behalten.
- **Dedup (persistent, FIRMEN-basiert):** **Eine Firma darf nur EINMAL gefunden
werden** (regional **und** remote gemeinsam). Treffer **verwerfen**, sobald
**eines** zutrifft: (a) gleiche/sehr ähnliche `quelle_url`, (b) gleiche
`external_id`, oder (c) **die Firma steht schon im Applied-Set** — unabhängig vom
Stellentitel. Firmen-Gleichheit über `firma_slug` prüfen: Kandidaten-Firma ebenso
sluggen (lowercase, Umlaute→ae/oe/ue/ss, `(m/w/d)` & End-Rechtsform raus, mit `-`
verbunden) und als **dieselbe Firma** werten, wenn der Slug **gleich** ist ODER
**ein Slug ein führendes Bindestrich-Präfix des anderen** ist (mind. 2 Tokens) —
so zählen **verschiedene Schreibweisen/Rechtsform-/Namensvarianten derselben
Firma als eine** (z. B. „IT-Problemlöser GmbH" = „IT Problemlöser Verwaltungs-
und Handels GmbH"). Offensichtlich derselbe Arbeitgeber → als Dublette verwerfen.
Distinkte Firmen mit nur gleichem ersten Wort („Meyer IT" vs. „Meyer Logistik")
sind **nicht** dieselbe Firma. Im Zweifel behalten und als „evtl. Dublette"
markieren.
- **Blacklist (harter Ausschluss):** Kandidat **verwerfen** (nicht listen, nicht
importieren), sobald er auf einen Blacklist-Eintrag passt — je nach `typ`:
- `url` → normalisierte Kandidaten-URL == `url_norm`.
- `domain` → Host der Kandidaten-URL == `domain` (auch Subdomains).
- `firma` → **dieselbe Firma** wie der Eintrag (Kandidaten-`firma_slug` gleich
`firma_slug` **oder** Präfix-Match wie bei der Dedup; `firma_norm` nur als
Fallback für Alt-Einträge ohne Slug).
- `firma_stelle` → dieselbe Firma (`firma_slug`, wie oben) **und** `stelle_norm`
passen (ist `ort_norm` gesetzt, muss auch der Ort passen).
- `auto` → wie die konkreten Felder, die der Eintrag trägt.
Diese Muster sind bewusst blockiert (u. a. jeder früher importierte Job, siehe
Schritt 6) — geblacklistete Stellen daher **stillschweigend überspringen**,
nicht als „evtl. Dublette" präsentieren.
- **Relevanz:** höher gewichten, je mehr Buzzwords aus dem Profil passen.
4. **Existenz prüfen, Link prüfen, Remote-Zusage bestätigen, Kontakt-E-Mail &
Firmensitz recherchieren** — für jeden Treffer, der in die engere Wahl kommt,
**zwingend einzeln**:
- **Existenz-Prüfung (Pflicht):** Jede Stelle mit `WebFetch` auf dem Einzel-Link
öffnen und bestätigen, dass die Anzeige **noch aktiv** ist (Firma + Stelle
stehen dort, kein 404/„Stelle nicht mehr verfügbar"/Redirect auf Jobliste).
Snippets aus der WebSearch reichen **nicht** — Suchindizes zeigen oft schon
abgelaufene Anzeigen. Kein Live-Nachweis → Treffer **verwerfen**. Bei bereits
importierten Angeboten, die nicht mehr existieren, das JobOffer löschen
(`DELETE /joboffers/{id}`).
- **100-%-Remote live bestätigen (Pflicht):** Im per `WebFetch` geöffneten
Anzeigentext prüfen, dass die Stelle **tatsächlich vollständig remote /
deutschlandweit ortsunabhängig** ist (Abschnitte „Arbeitsmodell", „Standort",
„Das bieten wir"). Nur „Homeoffice möglich"/„hybrid"/feste Präsenztage → **nicht**
100 % remote → **verwerfen**. Nur eindeutig vollständig remote Stellen behalten.
- **Vollständige Stellenausschreibung erfassen (Pflicht):** Beim `WebFetch` der
Einzelanzeige den **kompletten Ausschreibungstext** übernehmen — nicht nur eine
Kurzzusammenfassung. Dazu gehören: Einleitung/Unternehmensvorstellung, **Aufgaben/
Tätigkeiten**, **Anforderungen/Profil**, **Wir bieten/Benefits**, Angaben zu
Arbeitszeit/Vertragsart/**Remote-Regelung**, Gehalt (falls genannt), Bewerbungsweg/
Kontakt und Referenz-/Kennziffer. Text weitgehend **wortgetreu und vollständig**
sichern (nur Navigations-/Cookie-/Footer-Boilerplate der Seite weglassen), inkl.
der Gliederung/Überschriften. Dieser Volltext wandert in `beschreibung`
(Schritt 6). Ist die Seite lang/abgeschnitten, `WebFetch` gezielt erneut aufrufen.
- **`quelle_url` verifizieren:** Muss auf die **konkrete, noch aktive
Einzelanzeige** zeigen (nicht Trefferliste/Suchseite). Deeplink defekt/Liste →
per Suche den echten Einzel-Link finden (bevorzugt Firmenwebsite/Karriereseite),
sonst verwerfen.
- **Vermittler/Zeitarbeit aussortieren (Pflicht):** Zeigt der Treffer auf eine
Personalberatung/Headhunter/Zeitarbeit statt den echten Arbeitgeber, **verwerfen**.
Bei Unsicherheit die inserierende Firma per `WebFetch` (Impressum/„Über uns")
prüfen — Recruiting-/Personaldienstleister raus. Existiert dieselbe Stelle direkt
auf der Firmen-Karriereseite, diese Original-Anzeige verwenden.
- **Bewerber-Kontakt-E-Mail recherchieren:** Die E-Mail-Adresse ermitteln, an die
sich Bewerber wenden. Reihenfolge: (a) direkt in der Stellenanzeige genannte
Bewerbungs-/Kontaktadresse; (b) Karriere-/Kontaktseite der Firma
(`WebSearch` „<Firma> Karriere Kontakt Bewerbung E-Mail", `WebFetch` der Seite);
(c) allgemeine Bewerbungsadresse (`bewerbung@`/`jobs@`/`karriere@<domain>`) nur,
wenn auf der Firmenseite belegt. **Nicht raten** — nur belegte Adressen; sonst
`kontakt_email` leer lassen und als „nicht gefunden" markieren.
- **Firmensitz recherchieren (Pflicht, soweit belegbar):** Da die Stelle
ortsunabhängig ist, gibt es keinen festen Arbeitsort — als `adresse` den
**eingetragenen Firmensitz** des Arbeitgebers erfassen (Straße, Hausnummer, PLZ,
Ort; Quelle: Impressum/Kontaktseite, `WebSearch` „<Firma> Impressum Adresse",
`WebFetch` der Seite). **Nicht raten** — nur eine belegte Anschrift übernehmen;
sonst `adresse` leer lassen und als „nicht gefunden" markieren. Der Firmensitz
ist reine Zusatzinfo und **kein** Filterkriterium (die Stelle bleibt zulässig,
egal wo die Firma sitzt).
5. **Ergebnis präsentieren** als Tabelle: Firma · Stelle · **Arbeitsmodell (100 %
Remote)** · Firmensitz · Quelle (Board) · **verifizierter Link** ·
**Kontakt-E-Mail** · kurze Passt-Begründung. Sortierung: neue, klar passende
Treffer **nach Relevanz** (Buzzword-Passung; bei Gleichstand klar/eindeutig
100-%-Remote vor „remote, aber schwammig formuliert"). Fehlt Link, E-Mail oder
Firmensitz, kennzeichnen.
6. **Importieren** — im **interaktiven** Modus nur nach Rückfrage/Bestätigung des
Nutzers; im **autonomen Modus** (siehe unten) automatisch ohne Rückfrage. Jeden
neuen Treffer als JobOffer einspielen (Upsert, idempotent) über den
`bewerbungs-tracker`-Skill:
```bash
~/.claude/skills/bewerbungs-tracker/scripts/bt.sh POST /joboffers '{
"external_id": "remote-<slug(firma)>-<slug(stelle)>",
// IMMER setzen und DETERMINISTISCH aus
// firma+stelle bilden — NICHT aus der
// Board-/Anzeigen-ID. Präfix "remote-" hält
// Remote-Funde auseinander; Slug = normalisiert
// wie der firma_slug (lowercase, ohne (m/w/d)
// & Rechtsform, Interpunktion→"-"), z. B.
// "remote-musterfirma-cloud-administrator".
// So bekommt DIESELBE Stelle über jeden Lauf
// UND jedes Board dasselbe external_id →
// Server-Upsert greift zuverlässig statt eine
// Dublette anzulegen. (Eine flüchtige Board-ID
// würde je Quelle abweichen und Duplikate
// erzeugen — daher NICHT nutzen.)
"quelle": "stellensuche-remote",
"firma": "…", "stelle": "…",
"ort": "Remote (Deutschland)", // ortsunabhängig; ggf. "100% Remote / Homeoffice"
"adresse": "<eingetragener Firmensitz: Straße Hausnr, PLZ Ort; recherchiert &
verifiziert; sonst weglassen>",
"gehalt": "…",
"beschreibung": "<VOLLSTÄNDIGE Stellenausschreibung>",
// Der komplette, in Schritt 4 per WebFetch
// erfasste Anzeigentext inkl. Remote-Regelung —
// wortgetreu und vollständig, nicht kürzen.
// Gliederung/Überschriften erhalten (Markdown ok).
"quelle_url": "<verifizierter Einzel-Link, bevorzugt Firmen-Karriereseite>",
"art": "Firmenwebsite",
"anzeige_datum": "YYYY-MM-DD",
"kontakt_email": "<recherchierte Bewerber-E-Mail, sonst weglassen>",
"labels": ["Remote-Deutschlandweit", "Homeoffice-Deutschlandweit"]
// IMMER beide setzen: dieser Skill sucht ausschließlich
// 100% Remote / Homeoffice deutschlandweit → Arbeitsort-
// Labels "Remote-Deutschlandweit" + "Homeoffice-Deutschlandweit".
}'
```
`art`-Enum: `E-Mail`, `Online-Portal`, `Indeed`, `StepStone`, `Firmenwebsite`,
`Post`, `Initiativbewerbung`, `Arbeitsagentur`, `Sonstiges`. **Standard ist
`Firmenwebsite`** (Original-Anzeige des Arbeitgebers). `StepStone` nie verwenden
(ausgeschlossene Quelle); `Arbeitsagentur`/`Indeed` nur, wenn der Link direkt zur
Arbeitgeber-Anzeige führt.
**`409`-Antwort beim Import:** Steht der Job (trotz Vorfilter) auf der Blacklist,
antwortet `POST /joboffers` mit **409** (`BlacklistConflict`) und nimmt ihn
**nicht** auf. Das ist **kein Fehler** — als „übersprungen (Blacklist)" zählen
und mit dem nächsten Treffer weitermachen, **nicht** mit `force` erzwingen.
7. **Direkt blacklisten (nach jedem erfolgreichen Import).** Sobald ein JobOffer
angelegt wurde (Antwort **201** / `action:created`; bei `updated` ist der Job
bereits erfasst), die **Firma sofort ganz** blacklisten, damit dieser Arbeitgeber
in künftigen Läufen — remote **wie regional** — **mit keinem Stellentitel** wieder
auftaucht (Regel „eine Firma nur einmal"). Per `typ: firma` sperren — der Server
matcht firmenweit über `firma_slug` und deckt so abweichende Schreibweisen/
Rechtsformen mit ab:
```bash
~/.claude/skills/bewerbungs-tracker/scripts/bt.sh POST /joboffers/blacklist '{
"typ": "firma",
"firma": "<firma wie importiert>",
"grund": "auto: stellensuche-remote-import <YYYY-MM-DD>"
}'
```
Server-Antwort **201** = geblacklistet. Normalisierung (Groß/Klein, `(m/w/d)`,
Rechtsform, Umlaute) übernimmt der Server. Nur nach **echtem** Neuimport
blacklisten — nicht bei einem 409-Skip und nicht bei bloßem `updated`.
## Autonomer Modus (headless / geplant)
Wird der Skill **nicht-interaktiv** ausgeführt — d. h. ohne Nutzer, der bestätigen
kann (z. B. `claude -p "…" --dangerously-skip-permissions`, Cron/geplanter Lauf,
`ollama launch claude … -p …`) — im **autonomen Modus** arbeiten:
- **Ohne Rückfrage importieren:** Neue, geprüfte Treffer direkt als JobOffer
einspielen (Schritt 6) **und anschließend blacklisten** (Schritt 7). Es gibt
niemanden zum Bestätigen — nicht auf Eingabe warten.
- **Alle Prüfregeln gelten unverändert und strikt:** Existenz-Prüfung, **100-%-Remote
live bestätigt**, korrekter verifizierter Einzel-Link, Kontakt-E-Mail nur wenn
belegt, **Firmensitz nur wenn belegt**, die persistente Dedup **und der
Blacklist-Filter** — importiere **nur** echte, live verifizierte, eindeutig
vollständig remote, noch nicht erfasste und **nicht geblacklistete** Stellen.
Im Zweifel **nicht** importieren (lieber auslassen).
- **Keine Bewerbungen anlegen/versenden**, keine Löschungen bestehender Bewerbungen,
keine Generierung anstoßen — nur neue Jobangebote (`POST /joboffers`) einpflegen
und den jeweils importierten Job blacklisten (`POST /joboffers/blacklist`).
- **Kurzbericht ausgeben** (für das Log): je Treffer `action`
(created+blacklisted / updated / skip-dedup / skip-blacklist) mit Firma, Stelle,
Arbeitsmodell, Link; am Ende Zähler „X neu importiert & geblacklistet, Y als
Dublette/Blacklist übersprungen, Z verworfen (nicht verifizierbar / nicht 100 %
remote)".
## Regeln
- **Nur 100 % Remote, deutschlandweit.** Es werden **ausschließlich** eindeutig
vollständig remote / ortsunabhängige Stellen gelistet/importiert, die aus dem
Homeoffice irgendwo in Deutschland ausübbar sind (Anstellung in DE). Reine
Präsenz-, Hybrid- (feste Bürotage) und ortsgebundene Stellen werden **verworfen** —
Letztere sind Sache des regionalen Skills `it-stellensuche`. Kein Orts-Ranking,
Priorität nach Relevanz.
- **Labels `Remote-Deutschlandweit` + `Homeoffice-Deutschlandweit` setzen (Pflicht).**
Jedes von diesem Skill importierte JobOffer bekommt
`labels: ["Remote-Deutschlandweit", "Homeoffice-Deutschlandweit"]` (100 % remote,
deutschlandweit). Kein `Regional`-Label — ortsgebundene Stellen sind Sache des
Skills `it-stellensuche`.
- **Keine erfundenen Stellen.** Nur Jobs ausgeben, die per WebSearch/WebFetch real
belegt sind. **`quelle_url` muss der korrekte, geprüfte Direktlink zur
Einzelanzeige sein** (per `WebFetch` bestätigt) — keine Such-/Listenseiten, keine
geratenen URLs. Kein verifizierbarer Einzel-Link → Treffer nicht listen/importieren.
- **100 % Remote live belegt.** Vor dem Listen/Importieren im Anzeigentext bestätigen,
dass die Stelle wirklich vollständig remote ist. „Homeoffice möglich"/hybrid/feste
Präsenztage genügen **nicht** → verwerfen.
- **Nur Original-Anzeigen echter Arbeitgeber.** Headhunter/Personalvermittler,
Zeitarbeit/Arbeitnehmerüberlassung und **Stepstone** sind ausgeschlossen — weder
listen noch importieren (siehe „Quellen-Politik"). Bevorzugt der Firmen-Direktlink.
- **`beschreibung` = vollständige Ausschreibung.** Immer den kompletten, per WebFetch
erfassten Anzeigentext (Aufgaben, Anforderungen, Benefits, Remote-Regelung,
Bewerbungsweg, Kennziffer) übernehmen — nicht kürzen oder zusammenfassen.
- **Kontakt-E-Mail nicht erfinden.** `kontakt_email` nur setzen, wenn die Adresse in
der Anzeige oder auf der Firmen-Karriereseite belegt ist; sonst weglassen.
- **Firmensitz recherchieren, nicht erfinden.** `adresse` = belegter Firmensitz aus
dem Impressum; sonst leer lassen. Kein Filterkriterium — nur Zusatzinfo.
- **Keine Doppelfunde — eine Firma nur EINMAL, auch über Läufe und Skills hinweg.**
Vor dem Listen/Importieren immer `applied-set.sh` **und** `blacklist-set.sh` laden
und jeden Kandidaten per `quelle_url`, `external_id` **und `firma_slug`
(firmenweit)** sowie gegen die Blacklist abgleichen. Dedup/Blacklist sind mit dem
regionalen Skill **gemeinsam**. **Steht die Firma schon im Applied-Set oder auf der
Blacklist — egal unter welchem Stellentitel und egal in welcher Schreibweise/
Rechtsform (`firma_slug` gleich ODER Präfix-Match, mind. 2 Tokens) — wird kein
weiterer Treffer dieses Arbeitgebers ausgegeben oder importiert.** Auch **innerhalb
eines Laufs** jede Firma nur **einmal** präsentieren. Beim Import die `external_id`
**deterministisch aus `firma+stelle`** bilden (Präfix `remote-`), **nicht** aus
einer Board-/Anzeigen-ID — so ergibt dieselbe Stelle über jeden Lauf/jedes Board
dasselbe `external_id` (Server-Upsert statt Dublette). Der Server dedupliziert
`POST /joboffers` über `external_id` (Upsert) und die Blacklist (409, firmenweit
über `firma_slug`).
- **Blacklisten nach jedem Import (Pflicht, firmenweit).** Jeder frisch angelegte Job
(`action:created`) wird direkt anschließend per `POST /joboffers/blacklist`
(**`typ:firma`** — die ganze Firma) gesperrt (Schritt 7), damit dieser Arbeitgeber
in Folgeläufen mit keinem Titel wieder auftaucht. Nicht blacklisten bei `updated`
oder 409-Skip. Ein 409 beim `POST /joboffers` bedeutet „steht schon auf der
Blacklist" → überspringen, nie mit `force` erzwingen.
- **Interaktiv: nicht ungefragt importieren** — erst zeigen, dann auf Bestätigung
importieren. **Autonom/headless: automatisch importieren** (siehe „Autonomer
Modus"). In **keinem** Modus Bewerbungen anlegen/versenden.
- Board-Aggregatoren-Duplikate (dieselbe Stelle auf mehreren Portalen) zu einem
Eintrag zusammenfassen, bevorzugt mit Direkt-/Firmenwebsite-Link.
- Ist die Bewerbungs-Tracker-API nicht erreichbar (`GET /health`), Dedup nicht
möglich → Nutzer warnen und Treffer ohne Dedup mit Hinweis liefern.
## Abhängigkeiten
- Tools: `WebSearch`, `WebFetch`.
- Skill `bewerbungs-tracker` (für Dedup-Daten, JobOffer-Import und Blacklist:
`GET/POST /joboffers/blacklist`).
- Scripts: `scripts/arbeitsagentur.sh` (strukturierte Jobsuche-API der Bundesagentur
für Arbeit, `search`/`detail` — hier mit `arbeitszeit=ho`), `scripts/applied-set.sh`
(erfasste Firmen), `scripts/blacklist-set.sh` (gesperrte Muster) — allesamt
**Symlinks** auf die geteilten Scripts des Skills `it-stellensuche` (eine Quelle der
Wahrheit). `applied-set`/`blacklist-set` nutzen den `bt.sh`-Helfer des
`bewerbungs-tracker`-Skills; `arbeitsagentur.sh` nur `curl` + `python3`.
@@ -0,0 +1 @@
../../it-stellensuche/scripts/applied-set.sh
@@ -0,0 +1 @@
../../it-stellensuche/scripts/arbeitsagentur.sh
@@ -0,0 +1 @@
../../it-stellensuche/scripts/blacklist-set.sh
+565
View File
@@ -0,0 +1,565 @@
---
name: it-stellensuche
description: Search the web for NEW jobs matching the user's own search profile (cities / remote mode, taken from the Bewerbungs-Tracker) that they have NOT applied to yet, then present them and optionally import them as JobOffers into the Bewerbungs-Tracker. Use whenever the user wants to find new job postings, "Stellensuche", "neue Stellen suchen", "Jobs finden", "Systemadministrator-Stellen", or refresh open job leads. Deduplicates against existing Bewerbungen and Jobangebote via the bewerbungs-tracker skill.
---
# Stellensuche (Suchprofil des jeweiligen Benutzers)
Findet **neue** Stellen im Web — **in den Städten bzw. im Modus, die das Suchprofil des
Benutzers vorgibt** —, filtert alle raus, für die sich der Nutzer schon beworben hat
**oder die auf der Blacklist stehen**, und spielt sie auf Wunsch als JobOffers in den
Bewerbungs-Tracker ein.
> **Multi-User (WICHTIG):** Der Tracker ist eine Plattform mit mehreren Benutzern; jeder
> hat **sein eigenes Suchprofil und seinen eigenen API-Key**. Region, Modus und
> Ausschlüsse stehen **nicht mehr in diesem Skill**, sondern kommen von außen:
>
> 1. **Aus dem Prompt**, wenn er sie nennt (so ruft der Jobsuche-Runner den Skill auf —
> er baut den Prompt aus dem Suchprofil des Benutzers). Der Prompt hat immer Vorrang.
> 2. **Sonst aus dem Tracker:** `GET /templates` (typ `Lebenslauf`) liefert Rolle und
> Technologien des Benutzers; nach Orten/Modus fragen, wenn der Prompt nichts sagt.
>
> Die Beispiele weiter unten (Rollen, Buzzwords, Städte) sind **nur Muster** — niemals
> ungefragt die Städte oder das Profil eines anderen Benutzers verwenden. Der API-Key in
> `BEWERBUNG_API_KEY` bestimmt, in wessen Konto importiert wird.
> **Blacklist-Prinzip (zentral):** Jeder erfolgreich importierte Job wird
> **unmittelbar danach geblacklistet**. So taucht dieselbe Stelle in späteren
> Suchläufen nie wieder auf — die Dublett-Vermeidung bleibt bestehen, selbst wenn
> das JobOffer später gelöscht oder in eine Bewerbung überführt wird. Umgekehrt
> werden Kandidaten, die schon auf der Blacklist stehen, gar nicht erst gelistet
> oder importiert.
## Suchprofil
**Quelle des Profils (in dieser Reihenfolge):**
1. **Der Prompt** — der Jobsuche-Runner hängt den Lebenslauf/das Kurzprofil des Benutzers
direkt an („--- PROFIL --- … --- ENDE PROFIL ---"). Rollen und Buzzwords **daraus**
ableiten.
2. **Der Tracker** — sonst `GET /templates` (typ `Lebenslauf`, sonst `Profil/Kurzprofil`)
des Benutzers, dem der `BEWERBUNG_API_KEY` gehört.
3. Erst wenn beides fehlt: aus den bereits erfassten Bewerbungen (`GET /applications`)
ableiten oder nachfragen.
**Rolle:** breit fassen — dieselbe Tätigkeit läuft je nach Firma unter vielen Titeln.
Passende Bezeichnungen als Query-Varianten und für die Relevanzbewertung nutzen.
> Die folgende Liste ist ein **Beispiel für ein IT-Infrastruktur-Profil** — sie zeigt, wie
> breit zu fassen ist, ist aber **kein Default**. Für einen Benutzer mit anderem Beruf die
> Titel entsprechend aus **seinem** Lebenslauf bilden.
- **Kern (Beispiel: 2nd-Level-Sysadmin + IT-Consultant):**
Systemadministrator, IT-Administrator, IT-Systemadministrator, IT-Systemadministration,
IT-Systemadministrator 2nd Level, 2nd-Level-Administrator, System-Administrator (m/w/d),
Fachinformatiker Systemintegration, Administrator (m/w/d).
- **Support/Betrieb:** 1st/2nd/3rd-Level-Support, 2nd-Level-Support, IT-Support,
IT-Supporter, Support-Techniker, Helpdesk-Mitarbeiter, IT-Techniker,
IT-Systemtechniker, Systemtechniker, IT-Systemelektroniker, Servicetechniker IT,
Onsite-Techniker, Field-Service-Techniker, IT-Systembetreuer, IT-Betreuer,
IT-Allrounder, IT-Koordinator, IT-Mitarbeiter, IT-Operations / IT-Betrieb,
IT-Administrator EDV, EDV-Administrator, EDV-Betreuer.
- **Infrastruktur/Netz:** System Engineer, Systems Engineer, Infrastructure Engineer,
IT-Infrastruktur, Infrastruktur-Administrator, Netzwerkadministrator, Network Engineer,
Virtualisierungsadministrator, Storage-/Backup-Administrator, Rechenzentrums-Techniker,
Datacenter-Techniker.
- **Cloud/Hosting/Modern Workplace (passt zu Hetzner/OVH im CV):** Cloud-Administrator,
Cloud Engineer, Cloud-Operations-Engineer, Hosting-Administrator, Hosting Engineer,
Microsoft-365-Administrator, M365-/Modern-Workplace-Administrator,
Azure-/Entra-Administrator, DevOps-Engineer (Infrastruktur-lastig),
Linux-Administrator, Linux System Engineer, Windows-Administrator.
- **Beratung/Leitung (klein) — deckt die CV-Rolle „IT-Consultant" ab:** IT-Consultant,
IT-Berater (technisch, hands-on), IT-Systemberater, IT-Kundenberater (technisch),
IT-Teamleiter/IT-Leiter in kleinen Firmen (nur wenn hands-on).
Ausschließen bleiben reine Softwareentwickler-, Data-Science-, Vertriebs- und
SAP-only-Stellen, außer sie passen klar zum Infrastruktur-Profil.
> **Ex-Arbeitgeber IMMER ausschließen (harte Regel, unabhängig von der Schreibweise):**
> Stellen der **„IT-Problemlöser"** (Essen) — voller Firmenname u. a.
> **„IT Problemlöser Verwaltungs- und Handels GmbH"**, auch **„IT-Problemlöser GmbH"**,
> **„IT Problemlöser"** o. Ä. — werden **nie** gelistet oder importiert. Es ist der
> frühere Arbeitgeber des Nutzers (siehe Lebenslauf). Diese Firma taucht immer wieder
> mit **wechselnden Stellentiteln** auf; die `firma_stelle`-Blacklist greift dann nicht,
> weil der Titel abweicht. Deshalb **jeden Treffer verwerfen, dessen Firmenname `IT
> Problemlöser` / `IT-Problemlöser` (in jeder Rechtsform-/Schreibvariante) enthält** —
> ohne auf die Blacklist zu warten. (Firmenweite `firma`-Blacklist-Einträge greifen
> inzwischen schreibweisen-robust über `firma_slug` inkl. Präfix-Match — diese
> Modell-Regel bleibt trotzdem als zusätzliche Sicherung bestehen.)
**Region: kommt aus dem Suchprofil des Benutzers, nicht aus diesem Skill.**
Der Prompt nennt entweder eine **Städteliste** (regionaler Modus) oder **100 % Remote /
deutschlandweit** — oder beides. Es gilt strikt:
- **Regionaler Modus:** **Nur** Stellen mit Arbeitsort in einer der **genannten** Städte.
Die **erste genannte Stadt ist der Wohnort** und wird am höchsten priorisiert, danach
in der angegebenen Reihenfolge. Orte **außerhalb** dieser Liste werden **verworfen**
auch das direkte Umland, und **auch reine Homeoffice-/Remote-Stellen ohne Sitz in einer
der genannten Städte**. Sitzt der Arbeitgeber in einer der Städte und bietet zusätzlich
Homeoffice, ist die Stelle zulässig.
- **Remote-Modus:** Nur Stellen, die **zu 100 % im Homeoffice** ausübbar sind
(deutschlandweit, Firmensitz egal). Präsenz, Hybrid mit festen Bürotagen und vages
„Homeoffice möglich" werden verworfen; die 100-%-Zusage live im Anzeigentext bestätigen.
Nennt der Prompt keine Region, **nachfragen** statt zu raten — niemals auf eine im Skill
hinterlegte Städteliste zurückfallen (die gibt es bewusst nicht mehr).
**Skills/Buzzwords** (für Query-Varianten & Relevanzbewertung): Die **Kernbegriffe kommen
aus dem Lebenslauf des jeweiligen Benutzers** (höchste Gewichtung); ergänze naheliegende,
dazu passende Technologien — als Suchbegriffe und zum Erkennen passender Anzeigen, auch
wenn sie nicht wörtlich im CV stehen.
> Auch die folgende Liste ist ein **Beispiel** (IT-Infrastruktur). Für andere Profile die
> Kernbegriffe aus **deren** Lebenslauf ziehen, nicht von hier.
- **Kern (Beispiel, aus einem Infrastruktur-CV):** Windows Server, Active Directory,
Exchange, Microsoft 365 / MS365, Linux-Administration (Debian/Ubuntu),
Docker/Container, Proxmox/Virtualisierung, Netzwerk (OPNsense, pfSense,
VPN: WireGuard/OpenVPN/IPsec, LAN/WAN, TCP/IP), Firewall, Monitoring
(Prometheus, Grafana, Beszel, Uptime Kuma), Hetzner/OVH Cloud, Self-Hosting,
Automatisierung, AI/Claude/Agent-Workflows.
- **Microsoft-/Windows-Umfeld:** Entra ID / Azure AD, Intune, Endpoint Manager,
Group Policy / GPO, WSUS, PowerShell, Hyper-V, SharePoint, Teams, Windows 10/11,
Client-Management, MDM.
- **Virtualisierung/Server:** VMware vSphere / ESXi, Hyper-V, Citrix, KVM,
Terminalserver / RDS, Server-Hardware (Dell/HPE/Lenovo).
- **Backup/Storage:** Veeam, Backup & Recovery, Datensicherung, NAS/SAN, TrueNAS,
ZFS, Ceph, Synology, QNAP.
- **Netzwerk/Security:** VLAN, Routing & Switching, Cisco, Fortinet/FortiGate,
Sophos, Ubiquiti/UniFi, DNS/DHCP, Reverse Proxy (Nginx, Traefik), Let's Encrypt,
IT-Security, Patch-Management, ISO 27001, IT-Grundschutz.
- **Cloud/DevOps/Automatisierung:** Microsoft Azure, AWS, Kubernetes, Ansible,
Terraform, GitLab / CI/CD, Bash-/Shell-Scripting, Portainer, Nextcloud.
- **Datenbanken/Web:** MySQL/MariaDB, PostgreSQL, MS SQL Server, Apache, Nginx, IIS.
- **Betrieb/Organisation:** Ticketsystem (Jira, OTRS, Zammad), Helpdesk,
Rechenzentrum, On-Premise, Managed Services, IT-Dienstleister, Systemhaus.
> Aktuelles Profil (Titel, Skills, Ort) steht in der Lebenslauf-Vorlage der API:
> `bewerbungs-tracker` → `GET /templates` (typ `Lebenslauf`). Bei Bedarf dort
> gegenlesen, statt zu raten.
## Quellen-Politik (WICHTIG)
Ziel ist **immer die Original-Stellenausschreibung direkt beim echten Arbeitgeber**
— bevorzugt auf dessen **Firmen-/Karriereseite**.
- **Ausschließen (nicht listen, nicht importieren):**
- **Headhunter / Personalvermittler / Personalberatungen** (recruiten für Dritte).
- **Zeitarbeit / Arbeitnehmerüberlassung / Personaldienstleister** (z. B. Randstad,
Hays, GULP, Amadeus FiRe, Adecco, Manpower, Piening, Tempton, DIS AG, Robert Half
u. ä. — auch unbekannte, wenn die Anzeige „Arbeitnehmerüberlassung",
„im Kundenauftrag", „für unseren Kunden", „Personaldienstleister" o. Ä. nennt).
- **Stepstone** als Quelle — nicht als Board nutzen, keine `site:stepstone.de`-Query,
keine Stepstone-Links importieren.
- **Erkennungsmerkmale eines Vermittlers/Zeitarbeit** (bei Unsicherheit die
Firmen-/Impressumsseite per `WebFetch` prüfen): Formulierungen wie „für unseren
Kunden", „im Auftrag unseres Mandanten", „Arbeitnehmerüberlassung", „Direktvermittlung",
„Personaldienstleistung", „Recruiting-Partner"; oder die inserierende Firma ist erkennbar
eine Personal-/Recruiting-Agentur. → **verwerfen**.
- **Zulässig als Quelle**, wenn es zur Original-Anzeige des Arbeitgebers führt:
Firmen-Karriereseite (bevorzugt), Arbeitsagentur/Bundesagentur für Arbeit, Indeed
oder LinkedIn **nur** wenn die Anzeige eindeutig vom echten Arbeitgeber (nicht von
einem Vermittler) stammt. Führt ein Board-Treffer zu einer Firma, deren eigene
Karriereseite dieselbe Stelle direkt listet, **immer den Firmen-Direktlink** nehmen.
## Workflow
1. **Bereits erfasste Jobs + Blacklist laden** (Dedup-Basis). Dieser Schritt ist
**immer zuerst** auszuführen, damit Stellen aus früheren Suchläufen **nicht
erneut** gefunden/importiert werden. Zwei Quellen:
```bash
scripts/applied-set.sh # firma_slug<TAB>firma<TAB>stelle<TAB>ort<TAB>quelle_url<TAB>external_id
scripts/blacklist-set.sh # id<TAB>typ<TAB>firma<TAB>stelle<TAB>ort<TAB>domain<TAB>url_norm<TAB>firma_norm<TAB>firma_slug<TAB>stelle_norm<TAB>ort_norm<TAB>grund
```
- **applied-set** (Bewerbungen **und** importierte Jobangebote): **eine Zeile
pro bereits erfasster Firma** (company-level, nicht mehr pro Stelle). Je Zeile
drei Dublett-Merkmale merken: **`firma_slug`** (robuster Firmen-Schlüssel:
lowercase, Umlaute→ae/oe/ue/ss, `(m/w/d)` raus, End-Rechtsform wie gmbh/ag/kg
entfernt, mit `-` verbunden), **`quelle_url`** und **`external_id`**.
- **blacklist-set** (gesperrte Muster): je Zeile den **`typ`** und die dazu
passenden Felder merken (`url_norm`, `domain`, `firma_norm`, **`firma_slug`**,
`stelle_norm`). Die `*_norm`/`firma_slug`-Felder liefert der Server bereits
normalisiert (lowercase, Tracking-Parameter entfernt, `(m/w/d)`/Rechtsform
bereinigt).
2. **Kandidaten sammeln — strukturierte Quelle zuerst, dann WebSearch.**
**(a) Arbeitsagentur-API (primär — frisch, hohe Trefferzahl, strukturiert).** Vor
der Freitext-Suche die Jobsuche-API der Bundesagentur für Arbeit abfragen. Sie
liefert frische, deduplizierbare Treffer mit Firma, Ort, Datum und stabiler
`refnr` — genau das, was reine WebSearch nicht zuverlässig hergibt. Pro Rolle einen
Umkreis-Query:
```bash
scripts/arbeitsagentur.sh search "<Rolle>" "<Stadt>" 15 30 50
# -> firma_slug<TAB>firma<TAB>titel<TAB>ort<TAB>datum<TAB>refnr<TAB>angebotsart
```
Rollen aus dem Suchprofil durchrotieren (Systemadministrator, IT-Administrator,
Fachinformatiker Systemintegration, IT-Support, IT-Techniker, System Engineer,
Netzwerkadministrator, …). Städte: **Gladbeck** deckt per Umkreis 1520 km
Bottrop/Gelsenkirchen/Essen/Oberhausen weitgehend mit ab; **Dorsten** und **Marl**
je separat (`wo=Dorsten`, `wo=Marl`, Umkreis ~10). `tage` (Default 30) steuert die
Frische. Achtung: der Umkreis zieht auch **Nachbarorte außerhalb der sieben
Städte** rein (z. B. Haltern, Recklinghausen, Herten) — die **harte Regionsregel**
(nur die sieben Städte, Schritt 3) über die `ort`-Spalte anwenden und Fremdorte
**verwerfen**. Jede verbleibende Zeile **sofort per `firma_slug`** gegen
applied-set/blacklist prüfen (Firmen-Dedup, Schritt 1) — bereits erfasste/gesperrte
Firmen raus. Für
**überlebende** Treffer die Details holen:
```bash
scripts/arbeitsagentur.sh detail <refnr>
```
Detail liefert **Volltext** (→ `beschreibung`), **`EXTERNE_URL`** (Original-Link des
Arbeitgebers → bevorzugt `quelle_url`), **Adresse**, **Vergütung** und die Flags
**`ZEITARBEIT_AUE` / `PRIVATE_ARBEITSVERMITTLUNG`** — steht dort `True`, ist es
Zeitarbeit/Vermittler → **verwerfen** (ohne WebFetch, spart einen Schritt). Meldet
das Detail „keine aktive Anzeige", ist sie abgelaufen → **verwerfen**. Die `refnr`
ist stabil → als Existenz-Nachweis und für die `external_id` nutzbar. Führt
`EXTERNE_URL` nur auf ein Board (nicht den Arbeitgeber), die Firmen-Karriereseite
suchen und deren Direktlink bevorzugen (Quellen-Politik gilt unverändert).
**(b) WebSearch (ergänzend — für alles, was nicht in der API steht).** Mit dem
`WebSearch`-Tool **viele** Queries fahren — Rolle × Ort für **jede der sieben
Städte**, mit Fokus auf **Original-Anzeigen der Arbeitgeber**. Nur diese sieben
Orte abfragen, keine weiteren Revierstädte.
> **Query-Strategie (Recall maximieren — WICHTIG, findet die Suche „kaum noch
> Stellen"):** Lieber **viele einfache, natürlichsprachliche Einzel-Queries** als
> wenige überladene. **Boolesche Operatoren sparsam einsetzen** — `OR`, `site:`,
> `inurl:` und `-minus`-Ausschlüsse **senken bei WebSearch oft drastisch die
> Trefferzahl**. Statt eine `A OR B OR C … -stepstone -zeitarbeit`-Monsterquery
> lieber **getrennte, kurze Queries** absetzen und Vermittler/Stepstone erst
> **nachträglich beim Filtern** (Schritt 3) rauswerfen. Pro Stadt **mehrere
> Formulierungen** durchvariieren:
> **Rolle × Ort × Suffix**, mit den Suffixen `Stellenangebot`, `Job`, `Jobs`,
> `Stelle`, `Vollzeit`, `Festanstellung`, `unbefristet`, `gesucht`, `m/w/d`, `2026`
> und **ganz ohne Suffix** (nur `Rolle Ort`). Zusätzlich **Buzzword × Ort**
> (s. u.). So deckt jede Stadt 1015 verschiedene Queries ab statt 12.
**Rolle × Ort (je Stadt mehrere Suffixe durchspielen), Beispiele:**
- `IT-Systemadministrator Gladbeck Stellenangebot`, `Systemadministrator Gladbeck`,
`IT-Administrator Gladbeck Vollzeit`, `IT Systemadministrator Gladbeck m/w/d`
- `Systemadministrator Essen Jobs`, `2nd Level Support Essen`,
`IT-Administrator Essen Festanstellung`, `IT Systemadministrator Essen gesucht`
- `Fachinformatiker Systemintegration Oberhausen`, `IT-Administrator Oberhausen Stelle`,
`Systemadministrator Oberhausen unbefristet`
- `IT Administrator Gelsenkirchen Stellenangebot`, `Systemadministrator Gelsenkirchen`,
`IT-Systemadministrator Gelsenkirchen 2026`, `IT-Support Gelsenkirchen`
- `Systemadministrator Bottrop Jobs`, `IT-Administrator Bottrop`,
`IT Systemadministrator Bottrop Vollzeit`, `EDV-Administrator Bottrop`
- `IT-Administrator Dorsten Stellenangebot`, `Systemadministrator Dorsten`,
`IT-Techniker Dorsten Job`
- `Systemadministrator Marl Stellenangebot`, `IT-Administrator Marl`,
`IT Systemadministrator Marl Vollzeit`, `IT-Techniker Marl Job`
- Weitere Titel je Stadt durchrotieren: `System Engineer <Ort>`,
`IT-Systembetreuer <Ort>`, `Cloud Administrator <Ort>`, `Linux Administrator <Ort>`,
`Netzwerkadministrator <Ort>`, `IT-Consultant <Ort>`, `IT-Support <Ort>`.
- **Buzzword × Ort** (holt Anzeigen, die im Titel anders heißen, inhaltlich aber
passen — s. Skills/Buzzwords oben): `Active Directory <Ort> Stellenangebot`,
`Microsoft 365 Administrator <Ort>`, `VMware <Ort> IT Job`, `Proxmox <Ort>`,
`Hyper-V Administrator <Ort>`, `Veeam <Ort> IT`, `PowerShell Administrator <Ort>`.
- **Deutsche Jobbörsen zusätzlich anzapfen** (breitere Abdeckung, jeweils als
eigene, einfache Query — nicht stapeln): `<Rolle> <Ort> meinestadt`,
`<Rolle> <Ort> kimeta`, `<Rolle> <Ort> yourfirm`, `<Rolle> <Ort> arbeitsagentur`,
`<Rolle> <Ort> indeed`. Falls doch ein `site:`-Filter, dann **einzeln**:
`site:arbeitsagentur.de Systemadministrator <Ort>`, `site:de.indeed.com
IT-Administrator <Ort>`, `site:de.linkedin.com/jobs Systemadministrator <Ort>`.
**Kein** `site:stepstone.de`. Von Board-Treffern stets zur Original-Anzeige des
Arbeitgebers durchklicken.
- **Firmen-Karriereseiten gezielt** (bevorzugt): `IT-Administrator Essen Karriere`,
`Systemadministrator Gelsenkirchen Karriere Stellenangebot`,
`IT Jobs <Ort> Karriere` — Vermittler/Stepstone später beim Filtern aussortieren,
nicht per `-minus` in der Query.
Für Details/Ort/Firma einer Trefferseite `WebFetch` nutzen. Sieht ein Treffer nach
Firmen-Karriereseite aus: dort direkt nach der Einzelanzeige suchen.
2b. **Firmen zuerst finden, dann deren Karriereseite prüfen** (Hauptquelle für
„unentdeckte" Stellen). Board-Suchen finden nur, was breit ausgeschrieben ist —
viele Arbeitgeber posten IT-Stellen **ausschließlich auf der eigenen Website**.
Deshalb parallel zur Rolle-×-Ort-Suche **lokale Arbeitgeber in den sieben Städten
identifizieren** und deren Karriereseite direkt öffnen:
- **Arbeitgeber recherchieren** je Stadt — Firmen, die typischerweise interne IT
oder IT-Dienstleistung haben: Systemhäuser/IT-Dienstleister, Industrie &
Mittelstand, Chemie/Logistik (Essen, Oberhausen, Gelsenkirchen), Stadtwerke &
kommunale Betriebe, Krankenhäuser/Kliniken, Hochschulen, Versicherungen/Banken,
größere Handels-/Handwerksbetriebe. Beispiel-Queries:
`größte Arbeitgeber Essen IT`, `Systemhaus Gelsenkirchen`,
`IT-Dienstleister Oberhausen`, `Unternehmen Gladbeck IT-Abteilung`,
`Stadtwerke Bottrop Karriere`, `Klinikum Dorsten Stellenangebote IT`,
`Chemiepark Marl IT`, `Stadtwerke Marl Karriere`.
- **Karriereseite direkt anfahren:** zu jeder gefundenen Firma
`WebSearch "<Firma> Karriere"` bzw. `"<Firma> Stellenangebote"` und die
Jobliste per `WebFetch` öffnen; auf offene **IT-/Administrator-/Support-Stellen**
am jeweiligen Standort prüfen. Auch `inurl:karriere`, `inurl:jobs`,
`inurl:stellen` gezielt einsetzen und gängige Bewerber-Portale erkennen
(z. B. `/karriere`, `jobs.<firma>.de`, softgarden/onlyfy/Personio/Workday-Seiten).
- **Buzzwords als Türöffner:** findet die reine Titel-Suche wenig, mit den
Technologie-Buzzwords (oben) über die Stadt suchen, z. B.
`VMware Essen Stellenangebot`, `Active Directory Gelsenkirchen Job`,
`Microsoft 365 Administrator Oberhausen`, `Linux Bottrop IT` — so tauchen
Anzeigen auf, die im Titel anders heißen, inhaltlich aber passen.
- Jede so gefundene Stelle läuft durch dieselben Prüf-/Filter-/Dedup-Schritte
(37). Der Firmen-Direktlink ist hier ohnehin schon die bevorzugte `quelle_url`.
3. **Filtern & bewerten:**
- **Region (harter Ausschluss):** **nur** die sieben Städte Gladbeck, Essen,
Gelsenkirchen, Oberhausen, Bottrop, Dorsten, Marl. Jede Stelle mit Arbeitsort außerhalb
dieser sieben Städte **verwerfen** — auch übrige Revierstädte und reines
Homeoffice/Remote ohne Sitz in einer der sieben Städte. **Nach Nähe zu Gladbeck
priorisieren** (Gladbeck > Bottrop/Gelsenkirchen > Essen/Oberhausen > Dorsten/Marl).
- **Rolle:** Systemadministration/IT-Infrastruktur; keine reinen Entwickler-,
Vertriebs- oder SAP-only-Stellen (außer sie passen klar zum Profil).
- **Quelle (harter Ausschluss):** Headhunter/Personalvermittler, Zeitarbeit/
Arbeitnehmerüberlassung und Stepstone werden **verworfen** — siehe
„Quellen-Politik". Nur Original-Anzeigen echter Arbeitgeber behalten.
- **Dedup (persistent, FIRMEN-basiert):** **Eine Firma darf nur EINMAL gefunden
werden.** Treffer **verwerfen**, sobald **eines** zutrifft: (a) gleiche/sehr
ähnliche `quelle_url`, (b) gleiche `external_id`, oder (c) **die Firma steht
schon im Applied-Set** — unabhängig vom Stellentitel. Firmen-Gleichheit über
`firma_slug` prüfen: Kandidaten-Firma ebenso sluggen (lowercase, Umlaute→
ae/oe/ue/ss, `(m/w/d)` & End-Rechtsform raus, mit `-` verbunden) und als
**dieselbe Firma** werten, wenn der Slug **gleich** ist ODER **ein Slug ein
führendes Bindestrich-Präfix des anderen** ist (mind. 2 Tokens) — so zählen
**verschiedene Schreibweisen/Rechtsform-/Namensvarianten derselben Firma als
eine** (z. B. „IT-Problemlöser GmbH" = „IT Problemlöser Verwaltungs- und Handels
GmbH"; „Stadtwerke Essen" = „Stadtwerke Essen Netz GmbH"). Zusätzlich gesunder
Menschenverstand: ist offensichtlich derselbe Arbeitgeber gemeint, als Dublette
verwerfen. Distinkte Firmen mit nur gleichem ersten Wort (z. B. „Meyer IT" vs.
„Meyer Logistik") sind **nicht** dieselbe Firma. So taucht kein Arbeitgeber aus
früheren Läufen erneut auf. Im Zweifel behalten und als „evtl. Dublette" markieren.
- **Blacklist (harter Ausschluss):** Kandidat **verwerfen** (nicht listen, nicht
importieren), sobald er auf einen Blacklist-Eintrag passt — je nach `typ`:
- `url` → normalisierte Kandidaten-URL == `url_norm`.
- `domain` → Host der Kandidaten-URL == `domain` (auch Subdomains).
- `firma` → **dieselbe Firma** wie der Eintrag (Kandidaten-`firma_slug` gleich
`firma_slug` **oder** Präfix-Match wie bei der Dedup; `firma_norm` nur als
Fallback für Alt-Einträge ohne Slug).
- `firma_stelle` → dieselbe Firma (`firma_slug`, wie oben) **und** `stelle_norm`
passen (ist `ort_norm` gesetzt, muss auch der Ort passen).
- `auto` → wie die konkreten Felder, die der Eintrag trägt.
Diese Muster sind bewusst blockiert (u. a. jeder früher importierte Job, siehe
Schritt 6) — geblacklistete Stellen daher **stillschweigend überspringen**,
nicht als „evtl. Dublette“ präsentieren.
- **Relevanz:** höher gewichten, je mehr Buzzwords aus dem Profil passen.
4. **Existenz prüfen, Link prüfen, Kontakt-E-Mail & Firmenadresse recherchieren** —
für jeden Treffer, der in die engere Wahl kommt, **zwingend einzeln**:
- **Existenz-Prüfung (Pflicht):** Jede Stelle mit `WebFetch` auf dem Einzel-Link
öffnen und bestätigen, dass die Anzeige **noch aktiv** ist (Firma + Stelle
stehen dort, kein 404/„Stelle nicht mehr verfügbar"/Redirect auf Jobliste).
Snippets aus der WebSearch reichen **nicht** — Suchindizes zeigen oft schon
abgelaufene Anzeigen. Kein Live-Nachweis → Treffer **verwerfen** (nicht listen,
nicht importieren). Bei bereits importierten Angeboten, die nicht mehr existieren,
das JobOffer löschen (`DELETE /joboffers/{id}`).
- **Vollständige Stellenausschreibung erfassen (Pflicht):** Beim `WebFetch` der
Einzelanzeige den **kompletten Ausschreibungstext** übernehmen — nicht nur eine
Kurzzusammenfassung. Dazu gehören: Einleitung/Unternehmensvorstellung, **Aufgaben/
Tätigkeiten**, **Anforderungen/Profil**, **Wir bieten/Benefits**, Angaben zu
Arbeitszeit/Vertragsart/Standort, Gehalt (falls genannt), Bewerbungsweg/Kontakt
und Referenz-/Kennziffer. Text weitgehend **wortgetreu und vollständig** sichern
(nur Navigations-/Cookie-/Footer-Boilerplate der Seite weglassen), inkl. der
Gliederung/Überschriften. Dieser Volltext wandert in `beschreibung` (Schritt 6).
Ist die Seite lang/abgeschnitten, `WebFetch` gezielt erneut aufrufen, um alle
Abschnitte zu bekommen.
- **`quelle_url` verifizieren:** Muss auf die **konkrete, noch aktive
Einzelanzeige** zeigen (nicht Trefferliste/Suchseite). Deeplink defekt/Liste →
per Suche den echten Einzel-Link finden (bevorzugt Firmenwebsite/Karriereseite),
sonst verwerfen.
- **Vermittler/Zeitarbeit aussortieren (Pflicht):** Zeigt der Treffer auf eine
Personalberatung/Headhunter/Zeitarbeit statt den echten Arbeitgeber, **verwerfen**
(nicht listen, nicht importieren). Bei Unsicherheit die inserierende Firma per
`WebFetch` (Impressum/„Über uns") prüfen — Recruiting-/Personaldienstleister raus.
Existiert dieselbe Stelle direkt auf der Firmen-Karriereseite, diese
Original-Anzeige verwenden statt des Vermittler-Treffers.
- **Bewerber-Kontakt-E-Mail recherchieren:** Die E-Mail-Adresse ermitteln, an die
sich Bewerber wenden. Reihenfolge: (a) direkt in der Stellenanzeige genannte
Bewerbungs-/Kontaktadresse; (b) Karriere-/Kontaktseite der Firma
(`WebSearch` „<Firma> Karriere Kontakt Bewerbung E-Mail“, `WebFetch` der Seite);
(c) allgemeine Bewerbungsadresse (`bewerbung@`/`jobs@`/`karriere@<domain>`) nur,
wenn auf der Firmenseite belegt. **Nicht raten** — nur Adressen übernehmen, die
belegt sind; sonst `kontakt_email` leer lassen und als „nicht gefunden“ markieren.
- **Firmenadresse recherchieren (Pflicht):** Die **genaue Anschrift** des
Arbeitgebers ermitteln (Straße, Hausnummer, PLZ, Ort) — und zwar die, die
**exakt zu dieser Stelle passt**. Reihenfolge: (a) in der Stellenanzeige selbst
genannter Einsatz-/Arbeitsort (oft unter „Standort"/„Einsatzort"/am Anzeigenende);
(b) Impressum bzw. Kontakt-/Standortseite der Firma
(`WebSearch` „<Firma> Impressum Adresse <Ort>“, `WebFetch` der Seite).
⚠️ **Adresse muss zur Stelle passen:** Hat die Firma **mehrere Standorte/
Niederlassungen**, die Anschrift des **konkreten Arbeitsorts der Stelle** nehmen —
**nicht** blind die Zentrale/Hauptsitz-Adresse, wenn die Stelle an einem anderen
Standort ausgeschrieben ist. Nennt die Anzeige nur eine Stadt (`ort`), aber keine
Straße, gezielt die zu **dieser Stadt** gehörende Niederlassungsadresse suchen und
verifizieren. **Nicht raten** — nur eine belegte, zum Stellen-Ort passende Adresse
übernehmen; sonst `adresse` leer lassen und als „nicht gefunden“ markieren.
Passt die einzige auffindbare Firmenadresse **nicht** zum `ort` der Stelle
(anderer Standort), lieber leer lassen als eine falsche Zentrale eintragen.
5. **Ergebnis präsentieren** als Tabelle: Firma · Stelle · Ort · **Adresse** ·
Quelle (Board) · **verifizierter Link** · **Kontakt-E-Mail** · kurze
Passt-Begründung. Sortierung: neue, klar passende Treffer **nach Nähe zu Gladbeck**
(Gladbeck zuerst, dann Bottrop/Gelsenkirchen, dann Essen/Oberhausen, dann Dorsten/Marl).
Fehlt Link, E-Mail oder Adresse, kennzeichnen.
6. **Importieren** — im **interaktiven** Modus nur nach Rückfrage/Bestätigung des
Nutzers; im **autonomen Modus** (siehe unten) automatisch ohne Rückfrage. Jeden
neuen Treffer als JobOffer einspielen (Upsert, idempotent) über den
`bewerbungs-tracker`-Skill:
```bash
~/.claude/skills/bewerbungs-tracker/scripts/bt.sh POST /joboffers '{
"external_id": "stellensuche-<slug(firma)>-<slug(stelle)>",
// IMMER setzen und DETERMINISTISCH aus
// firma+stelle bilden — NICHT aus der
// Board-/Anzeigen-ID. Slug = normalisiert
// wie der firma_slug (lowercase, ohne (m/w/d)
// & Rechtsform, Interpunktion→"-"), z. B.
// "stellensuche-musterfirma-it-systemadministrator".
// So bekommt DIESELBE Stelle über jeden
// Lauf UND jedes Board dasselbe external_id
// → Server-Upsert greift zuverlässig statt
// eine Dublette anzulegen. (Eine flüchtige
// Board-ID würde je Quelle abweichen und so
// Duplikate erzeugen — daher NICHT nutzen.)
"quelle": "stellensuche",
"firma": "…", "stelle": "…", "ort": "…",
"adresse": "<genaue Anschrift des Arbeitsorts der Stelle: Straße Hausnr, PLZ Ort;
recherchiert & verifiziert, passend zum Stellen-Standort; sonst weglassen>",
"gehalt": "…",
"beschreibung": "<VOLLSTÄNDIGE Stellenausschreibung>",
// Der komplette, in Schritt 4 per WebFetch
// erfasste Anzeigentext: Aufgaben, Anforderungen/
// Profil, Wir-bieten/Benefits, Arbeitszeit/
// Vertragsart, Standort, Gehalt, Bewerbungsweg,
// Referenz-/Kennziffer — wortgetreu und
// vollständig, nicht kürzen/zusammenfassen.
// Gliederung/Überschriften erhalten (Markdown
// ok). Nur Seiten-Boilerplate weglassen.
"quelle_url": "<verifizierter Einzel-Link, bevorzugt Firmen-Karriereseite>",
"art": "Firmenwebsite",
"anzeige_datum": "YYYY-MM-DD",
"kontakt_email": "<recherchierte Bewerber-E-Mail, sonst weglassen>",
"labels": ["Regional"] // IMMER setzen: dieser Skill sucht ortsgebunden in
// den sieben Städten → Arbeitsort-Label "Regional".
}'
```
`art`-Enum: `E-Mail`, `Online-Portal`, `Indeed`, `StepStone`, `Firmenwebsite`,
`Post`, `Initiativbewerbung`, `Arbeitsagentur`, `Sonstiges`. **Standard ist
`Firmenwebsite`** (Original-Anzeige des Arbeitgebers). `StepStone` nie verwenden
(ausgeschlossene Quelle); `Arbeitsagentur`/`Indeed` nur, wenn der Link direkt zur
Arbeitgeber-Anzeige führt.
**`409`-Antwort beim Import:** Steht der Job (trotz Vorfilter) auf der Blacklist,
antwortet `POST /joboffers` mit **409** (`BlacklistConflict`) und nimmt ihn
**nicht** auf. Das ist **kein Fehler** — als „übersprungen (Blacklist)" zählen
und mit dem nächsten Treffer weitermachen, **nicht** mit `force` erzwingen.
7. **Direkt blacklisten (nach jedem erfolgreichen Import).** Sobald ein JobOffer
angelegt wurde (Antwort **201** / `action:created`; bei `updated` ist der Job
bereits erfasst), die **Firma sofort ganz** blacklisten, damit dieser Arbeitgeber
in künftigen Läufen **mit keinem Stellentitel** wieder auftaucht (Regel „eine Firma
nur einmal"). Per `typ: firma` sperren — der Server matcht firmenweit über
`firma_slug` und deckt so abweichende Schreibweisen/Rechtsformen mit ab:
```bash
~/.claude/skills/bewerbungs-tracker/scripts/bt.sh POST /joboffers/blacklist '{
"typ": "firma",
"firma": "<firma wie importiert>",
"grund": "auto: stellensuche-import <YYYY-MM-DD>"
}'
```
Server-Antwort **201** = geblacklistet. Normalisierung (Groß/Klein, `(m/w/d)`,
Rechtsform, Umlaute) übernimmt der Server. Nur nach **echtem** Neuimport
blacklisten — nicht bei einem 409-Skip (steht ja schon drauf) und nicht bei
bloßem `updated`.
## Autonomer Modus (headless / geplant)
Wird der Skill **nicht-interaktiv** ausgeführt — d. h. ohne Nutzer, der bestätigen
kann (z. B. `claude -p "…" --dangerously-skip-permissions`, Cron/geplanter Lauf,
`ollama launch claude … -p …`) — im **autonomen Modus** arbeiten:
- **Ohne Rückfrage importieren:** Neue, geprüfte Treffer direkt als JobOffer
einspielen (Schritt 6) **und anschließend blacklisten** (Schritt 7). Es gibt
niemanden zum Bestätigen — nicht auf Eingabe warten.
- **Alle Prüfregeln gelten unverändert und strikt:** Existenz-Prüfung (Schritt 4),
korrekter verifizierter Einzel-Link, Kontakt-E-Mail nur wenn belegt, **Firmenadresse
nur wenn belegt und zum Stellen-Ort passend** (Schritt 4), die
persistente Dedup **und der Blacklist-Filter** (Schritt 1/3) — importiere **nur**
echte, live verifizierte, noch nicht erfasste und **nicht geblacklistete** Stellen.
Im Zweifel **nicht** importieren (lieber auslassen).
- **Keine Bewerbungen anlegen/versenden**, keine Löschungen bestehender Bewerbungen,
keine Generierung anstoßen — nur neue Jobangebote (`POST /joboffers`) einpflegen
und den jeweils importierten Job blacklisten (`POST /joboffers/blacklist`).
- **Kurzbericht ausgeben** (für das Log): je Treffer `action`
(created+blacklisted / updated / skip-dedup / skip-blacklist) mit Firma, Stelle,
Ort, Link; am Ende Zähler „X neu importiert & geblacklistet, Y als Dublette/
Blacklist übersprungen, Z verworfen (nicht verifizierbar)".
## Regeln
- **Nur sieben Städte.** Es werden **ausschließlich** Stellen mit Arbeitsort in
Gladbeck, Essen, Gelsenkirchen, Oberhausen, Bottrop, Dorsten oder Marl gelistet/importiert.
Alles außerhalb dieser sieben Städte (übriges Ruhrgebiet, reines Remote/Homeoffice ohne
Sitz in einer der sieben Städte) wird **verworfen**. Priorität nach Nähe zu Gladbeck.
- **Label `Regional` setzen (Pflicht).** Jedes von diesem Skill importierte JobOffer
bekommt `labels: ["Regional"]` (ortsgebundene Stelle in einer der sieben Städte).
Kein Remote-/Homeoffice-Label — solche Stellen sind Sache des Skills
`it-stellensuche-remote`.
- **Keine erfundenen Stellen.** Nur Jobs ausgeben, die per WebSearch/WebFetch real
belegt sind. **`quelle_url` muss der korrekte, geprüfte Direktlink zur
Einzelanzeige sein** (per `WebFetch` bestätigt) — keine Such-/Listenseiten, keine
geratenen URLs. Kein verifizierbarer Einzel-Link → Treffer nicht listen/importieren.
- **Nur Original-Anzeigen echter Arbeitgeber.** Headhunter/Personalvermittler,
Zeitarbeit/Arbeitnehmerüberlassung und **Stepstone** sind ausgeschlossen — weder
listen noch importieren (siehe „Quellen-Politik"). Bevorzugt der Firmen-Direktlink;
im Zweifel (Vermittler? Zeitarbeit?) die inserierende Firma prüfen und lieber
verwerfen.
- **`beschreibung` = vollständige Ausschreibung.** Immer den kompletten, per WebFetch
erfassten Anzeigentext (Aufgaben, Anforderungen, Benefits, Konditionen, Bewerbungsweg,
Kennziffer) übernehmen — nicht kürzen oder zusammenfassen. Inhalt aus der realen
Anzeige, nicht frei ergänzen.
- **Kontakt-E-Mail nicht erfinden.** `kontakt_email` nur setzen, wenn die Adresse in
der Anzeige oder auf der Firmen-Karriereseite belegt ist; sonst weglassen.
- **Firmenadresse recherchieren, nicht erfinden — und passend zur Stelle.** `adresse`
(Straße, Hausnummer, PLZ, Ort) im Web ermitteln und nur eine **belegte** Anschrift
eintragen, die **genau zum Arbeitsort der Stelle** gehört. Bei mehreren Standorten
die Niederlassung der ausgeschriebenen Stelle nehmen, **nicht** pauschal den
Hauptsitz. Passt keine verifizierte Adresse zum Stellen-Ort, `adresse` leer lassen
statt eine falsche Zentrale einzutragen.
- **Keine Doppelfunde — eine Firma nur EINMAL, auch über Läufe hinweg.** Vor dem
Listen/Importieren immer `applied-set.sh` **und** `blacklist-set.sh` laden und
jeden Kandidaten per `quelle_url`, `external_id` **und `firma_slug` (firmenweit)**
sowie gegen die Blacklist abgleichen. **Steht die Firma schon im Applied-Set oder
auf der Blacklist — egal unter welchem Stellentitel und egal in welcher
Schreibweise/Rechtsform (`firma_slug` gleich ODER Präfix-Match, mind. 2 Tokens) —
wird kein weiterer Treffer dieses Arbeitgebers ausgegeben oder importiert.** Auch
**innerhalb eines Laufs** jede Firma nur **einmal** präsentieren (bei mehreren
Stellen derselben Firma die am besten passende wählen). Beim Import die
`external_id` **deterministisch aus `firma+stelle`** bilden, **nicht** aus einer
Board-/Anzeigen-ID — so ergibt dieselbe Stelle über jeden Lauf/jedes Board dasselbe
`external_id` (Server-Upsert statt Dublette). Der Server dedupliziert
`POST /joboffers` über `external_id` (Upsert) und die Blacklist (409, firmenweit
über `firma_slug`).
- **Blacklisten nach jedem Import (Pflicht, firmenweit).** Jeder frisch angelegte Job
(`action:created`) wird direkt anschließend per `POST /joboffers/blacklist`
(**`typ:firma`** — die ganze Firma) gesperrt (Schritt 7), damit dieser Arbeitgeber
in Folgeläufen mit keinem Titel wieder auftaucht. Nicht blacklisten bei `updated`
oder bei einem 409-Skip. Ein 409 beim `POST /joboffers` bedeutet „steht schon auf
der Blacklist" → überspringen, nie mit `force` erzwingen.
- **Interaktiv: nicht ungefragt importieren** — erst zeigen, dann auf Bestätigung
importieren. **Autonom/headless: automatisch importieren** (siehe „Autonomer
Modus"). In **keinem** Modus Bewerbungen anlegen/versenden.
- Board-Aggregatoren-Duplikate (dieselbe Stelle auf mehreren Portalen) zu einem
Eintrag zusammenfassen, bevorzugt mit Direkt-/Firmenwebsite-Link.
- Ist die Bewerbungs-Tracker-API nicht erreichbar (`GET /health`), Dedup nicht
möglich → Nutzer warnen und Treffer ohne Dedup mit Hinweis liefern.
## Abhängigkeiten
- Tools: `WebSearch`, `WebFetch`.
- Skill `bewerbungs-tracker` (für Dedup-Daten, JobOffer-Import und Blacklist:
`GET/POST /joboffers/blacklist`).
- Scripts: `scripts/arbeitsagentur.sh` (strukturierte Jobsuche-API der Bundesagentur
für Arbeit — primäre Quelle, `search`/`detail`), `scripts/applied-set.sh` (erfasste
Firmen), `scripts/blacklist-set.sh` (gesperrte Muster). `applied-set`/`blacklist-set`
nutzen den `bt.sh`-Helfer des `bewerbungs-tracker`-Skills; `arbeitsagentur.sh` nutzt
nur `curl` + `python3` (öffentliche API, kein Key nötig).
+104
View File
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
# Prints the set of jobs the user has ALREADY engaged with, for deduplication
# during a job search — so the same job is never found/imported twice, also on
# LATER runs. Combines existing Bewerbungen (/applications) and already-imported
# Jobangebote (/joboffers) from the Bewerbungs-Tracker API.
#
# Output: EINE Zeile pro bereits erfasster FIRMA (company-level), tab-separated:
# firma_slug <TAB> firma <TAB> stelle <TAB> ort <TAB> quelle_url <TAB> external_id
#
# firma_slug = robuster Firmen-Schlüssel (lowercase, Umlaute ae/oe/ue/ss,
# Diakritika entfernt, (m/w/d) raus, End-Rechtsform-Tokens wie
# gmbh/ag/kg entfernt, mit "-" verbunden). Identisch zu firmaSlug
# in lib/blacklist.js des Servers — deckt Schreibweisen-/
# Rechtsform-/Umlaut-Varianten EINER Firma ab.
#
# Regel: eine FIRMA darf nur EINMAL gefunden werden. Ein neuer Treffer gilt als
# Dublette (→ verwerfen), wenn seine Firma zu EINER Zeile hier passt:
# * gleicher firma_slug, ODER
# * ein firma_slug ist ein führendes Bindestrich-Präfix des anderen (≥2 Tokens)
# — d. h. Kurzname vs. voller Firmenname ("it-problemloeser" ⊂
# "it-problemloeser-verwaltungs-und-handels"), ODER
# * gleiche quelle_url ODER gleiche external_id.
# Es wird bewusst NICHT mehr nach Stellentitel unterschieden — eine bereits
# erfasste Firma taucht mit KEINEM (auch nicht neuem) Titel wieder auf.
# HTML-Entities werden dekodiert.
#
# Reuses the bewerbungs-tracker skill's helper for auth/base-URL.
set -euo pipefail
BT="${BT_SCRIPT:-$HOME/.claude/skills/bewerbungs-tracker/scripts/bt.sh}"
if [[ ! -x "$BT" ]]; then
echo "applied-set.sh: bewerbungs-tracker helper not found at $BT" >&2
exit 2
fi
# In Tempdateien schreiben (NICHT in Env-Vars): die kombinierte JSON aus
# /applications + /joboffers wird groß; als Env-Var würde sie ARG_MAX (argv+envp)
# sprengen ("Argument list too long" beim python3-Start). Dateien lesen umgeht das.
TMPDIR_AS="$(mktemp -d)"
trap 'rm -rf "$TMPDIR_AS"' EXIT
APPS_FILE="$TMPDIR_AS/apps.json"
OFFERS_FILE="$TMPDIR_AS/offers.json"
"$BT" GET '/applications?limit=500' > "$APPS_FILE"
"$BT" GET '/joboffers' > "$OFFERS_FILE"
APPS_FILE="$APPS_FILE" OFFERS_FILE="$OFFERS_FILE" python3 <<'PY'
import os, json, html, re, sys, unicodedata
def load(name):
with open(os.environ[name], encoding='utf-8') as fh:
raw = fh.read().split('<http')[0]
try:
return json.loads(raw)
except Exception:
return []
def clean(v):
return html.unescape(str(v or '')).replace('\t', ' ').strip()
# Robuster Firmen-Schlüssel — MUSS mit firmaSlug() in lib/blacklist.js des
# Servers übereinstimmen (Umlaut-Translit, (m/w/d) raus, End-Rechtsform-Tokens
# entfernt, mit "-" verbunden), damit Skill- und Server-Dedup dieselbe Firma
# gleich erkennen.
_GENDER = re.compile(r'\((?:[mwdfax](?:\s*/\s*[mwdfax])*)\)', re.I) # (m/w/d), (w/m/x)…
_LEGAL_FORMS = {
'gmbh', 'ggmbh', 'mbh', 'ug', 'haftungsbeschraenkt', 'ag', 'kg', 'kgaa',
'ohg', 'gbr', 'se', 'ek', 'eg', 'ev', 'partg', 'partmbb', 'co', 'cie',
'inc', 'incorporated', 'llc', 'ltd', 'limited', 'plc', 'corp', 'corporation',
'company', 'sa', 'sarl', 'sas', 'bv', 'nv', 'oy', 'ab', 'as', 'aps', 'srl', 'spa',
}
def firma_slug(s):
t = html.unescape(str(s or '')).lower()
t = (t.replace('ä', 'ae').replace('ö', 'oe').replace('ü', 'ue').replace('ß', 'ss'))
t = unicodedata.normalize('NFKD', t)
t = ''.join(c for c in t if not unicodedata.combining(c))
t = _GENDER.sub(' ', t)
t = re.sub(r'[^a-z0-9]+', ' ', t)
t = re.sub(r'\s+', ' ', t).strip()
if not t:
return ''
words = t.split(' ')
kept = words[:]
while len(kept) > 1 and kept[-1] in _LEGAL_FORMS:
kept.pop()
return '-'.join(kept if kept else words)
# Eine Firma nur EINMAL: pro firma_slug genau eine Zeile (erste gewinnt).
seen = set()
def emit(firma, stelle, ort, url, ext):
slug = firma_slug(firma)
if not slug or slug in seen:
return
seen.add(slug)
print('\t'.join([slug, clean(firma), clean(stelle), clean(ort),
clean(url), clean(ext)]))
for a in load('APPS_FILE'):
emit(a.get('firma'), a.get('stelle'), a.get('ort'), a.get('quelle_url'), '')
for o in load('OFFERS_FILE'):
emit(o.get('firma'), o.get('stelle'), o.get('ort'), o.get('quelle_url'),
o.get('external_id'))
print(f'# {len(seen)} bereits erfasste Firmen (Bewerbungen + Jobangebote)', file=sys.stderr)
PY
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# Query the Bundesagentur für Arbeit Jobsuche-API — a structured, FRESH, high-recall
# primary source for the it-stellensuche skills that complements free-text WebSearch.
# Results come back already shaped for the skill's firma_slug-based Dedup, and a
# posting's full detail (Volltext, Bewerbungs-URL, Zeitarbeit/Vermittler-Flags,
# Adresse) can be pulled with one call — no WebFetch needed for the basics.
#
# Public, read-only API. The API key is the well-known public value
# "jobboerse-jobsuche" (same key the arbeitsagentur.de site itself uses).
#
# Usage:
# arbeitsagentur.sh search <was> <wo> [umkreis_km] [tage] [size] [arbeitszeit]
# Lists postings for role <was> around location <wo>. One API call.
# umkreis_km default 15, tage (veröffentlicht seit) default 30, size default 50.
# <wo> leer lassen ("") = deutschlandweit. arbeitszeit optional, u. a. "ho"
# (Homeoffice) für die Remote-Suche, sonst weglassen.
# Output: one posting per line, tab-separated:
# firma_slug <TAB> firma <TAB> titel <TAB> ort <TAB> datum <TAB> refnr <TAB> angebotsart
# firma_slug is identical to applied-set.sh / firmaSlug() in lib/blacklist.js,
# so a hit can be dedup'd against the applied-set/blacklist immediately.
#
# arbeitsagentur.sh detail <refnr>
# Full posting for a refnr (from a search row). Prints a readable field block
# plus the complete Beschreibung — treat it like a WebFetch of the ad. Carries
# istArbeitnehmerUeberlassung / istPrivateArbeitsvermittlung → drop Zeitarbeit/
# Vermittler here WITHOUT a fetch; externeURL → the employer's original link
# (bevorzugt als quelle_url, sofern es zum echten Arbeitgeber führt).
#
# Existence check: refnr is stable; a detail call that returns no Titel/Beschreibung
# (HTTP 404 / error body) means the ad is gone → verwerfen (bzw. JobOffer löschen).
set -euo pipefail
API='https://rest.arbeitsagentur.de/jobboerse/jobsuche-service/pc/v4'
KEY='jobboerse-jobsuche'
usage() { sed -n '2,25p' "$0" >&2; exit 2; }
TMP="$(mktemp)"
trap 'rm -f "$TMP"' EXIT
cmd="${1:-}"; [[ -n "$cmd" ]] && shift || usage
if [[ "$cmd" == "search" ]]; then
was="${1:-}"; wo="${2:-}"; umkreis="${3:-15}"; tage="${4:-30}"; size="${5:-50}"; arbeitszeit="${6:-}"
[[ -n "$was" ]] || { echo "search: <was> fehlt" >&2; usage; }
# curl-Argumente als Array — sauberes, optionales wo=/arbeitszeit=.
args=(-sS -m 30 -G "$API/jobs" -H "X-API-Key: $KEY"
--data-urlencode "was=$was"
--data-urlencode "umkreis=$umkreis"
--data-urlencode "veroeffentlichtseit=$tage"
--data-urlencode "size=$size")
[[ -n "$wo" ]] && args+=(--data-urlencode "wo=$wo")
[[ -n "$arbeitszeit" ]] && args+=(--data-urlencode "arbeitszeit=$arbeitszeit")
curl "${args[@]}" -o "$TMP" || { echo "# Arbeitsagentur-API nicht erreichbar" >&2; exit 0; }
RESP_FILE="$TMP" python3 <<'PY'
import os, sys, json, html, re, unicodedata
# firma_slug — MUSS mit firmaSlug() (lib/blacklist.js) & applied-set.sh übereinstimmen.
_GENDER = re.compile(r'\((?:[mwdfax](?:\s*/\s*[mwdfax])*)\)', re.I)
_LEGAL = {'gmbh','ggmbh','mbh','ug','haftungsbeschraenkt','ag','kg','kgaa','ohg','gbr',
'se','ek','eg','ev','partg','partmbb','co','cie','inc','incorporated','llc',
'ltd','limited','plc','corp','corporation','company','sa','sarl','sas','bv',
'nv','oy','ab','as','aps','srl','spa'}
def firma_slug(s):
t = html.unescape(str(s or '')).lower()
t = t.replace('ä','ae').replace('ö','oe').replace('ü','ue').replace('ß','ss')
t = unicodedata.normalize('NFKD', t)
t = ''.join(c for c in t if not unicodedata.combining(c))
t = _GENDER.sub(' ', t)
t = re.sub(r'[^a-z0-9]+',' ',t)
t = re.sub(r'\s+',' ',t).strip()
if not t: return ''
w = t.split(' '); kept = w[:]
while len(kept) > 1 and kept[-1] in _LEGAL: kept.pop()
return '-'.join(kept if kept else w)
def clean(v):
return html.unescape(str(v if v is not None else '')).replace('\t',' ').replace('\n',' ').strip()
try:
with open(os.environ['RESP_FILE'], encoding='utf-8') as fh:
d = json.load(fh)
except Exception as e:
print(f'# Arbeitsagentur-API: keine/ungueltige Antwort ({e})', file=sys.stderr); sys.exit(0)
jobs = d.get('stellenangebote') or []
n = 0
for j in jobs:
slug = firma_slug(j.get('arbeitgeber'))
if not slug:
continue
ort = (j.get('arbeitsort') or {}).get('ort')
print('\t'.join([slug, clean(j.get('arbeitgeber')), clean(j.get('titel')), clean(ort),
clean(j.get('aktuelleVeroeffentlichungsdatum')),
clean(j.get('refnr')), clean(j.get('angebotsart') or 'ARBEIT')]))
n += 1
print(f'# {n} Treffer (Arbeitsagentur, {d.get("maxErgebnisse","?")} gesamt)', file=sys.stderr)
PY
elif [[ "$cmd" == "detail" ]]; then
ref="${1:-}"; [[ -n "$ref" ]] || { echo "detail: <refnr> fehlt" >&2; usage; }
seg="$(REF="$ref" python3 -c 'import base64,urllib.parse,os; print(urllib.parse.quote(base64.b64encode(os.environ["REF"].encode()).decode(), safe=""))')"
curl -sS -m 30 "$API/jobdetails/$seg" -H "X-API-Key: $KEY" -o "$TMP" \
|| { echo "# Arbeitsagentur-API nicht erreichbar" >&2; exit 0; }
REF="$ref" RESP_FILE="$TMP" python3 <<'PY'
import os, sys, json, html
try:
with open(os.environ['RESP_FILE'], encoding='utf-8') as fh:
d = json.load(fh)
except Exception as e:
print(f'# detail: ungueltige Antwort ({e})', file=sys.stderr); sys.exit(0)
titel = d.get('stellenangebotsTitel') or d.get('titel')
if not titel and not d.get('stellenangebotsBeschreibung'):
print(f'# refnr {os.environ.get("REF","")}: keine aktive Anzeige (abgelaufen/entfernt)')
sys.exit(0)
def u(v): return html.unescape(v) if isinstance(v, str) else v
lok = (d.get('stellenlokationen') or [{}])[0]
adr = ', '.join(x for x in [lok.get('strasse'),
' '.join(y for y in [lok.get('plz'), lok.get('ort')] if y)] if x)
print('FIRMA:', u(d.get('firma')))
print('TITEL:', u(titel))
print('ART:', d.get('stellenangebotsart'))
print('ZEITARBEIT_AUE:', d.get('istArbeitnehmerUeberlassung'))
print('PRIVATE_ARBEITSVERMITTLUNG:', d.get('istPrivateArbeitsvermittlung'))
print('HOMEOFFICE_MOEGLICH:', d.get('homeofficemoeglich'))
print('VERGUETUNG:', d.get('verguetungsangabe'))
print('ADRESSE:', adr or '(keine)')
print('EXTERNE_URL:', u(d.get('externeURL')) or '(keine — dann Firmen-Karriereseite suchen)')
print('REFERENZNUMMER:', d.get('referenznummer') or os.environ.get('REF',''))
print('VEROEFFENTLICHT:', d.get('datumErsteVeroeffentlichung') or d.get('aktuelleVeroeffentlichungsdatum'))
print('AENDERUNG:', d.get('aenderungsdatum'))
print('--- BESCHREIBUNG ---')
print(u(d.get('stellenangebotsBeschreibung')) or '(keine)')
PY
else
usage
fi
+61
View File
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
# Prints the Bewerbungs-Tracker BLACKLIST in a matchable, tab-separated form, so
# a job-search run can discard candidates that are blocked BEFORE listing or
# importing them (server also rejects blocked offers with 409 on POST /joboffers,
# but we don't want to even present them).
#
# Output: one blacklist entry per line, tab-separated:
# id <TAB> typ <TAB> firma <TAB> stelle <TAB> ort <TAB> domain <TAB> url_norm <TAB> firma_norm <TAB> firma_slug <TAB> stelle_norm <TAB> ort_norm <TAB> grund
#
# typ = url | domain | firma | firma_stelle | auto
#
# firma_slug = robuster Firmen-Schlüssel (Umlaut-Translit, End-Rechtsform raus,
# mit "-" verbunden) — identisch zu applied-set.sh und firmaSlug()
# in lib/blacklist.js. Damit greift die Firmen-Sperre auch bei
# abweichender Schreibweise/Rechtsform.
#
# A candidate is BLOCKED when one entry matches:
# typ=url -> candidate URL (normalized) == url_norm
# typ=domain -> candidate URL host == domain (also matches subdomains)
# typ=firma -> SAME company: candidate firma_slug == firma_slug, OR one
# firma_slug is a leading hyphen-prefix of the other (>=2
# tokens); firma_norm only as fallback for legacy rows.
# typ=firma_stelle -> same company (firma_slug, wie oben) AND stelle_norm equal
# (ort_norm only narrows further when set)
# typ=auto -> treat like the concrete fields it carries (url/firma_stelle)
#
# The server already provides the *_norm fields (lowercased, tracking params
# stripped, gender/legal-form removed), so compare against those.
#
# Reuses the bewerbungs-tracker skill's helper for auth/base-URL.
set -euo pipefail
BT="${BT_SCRIPT:-$HOME/.claude/skills/bewerbungs-tracker/scripts/bt.sh}"
if [[ ! -x "$BT" ]]; then
echo "blacklist-set.sh: bewerbungs-tracker helper not found at $BT" >&2
exit 2
fi
bl="$("$BT" GET '/joboffers/blacklist')"
BL_JSON="$bl" python3 <<'PY'
import os, json, html, sys
def load(name):
raw = os.environ[name].split('<http')[0]
try:
return json.loads(raw)
except Exception:
return []
def clean(v):
return html.unescape(str(v if v is not None else '')).replace('\t', ' ').strip()
rows = load('BL_JSON')
for e in rows:
print('\t'.join(clean(e.get(k)) for k in (
'id', 'typ', 'firma', 'stelle', 'ort', 'domain',
'url_norm', 'firma_norm', 'firma_slug', 'stelle_norm', 'ort_norm', 'grund')))
print(f'# {len(rows)} Blacklist-Eintraege', file=sys.stderr)
PY
+2 -1
View File
@@ -8,7 +8,8 @@
"dev": "nodemon server.js",
"docker:build": "docker build -t git.hackner.dev/thomas/jobbi-bewerbung:latest .",
"docker:push": "docker push git.hackner.dev/thomas/jobbi-bewerbung:latest",
"docker:deploy": "npm run docker:build && npm run docker:push"
"docker:deploy": "npm run docker:build && npm run docker:push",
"docker:build-agent": "docker build -t jobbi-bewerbung-agent:latest agent"
},
"dependencies": {
"ejs": "^3.1.9",
+119 -65
View File
@@ -2,29 +2,39 @@
// Host-side runner for the per-user job search.
//
// Why it lives on the host and not in the app: the app container has neither
// `claude` nor `ollama`. The app therefore only *queues* runs (table suchlaeufe);
// this script — started by cron every few minutes — picks them up and executes
// them. It replaces the two old single-user scripts (jobsuche-cron.sh and
// jobsuche-remote-cron.sh), whose search criteria were hard-coded into the prompt.
// `claude` nor `ollama` nor a docker socket. The app therefore only *queues*
// runs (table suchlaeufe); this script — started by cron every few minutes —
// picks them up and executes them. It replaces the two old single-user scripts
// (jobsuche-cron.sh and jobsuche-remote-cron.sh), whose search criteria were
// hard-coded into the prompt.
//
// Isolation: every run executes in its OWN throwaway Docker container
// (AGENT_IMAGE), with the requesting user's dedicated home directory mounted
// as /home/agent. That home IS that user's ~/.claude (memories, session
// contexts, history, settings) — never shared with another user. The three
// search skills are shared code baked into the image and symlinked into
// ~/.claude/skills by the image entrypoint.
//
// Per run it:
// 1. enqueues scheduled runs that are due (per user's Zeitplan),
// 2. claims one queued run at a time (status angefordert -> laeuft),
// 2. claims queued runs (status angefordert -> laeuft) and runs them in
// parallel up to MAX_PARALLEL — different users have different Ollama
// keys and thus separate rate limits, so parallelism is safe across users,
// 3. builds the prompt from that user's Suchprofil + their Lebenslauf,
// 4. runs the headless agent with THAT user's credentials:
// - BEWERBUNG_API_KEY = the user's API token (created if missing), so every
// imported Jobangebot lands in their account and passes the same
// dedup/blacklist path as the rest of the app,
// - the model is driven by the user's own Ollama key (see KI_AUTH below),
// 5. parses the agent's final "ERGEBNIS neu=… dubletten=… verworfen=…" line and
// writes status + counts back, so the UI can show what happened.
//
// Everything is per user; nothing here reads a global .env any more.
// 4. starts a container that runs the headless agent with THAT user's
// credentials:
// - BEWERBUNG_API_KEY = the user's API token (created if missing), so
// every imported Jobangebot lands in their account and passes the
// same dedup/blacklist path as the rest of the app,
// - ANTHROPIC_* = the user's own Ollama endpoint+key, so the search is
// billed to whoever requested it,
// 5. parses the agent's final "ERGEBNIS neu=… dubletten=… verworfen=…" line
// and writes status + counts back, so the UI can show what happened.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { spawn } = require('child_process');
const { spawn, spawnSync } = require('child_process');
const sqlite3 = require('sqlite3');
const suchprofil = require('../lib/suchprofil');
@@ -32,16 +42,25 @@ const suchprofil = require('../lib/suchprofil');
const DB_PATH = process.env.JOBSUCHE_DB || '/opt/jobbi-bewerbung/data/bewerbungen.db';
const LOG_DIR = process.env.JOBSUCHE_LOG_DIR || '/opt/jobbi-bewerbung/logs';
const API_URL = process.env.JOBSUCHE_API_URL || 'http://localhost:4327/api/v1';
// Swappable so the pipeline can be exercised end-to-end without burning tokens.
const CLAUDE_BIN = process.env.JOBSUCHE_CLAUDE_BIN || 'claude';
// 'benutzer' = each run uses that user's own Ollama key (they pay for their own
// search). 'host' = fall back to the machine's ollama login via `ollama launch`.
const KI_AUTH = process.env.JOBSUCHE_KI_AUTH || 'benutzer';
const RUN_TIMEOUT_MS = (Number(process.env.JOBSUCHE_TIMEOUT) || 2700) * 1000;
// A run that has been 'laeuft' for longer than this lost its process (reboot,
// OOM); it would otherwise block the user's queue forever.
const STALE_MS = RUN_TIMEOUT_MS + 10 * 60 * 1000;
const LOG_RETENTION_DAYS = Number(process.env.JOBSUCHE_LOG_RETENTION_DAYS) || 30;
// Agent-Container. Das Image wird lokal gebaut (npm run docker:build-agent),
// nicht über die Registry verteilt — es läuft nur auf diesem Host.
const AGENT_IMAGE = process.env.JOBSUCHE_AGENT_IMAGE || 'jobbi-bewerbung-agent:latest';
// uid/gid, als die der Container läuft und der das pro-Benutzer-Home gehört.
const AGENT_UID = Number(process.env.JOBSUCHE_AGENT_UID) || 1000;
const AGENT_GID = Number(process.env.JOBSUCHE_AGENT_GID) || AGENT_UID;
// Pro-Benutzer-Verzeichnisse (je HOME = je ~/.claude).
const AGENTS_DIR = process.env.JOBSUCHE_AGENTS_DIR || '/opt/jobbi-bewerbung/agents';
// Gleichzeitig laufende Such-Container. Verschiedene Benutzer haben getrennte
// Ollama-Keys -> getrennte Rate-Limits, deshalb ist pro-Benutzer-Parallelität
// sicher. Der Wert begrenzt lediglich die Host-Last.
const MAX_PARALLEL = Number(process.env.JOBSUCHE_MAX_PARALLEL) || 4;
const CFG = 'cfg:';
const db = new sqlite3.Database(DB_PATH);
@@ -88,6 +107,16 @@ async function profilText(userId) {
return rows.map((r) => (r.inhalt || '').trim()).filter(Boolean).join('\n\n---\n\n');
}
// Legt das pro-Benutzer-Home an (falls noch nicht vorhanden) und übergibt es
// an den Container-Agent. Bleibt über Läufe hinweg bestehen, damit Memories
// und Contexte erhalten bleiben.
function ensureAgentDir(userId) {
const dir = path.join(AGENTS_DIR, String(userId));
fs.mkdirSync(dir, { recursive: true });
spawnSync('chown', ['-R', `${AGENT_UID}:${AGENT_GID}`, dir]);
return dir;
}
// 1. Queue scheduled runs that are due.
async function faelligeEinreihen() {
const jetzt = new Date();
@@ -120,7 +149,7 @@ async function faelligeEinreihen() {
async function verwaisteAufraeumen() {
const res = await run(
`UPDATE suchlaeufe SET status = 'fehler', beendet_at = CURRENT_TIMESTAMP,
fehler = 'Lauf abgebrochen (Prozess nicht mehr vorhanden).'
fehler = 'Lauf abgebrochen (Container nicht mehr vorhanden).'
WHERE status = 'laeuft'
AND gestartet_at IS NOT NULL
AND (julianday('now') - julianday(gestartet_at)) * 86400000 > ?`,
@@ -152,26 +181,30 @@ async function laufAbschliessen(id, status, felder = {}) {
);
}
// Spawn the headless agent and collect its output.
function agentStarten(prompt, env, logStream) {
// Start the headless agent inside a throwaway container for this user. Only the
// curated env below is passed in (not process.env) so no host secrets leak.
function agentStarten(prompt, envObj, logStream, containerName, agentDir) {
return new Promise((resolve) => {
let argv;
if (KI_AUTH === 'host') {
// Legacy path: the machine's ollama login pays for the run.
argv = ['launch', 'claude', '--model', env.JOBSUCHE_MODEL, '--',
'--dangerously-skip-permissions', '-p', prompt];
argv = { cmd: 'ollama', args: argv };
} else {
// Per-user path: Claude Code talks to Ollama's Anthropic-compatible API with
// *this user's* key, so the search is billed to whoever requested it.
argv = { cmd: CLAUDE_BIN, args: ['--dangerously-skip-permissions', '-p', prompt] };
}
const args = [
'run', '--rm',
'--name', containerName,
// --network host: der Agent erreicht den Tracker (localhost:4327), das
// Internet (WebSearch/WebFetch/Arbeitsagentur) und den Ollama-Endpoint
// des Benutzers — alles über die Host-Netzwerk-Sicht.
'--network', 'host',
'--user', `${AGENT_UID}:${AGENT_GID}`,
'-e', 'HOME=/home/agent',
'-e', 'IS_SANDBOX=1',
];
for (const [k, v] of Object.entries(envObj)) args.push('-e', `${k}=${v}`);
args.push(
'-v', `${agentDir}:/home/agent`,
'-w', '/work',
AGENT_IMAGE,
'claude', '--dangerously-skip-permissions', '-p', prompt,
);
const kind = spawn(argv.cmd, argv.args, {
env,
cwd: '/opt/jobbi-bewerbung',
stdio: ['ignore', 'pipe', 'pipe'],
});
const kind = spawn('docker', args, { stdio: ['ignore', 'pipe', 'pipe'] });
let ausgabe = '';
const sammeln = (buf) => {
@@ -182,14 +215,17 @@ function agentStarten(prompt, env, logStream) {
kind.stdout.on('data', sammeln);
kind.stderr.on('data', sammeln);
// Beim Timeout den `docker run`-Client killen UND den Container per Name
// stoppen — ein kill des Clients allein ließe den Container sonst weiterlaufen.
const timer = setTimeout(() => {
spawnSync('docker', ['kill', containerName], { stdio: 'ignore' });
kind.kill('SIGTERM');
setTimeout(() => kind.kill('SIGKILL'), 60000);
setTimeout(() => kind.kill('SIGKILL'), 10000);
}, RUN_TIMEOUT_MS);
kind.on('error', (err) => {
clearTimeout(timer);
resolve({ code: -1, ausgabe, fehler: `Agent nicht startbar: ${err.message}` });
resolve({ code: -1, ausgabe, fehler: `Agent-Container nicht startbar: ${err.message}` });
});
kind.on('close', (code, signal) => {
clearTimeout(timer);
@@ -207,7 +243,8 @@ async function laufAusfuehren(lauf) {
const userId = lauf.user_id;
const user = await get('SELECT username FROM users WHERE id = ?', [userId]);
const name = user ? user.username : `#${userId}`;
log(`Lauf ${lauf.id} (Benutzer ${name}, ${lauf.ausloeser}) startet.`);
const containerName = `jobbi-agent-${userId}-${lauf.id}`;
log(`Lauf ${lauf.id} (Benutzer ${name}, ${lauf.ausloeser}) startet im Container ${containerName}.`);
const profil = suchprofil.fromRow(await get('SELECT * FROM suchprofil WHERE user_id = ?', [userId]));
const fehler = suchprofil.validate(profil);
@@ -218,7 +255,7 @@ async function laufAusfuehren(lauf) {
}
const ollamaKey = await cfgWert(userId, 'OLLAMA_API_KEY');
if (KI_AUTH !== 'host' && !ollamaKey) {
if (!ollamaKey) {
const msg = 'Kein eigener Ollama-API-Schlüssel hinterlegt — der Suchlauf läuft über das KI-Kontingent des Benutzers. Bitte in den Einstellungen eintragen.';
await laufAbschliessen(lauf.id, 'fehler', { fehler: msg });
log(` abgebrochen: kein Ollama-Key für ${name}.`);
@@ -235,27 +272,21 @@ async function laufAusfuehren(lauf) {
const logStream = fs.createWriteStream(logDatei, { flags: 'a' });
logStream.write(`=== Suchlauf ${lauf.id} — Benutzer ${name} (${lauf.ausloeser}) ===\n`);
logStream.write(`Modus: ${profil.modus} | Städte: ${profil.staedte.join(', ') || ''}\n`);
logStream.write(`Modell: ${modell} | KI-Auth: ${KI_AUTH}\n${'-'.repeat(50)}\n`);
logStream.write(`Modell: ${modell} | Container: ${containerName}\n${'-'.repeat(50)}\n`);
const env = {
...process.env,
HOME: process.env.HOME || '/root',
PATH: `/root/.local/bin:/usr/local/bin:/usr/bin:/bin:${process.env.PATH || ''}`,
IS_SANDBOX: '1',
// The tracker API — the agent imports Jobangebote as THIS user.
// Nur kuratierte Env-Vars in den Container — keine Host-Secrets.
const envObj = {
ANTHROPIC_BASE_URL: (await cfgWert(userId, 'OLLAMA_HOST')) || 'https://ollama.com',
ANTHROPIC_AUTH_TOKEN: ollamaKey,
ANTHROPIC_MODEL: modell,
ANTHROPIC_SMALL_FAST_MODEL: modell,
BEWERBUNG_API_URL: API_URL,
BEWERBUNG_API_KEY: apiKey,
JOBSUCHE_MODEL: modell,
};
if (KI_AUTH !== 'host') {
// Point Claude Code at Ollama's Anthropic-compatible endpoint with the user's key.
env.ANTHROPIC_BASE_URL = (await cfgWert(userId, 'OLLAMA_HOST')) || 'https://ollama.com';
env.ANTHROPIC_AUTH_TOKEN = ollamaKey;
env.ANTHROPIC_MODEL = modell;
env.ANTHROPIC_SMALL_FAST_MODEL = modell;
}
const { code, ausgabe, fehler: laufFehler } = await agentStarten(prompt, env, logStream);
const agentDir = ensureAgentDir(userId);
const { code, ausgabe, fehler: laufFehler } = await agentStarten(prompt, envObj, logStream, containerName, agentDir);
const ergebnis = suchprofil.parseErgebnis(ausgabe);
logStream.write(`\n${'-'.repeat(50)}\nExit-Code: ${code}\n`);
@@ -286,6 +317,27 @@ async function laufAusfuehren(lauf) {
log(` Lauf ${lauf.id} fertig: ${ergebnis.neu} neu, ${ergebnis.dubletten} Dubletten, ${ergebnis.verworfen} verworfen.`);
}
// Führt eine Liste von Läufen mit höchstens `parallel` gleichzeitig aus. Jeder
// Lauf läuft in seinem eigenen Container; verschiedene Benutzer haben getrennte
// Ollama-Keys, deshalb ist pro-Benutzer-Parallelität sicher.
async function runPool(items, parallel, fn) {
const queue = items.slice();
const workers = Array.from({ length: Math.min(parallel, queue.length || 1) }, async () => {
while (queue.length) {
const item = queue.shift();
try {
await fn(item);
} catch (e) {
log(` Lauf ${item.id} unerwartet abgebrochen: ${e && e.message ? e.message : e}`);
try {
await laufAbschliessen(item.id, 'fehler', { fehler: `Runner-Ausnahme: ${e && e.message ? e.message : e}` });
} catch (_) { /* Status ist dann zwar offen, aber ein Folge-Tick räumt auf. */ }
}
}
});
await Promise.all(workers);
}
function alteLogsAufraeumen() {
try {
const grenze = Date.now() - LOG_RETENTION_DAYS * 86400000;
@@ -307,15 +359,17 @@ async function main() {
await verwaisteAufraeumen();
await faelligeEinreihen();
// Work the queue until it is empty. Runs are serialised on purpose: a search is
// web- and token-heavy, and several in parallel would trip rate limits.
// Alle wartenden Läufe einsammeln (pro Benutzer steht ohnehin nur einer an)
// und mit begrenzter Parallelität abarbeiten.
const laeufe = [];
let lauf;
let anzahl = 0;
while ((lauf = await naechstenLaufClaimen())) {
await laufAusfuehren(lauf);
anzahl += 1;
while ((lauf = await naechstenLaufClaimen())) laeufe.push(lauf);
if (!laeufe.length) {
log('Nichts zu tun.');
} else {
log(`${laeufe.length} Lauf/Läufe eingereiht, starte bis zu ${MAX_PARALLEL} parallel.`);
await runPool(laeufe, MAX_PARALLEL, laufAusfuehren);
}
if (!anzahl) log('Nichts zu tun.');
alteLogsAufraeumen();
db.close();
+11 -4
View File
@@ -9,7 +9,8 @@
# Jetzt: Jeder Benutzer pflegt sein Suchprofil und seinen Zeitplan in der App
# (/jobsuche). Dieses Skript ruft nur noch den Runner auf, der fällige und manuell
# angeforderte Läufe abarbeitet — je Benutzer mit dessen eigenem Ollama- und
# API-Schlüssel.
# API-Schlüssel. Jeder Suchlauf läuft isoliert im eigenen Docker-Container pro
# Benutzer; der Runner selbst bleibt auf dem Host (Docker-CLI + DB-Zugriff).
#
# Cron (alle 5 Minuten — die Uhrzeit steuert der Benutzer, nicht der Cron):
# */5 * * * * /opt/jobbi-bewerbung/bin/jobsuche-runner.sh
@@ -26,13 +27,13 @@ export PATH="/root/.local/bin:/usr/local/bin:/usr/bin:/bin:$PATH"
# wartenden Suchläufe blieben ewig im Status 'angefordert' hängen.
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
# Claude Code verweigert --dangerously-skip-permissions als root; in dieser
# Container-/Sandbox-Umgebung mit IS_SANDBOX=1 erlaubt.
export IS_SANDBOX=1
PROJECT_DIR="/opt/jobbi-bewerbung"
LOG_DIR="$PROJECT_DIR/logs"
RUNNER="$PROJECT_DIR/source/scripts/jobsuche-runner.js"
# Agent-Image (pro Suchlauf ein frischer Container). Wird lokal gebaut:
# npm --prefix "$PROJECT_DIR/source" run docker:build-agent
AGENT_IMAGE="${JOBSUCHE_AGENT_IMAGE:-jobbi-bewerbung-agent:latest}"
mkdir -p "$LOG_DIR"
LOG="$LOG_DIR/jobsuche-runner.log"
@@ -52,6 +53,12 @@ if ! curl -fsS --max-time 10 http://localhost:4327/api/v1/health >/dev/null 2>&1
exit 1
fi
# Agent-Image muss vorhanden sein, sonst stapeln sich fehlgeschlagene Läufe.
if ! docker image inspect "$AGENT_IMAGE" >/dev/null 2>&1; then
echo "$(date -Is) FEHLER: Agent-Image '$AGENT_IMAGE' fehlt bitte bauen: npm --prefix $PROJECT_DIR/source run docker:build-agent" >> "$LOG"
exit 1
fi
node "$RUNNER" >> "$LOG" 2>&1
STATUS=$?