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
+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