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