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