#!/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 [umkreis_km] [tage] [size] [arbeitszeit] # Lists postings for role around location . One API call. # umkreis_km default 15, tage (veröffentlicht seit) default 30, size default 50. # 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 firma titel ort datum refnr 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 # 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: 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: 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