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:
+104
@@ -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
|
||||
Reference in New Issue
Block a user