Files
skillfactor-pipeline/pipeline/eu_fetch.py
skillfactor-pipeline 5814316ac7 feat(market): per-country query localization for the EU slice
JSearch matches against localized indexes - English titles return 0 in
DACH. Reviewed query config in data/eu-queries.json (overlay principle);
flagship corpus grows 66 -> 139 ads (73 DACH; NL empty, will honestly
report insufficient evidence).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDKeXvpT6tENSvyQGLV1Uq
2026-07-09 06:39:48 +02:00

133 lines
5.1 KiB
Python

"""Targeted EU/DACH ad fetch for one occupation (phase 2b).
Deliberately NOT part of the default full-catalog crawl: 4 extra countries
per occupation would blow the 33k lifetime JSearch budget. Use for the
reference/flagship packages and future targeted runs.
- Countries: DE, AT, CH, NL (kept as separate slice; US/UK slices untouched)
- Queries: preferred label + first alt label, up to 2 pages/country
- Budget: hard cap ~8-16 requests per occupation, all counted via
progress.spend_request BEFORE each HTTP call
- Raw pages cached under data/raw/jobs/<slug>/jsearch_<q>_<cc>_p<n>.json
(cache hits never re-spend quota)
- Result: merged into data/raw/jobs/<slug>_ads.json (dedupe by job_id) โ€”
the raw file stays the single source the gate/extractor reads.
Usage: python pipeline/eu_fetch.py <slug> [--pages 2]
"""
import argparse
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"))
sys.path.insert(0, os.path.dirname(__file__))
import progress # noqa: E402
from db import connect # noqa: E402
JSEARCH_URL = "https://api.openwebninja.com/jsearch/search-v2"
EU_COUNTRIES = ("de", "at", "ch", "nl")
RAW_JOBS = os.path.join(BASE, "data", "raw", "jobs")
EU_QUERIES_FILE = os.path.join(BASE, "data", "eu-queries.json")
def _queries_for_country(slug, country):
"""Localized queries per country. The JSearch index is language-local โ€”
English titles return 0 hits in DACH. Priority: reviewed config
(data/eu-queries.json) -> fallback English label. Empty list = skip."""
try:
cfg = json.load(open(EU_QUERIES_FILE, encoding="utf-8"))
if slug in cfg and country in cfg[slug]:
return cfg[slug][country]
except OSError:
pass
label = slug.replace("-", " ")
try:
m = json.load(open(os.path.join(BASE, "skills", slug, "manifest.json"),
encoding="utf-8"))
label = m.get("title", label)
except Exception:
pass
return [label]
def fetch_eu(slug, pages=2):
key = os.environ.get("JSEARCH_API_KEY", "").strip()
if not key:
sys.exit("JSEARCH_API_KEY missing")
state = progress.load()
raw_dir = os.path.join(RAW_JOBS, slug)
os.makedirs(raw_dir, exist_ok=True)
ads_file = os.path.join(RAW_JOBS, f"{slug}_ads.json")
existing = json.load(open(ads_file, encoding="utf-8")) if os.path.exists(ads_file) else []
seen = {a["job_id"] for a in existing}
added = 0
for country in EU_COUNTRIES:
for query in _queries_for_country(slug, country):
safe_q = query.replace(" ", "_").replace("/", "-")[:60]
cursor = None
for page in range(1, pages + 1):
cache = os.path.join(raw_dir, f"jsearch_{safe_q}_{country}_p{page}.json")
if os.path.exists(cache):
payload = json.load(open(cache, encoding="utf-8"))
else:
progress.spend_request(state, slug)
params = {"query": query, "country": country}
if cursor:
params["cursor"] = cursor
try:
r = requests.get(JSEARCH_URL, params=params,
headers={"x-api-key": key}, timeout=60)
r.raise_for_status()
except requests.RequestException as exc:
print(f" {query}/{country} p{page}: {exc} โ€” skip")
break
payload = r.json()
json.dump(payload, open(cache, "w", encoding="utf-8"),
ensure_ascii=False, indent=1)
time.sleep(2)
body = payload.get("data") or {}
jobs = (body.get("jobs") if isinstance(body, dict) else body) or []
if not jobs:
break
for job in jobs:
jid = job.get("job_id")
if jid and jid not in seen and job.get("job_description"):
seen.add(jid)
existing.append({
"job_id": jid,
"title": job.get("job_title"),
"employer": job.get("employer_name"),
"country": country,
"description": job.get("job_description"),
})
added += 1
cursor = (payload.get("cursor")
or (body.get("cursor") if isinstance(body, dict) else None))
if not cursor:
break
print(f"{query}/{country}: +{added} kumuliert")
json.dump(existing, open(ads_file, "w", encoding="utf-8"),
ensure_ascii=False, indent=1)
print(f"EU slice merged: +{added} new ads -> {ads_file} "
f"(total {len(existing)})")
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("slug")
ap.add_argument("--pages", type=int, default=2)
a = ap.parse_args()
fetch_eu(a.slug, a.pages)