# ============================================================================= # Job·Ka — Groupe KA # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : jobka/connectors/_jsonld.py # Rôle : Extraction du balisage schema.org JobPosting (JSON-LD) — partagé # par les connecteurs dont la source publie des pages détail HTML # Créé : 2026-08-18 Modifié : 2026-08-18 # ============================================================================= """Utilitaires JSON-LD (schema.org/JobPosting). Beaucoup de sites (iCIMS, Jobillico, Espresso-Jobs, Digital Recruiters, Workland/Atlas…) embarquent un bloc ``', re.S | re.I) _UNIT = {"hour": "hour", "hourly": "hour", "day": "day", "week": "week", "month": "month", "year": "year", "annual": "year"} def _iter_nodes(node): """Itère tous les objets JSON-LD (racine, @graph, listes imbriquées).""" if isinstance(node, list): for item in node: yield from _iter_nodes(item) elif isinstance(node, dict): yield node yield from _iter_nodes(node.get("@graph") or []) def extract_jobposting(html: str) -> dict | None: """Retourne le premier objet @type=JobPosting trouvé dans la page.""" for m in _SCRIPT_RE.finditer(html or ""): raw = m.group(1).strip() try: data = json.loads(raw) except ValueError: # JSON avec contrôle non échappé (fréquent) : tentative de secours try: data = json.loads(re.sub(r"[\x00-\x1f]", " ", raw)) except ValueError: continue for node in _iter_nodes(data): t = node.get("@type") types = t if isinstance(t, list) else [t] if any(str(x).lower() == "jobposting" for x in types if x): return node return None def _first(value): if isinstance(value, list): return value[0] if value else None return value def jobposting_fields(node: dict) -> dict: """Aplati un JobPosting JSON-LD en champs standard Job·Ka (dict sparse).""" out: dict = {} if not node: return out out["title"] = node.get("title") or node.get("name") or "" out["description_html"] = node.get("description") or "" out["date_posted"] = node.get("datePosted") or None out["date_deadline"] = node.get("validThrough") or None et = node.get("employmentType") out["employment_label"] = ", ".join(et) if isinstance(et, list) else (et or "") org = _first(node.get("hiringOrganization")) if isinstance(org, dict): out["employer"] = org.get("name") or org.get("legalName") or "" logo = _first(org.get("logo")) if isinstance(logo, dict): # ImageObject logo = logo.get("url") or logo.get("contentUrl") if isinstance(logo, str) and logo.startswith("http"): out["company_logo"] = logo elif isinstance(org, str): out["employer"] = org ind = _first(node.get("industry")) if isinstance(ind, dict): ind = ind.get("name") if isinstance(ind, str) and ind.strip(): out["industry"] = ind.strip() edu = _first(node.get("educationRequirements")) if isinstance(edu, dict): # EducationalOccupationalCredential edu = edu.get("credentialCategory") or edu.get("name") if isinstance(edu, str) and edu.strip(): out["education"] = edu.strip() skills = node.get("skills") if isinstance(skills, list): skills = ", ".join(str(s) for s in skills if s) if isinstance(skills, str) and skills.strip(): from ..normalize import clean_html as _ch out["skills"] = _ch(skills)[:2000] loc = _first(node.get("jobLocation")) if isinstance(loc, dict): addr = loc.get("address") or {} if isinstance(addr, str): out["location_label"] = addr elif isinstance(addr, dict): out["city"] = addr.get("addressLocality") or "" out["region_code"] = addr.get("addressRegion") or "" out["postal_code"] = addr.get("postalCode") or "" out["address"] = addr.get("streetAddress") or "" geo = loc.get("geo") or {} if isinstance(geo, dict) and geo.get("latitude") is not None: try: out["lat"] = float(geo["latitude"]) out["lng"] = float(geo["longitude"]) except (TypeError, ValueError): pass sal = node.get("baseSalary") if isinstance(sal, dict): val = sal.get("value") unit = None lo = hi = None if isinstance(val, dict): lo = val.get("minValue", val.get("value")) hi = val.get("maxValue") unit = val.get("unitText") elif isinstance(val, (int, float)): lo = val if isinstance(lo, str): try: lo = float(lo.replace(",", ".")) except ValueError: lo = None if isinstance(hi, str): try: hi = float(hi.replace(",", ".")) except ValueError: hi = None if lo: out["salary_min"] = float(lo) out["salary_max"] = float(hi) if hi else None out["salary_unit"] = _UNIT.get(str(unit or "").lower()) return out def apply_fields(job, fields: dict, *, override_employer: bool = False) -> None: """Verse les champs extraits dans un JobPosting sans écraser l'existant.""" from ..normalize import clean_html if fields.get("description_html") and not job.description: job.description = clean_html(fields["description_html"]) if fields.get("title") and not job.title: job.title = fields["title"] if fields.get("employer") and (override_employer or not job.employer): job.employer = fields["employer"] if fields.get("date_posted") and not job.date_posted: job.date_posted = fields["date_posted"] if fields.get("date_deadline") and not job.date_deadline: job.date_deadline = fields["date_deadline"] if fields.get("city") and not job.city: job.city = fields["city"] if fields.get("postal_code") and not job.postal_code: job.postal_code = fields["postal_code"] if fields.get("address") and not job.address: job.address = fields["address"] if fields.get("location_label") and not job.location_label: job.location_label = fields["location_label"] if fields.get("salary_min") is not None and job.salary_min is None: job.salary_min = fields["salary_min"] job.salary_max = fields.get("salary_max") job.salary_unit = fields.get("salary_unit") if fields.get("employment_label"): job.details.setdefault("employment_label", fields["employment_label"]) if fields.get("company_logo") and not job.company_logo: job.company_logo = fields["company_logo"] if fields.get("industry"): job.details.setdefault("industry", fields["industry"]) if fields.get("education"): job.requirements.setdefault("education", fields["education"]) if fields.get("skills"): job.requirements.setdefault("skills", fields["skills"]) if fields.get("lat") is not None and job.lat is None: job.lat = fields["lat"] job.lng = fields.get("lng")