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>
105 lines
4.4 KiB
Bash
Executable File
105 lines
4.4 KiB
Bash
Executable File
#!/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
|