"""Phase 3a — fetch real recruiter job ads via the JSearch API (openwebninja). Official API only (no HTML scraping). Raw responses are cached in data/raw/jobs/ so repeated runs never re-spend API quota. Target: 50-100 English-language ads (us + gb) for the occupation "recruiter". Fallback: Adzuna (if ADZUNA_APP_ID/KEY are set and JSearch quota is exhausted). """ import json import os import sys import time import requests from dotenv import load_dotenv BASE = os.path.join(os.path.dirname(__file__), "..") load_dotenv(os.path.join(BASE, ".env")) RAW_JOBS = os.path.join(BASE, "data", "raw", "jobs") JSEARCH_URL = "https://api.openwebninja.com/jsearch/search-v2" TARGET = 500 MAX_REQUESTS = 55 # hartes Budget (Basic-Plan: 200/Monat); Cache-Hits kosten nichts # Query-Varianten = ESCO-altLabels des Berufs -> maximale Marktabdeckung QUERIES = [ ("recruiter", "us"), ("recruiter", "gb"), ("talent acquisition specialist", "us"), ("talent acquisition specialist", "gb"), ("technical recruiter", "us"), ("recruiting coordinator", "us"), ("headhunter", "gb"), ] def fetch_jsearch(): key = os.environ.get("JSEARCH_API_KEY", "").strip() if not key: print("SKIP: JSEARCH_API_KEY missing (.env) — documented as TODO in README") return [] os.makedirs(RAW_JOBS, exist_ok=True) ads, seen = [], set() spent = [0] for query, country in QUERIES: cursor, page = None, 0 while len(ads) < TARGET and page < 10: page += 1 cache = os.path.join(RAW_JOBS, f"jsearch_{query.replace(' ', '_')}_{country}_p{page}.json") if os.path.exists(cache): payload = json.load(open(cache, encoding="utf-8")) elif spent[0] >= MAX_REQUESTS: print(f"request budget ({MAX_REQUESTS}) reached — stopping") break else: spent[0] += 1 params = {"query": query, "country": country, "language": "en"} if cursor: params["cursor"] = cursor # transiente API-Fehler (5xx/Timeout) mit Backoff abfangen, # statt den ganzen Lauf zu verlieren payload = None for attempt in range(3): try: r = requests.get(JSEARCH_URL, params=params, headers={"x-api-key": key}, timeout=60) except requests.RequestException as exc: print(f" {query}/{country} p{page}: {exc} (retry {attempt+1})") time.sleep(8 * (attempt + 1)) continue if r.status_code == 429: print(f"rate limit on {query}/{country} p{page}; stopping this query") break if r.status_code >= 500: print(f" {query}/{country} p{page}: HTTP {r.status_code} (retry {attempt+1})") time.sleep(8 * (attempt + 1)) continue r.raise_for_status() payload = r.json() break if payload is None: print(f" {query}/{country} p{page}: aufgegeben, naechste Query") break json.dump(payload, open(cache, "w", encoding="utf-8"), ensure_ascii=False, indent=1) time.sleep(2) # be gentle with the free tier body = payload.get("data") or {} # search-v2 liefert {"data": {"jobs": [...]}, "cursor": "..."} data = body.get("jobs") if isinstance(body, dict) else body data = data or [] if not data: break for job in data: jid = job.get("job_id") if jid and jid not in seen and job.get("job_description"): seen.add(jid) ads.append({ "job_id": jid, "title": job.get("job_title"), "employer": job.get("employer_name"), "country": country, "description": job.get("job_description"), }) cursor = (payload.get("cursor") or (body.get("cursor") if isinstance(body, dict) else None) or (payload.get("meta") or {}).get("cursor")) if not cursor: break print(f"{query}/{country}: kumuliert {len(ads)} Anzeigen") return ads def main(): ads = fetch_jsearch() out = os.path.join(RAW_JOBS, "recruiter_ads.json") if ads: json.dump(ads, open(out, "w", encoding="utf-8"), ensure_ascii=False, indent=1) print(f"OK: {len(ads)} unique ads -> {out}") else: print("No ads fetched.") if __name__ == "__main__": main()