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