HTML 82.1%
Python 14.6%
TypeScript 1.9%
CSS 1%
JavaScript 0.5%
1# =============================================================================2# Job·Ka — Groupe KA3# Auteur : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : jobka/connectors/_jsonld.py6# Rôle : Extraction du balisage schema.org JobPosting (JSON-LD) — partagé7# par les connecteurs dont la source publie des pages détail HTML8# Créé : 2026-08-18 Modifié : 2026-08-189# =============================================================================10"""Utilitaires JSON-LD (schema.org/JobPosting).1112Beaucoup de sites (iCIMS, Jobillico, Espresso-Jobs, Digital Recruiters,13Workland/Atlas…) embarquent un bloc ``<script type="application/ld+json">``14conforme à schema.org sur la page détail de chaque offre. On l'extrait ici de15façon tolérante (JSON imparfait, @graph, listes) et on le convertit en champs16standard prêts à verser dans un ``JobPosting``.17"""18from __future__ import annotations1920import json21import re2223_SCRIPT_RE = re.compile(24 r'<script[^>]*type=["\']application/ld\+json["\'][^>]*>(.*?)</script>',25 re.S | re.I)2627_UNIT = {"hour": "hour", "hourly": "hour", "day": "day", "week": "week",28 "month": "month", "year": "year", "annual": "year"}293031def _iter_nodes(node):32 """Itère tous les objets JSON-LD (racine, @graph, listes imbriquées)."""33 if isinstance(node, list):34 for item in node:35 yield from _iter_nodes(item)36 elif isinstance(node, dict):37 yield node38 yield from _iter_nodes(node.get("@graph") or [])394041def extract_jobposting(html: str) -> dict | None:42 """Retourne le premier objet @type=JobPosting trouvé dans la page."""43 for m in _SCRIPT_RE.finditer(html or ""):44 raw = m.group(1).strip()45 try:46 data = json.loads(raw)47 except ValueError:48 # JSON avec contrôle non échappé (fréquent) : tentative de secours49 try:50 data = json.loads(re.sub(r"[\x00-\x1f]", " ", raw))51 except ValueError:52 continue53 for node in _iter_nodes(data):54 t = node.get("@type")55 types = t if isinstance(t, list) else [t]56 if any(str(x).lower() == "jobposting" for x in types if x):57 return node58 return None596061def _first(value):62 if isinstance(value, list):63 return value[0] if value else None64 return value656667def jobposting_fields(node: dict) -> dict:68 """Aplati un JobPosting JSON-LD en champs standard Job·Ka (dict sparse)."""69 out: dict = {}70 if not node:71 return out72 out["title"] = node.get("title") or node.get("name") or ""73 out["description_html"] = node.get("description") or ""74 out["date_posted"] = node.get("datePosted") or None75 out["date_deadline"] = node.get("validThrough") or None76 et = node.get("employmentType")77 out["employment_label"] = ", ".join(et) if isinstance(et, list) else (et or "")7879 org = _first(node.get("hiringOrganization"))80 if isinstance(org, dict):81 out["employer"] = org.get("name") or org.get("legalName") or ""82 logo = _first(org.get("logo"))83 if isinstance(logo, dict): # ImageObject84 logo = logo.get("url") or logo.get("contentUrl")85 if isinstance(logo, str) and logo.startswith("http"):86 out["company_logo"] = logo87 elif isinstance(org, str):88 out["employer"] = org8990 ind = _first(node.get("industry"))91 if isinstance(ind, dict):92 ind = ind.get("name")93 if isinstance(ind, str) and ind.strip():94 out["industry"] = ind.strip()9596 edu = _first(node.get("educationRequirements"))97 if isinstance(edu, dict): # EducationalOccupationalCredential98 edu = edu.get("credentialCategory") or edu.get("name")99 if isinstance(edu, str) and edu.strip():100 out["education"] = edu.strip()101102 skills = node.get("skills")103 if isinstance(skills, list):104 skills = ", ".join(str(s) for s in skills if s)105 if isinstance(skills, str) and skills.strip():106 from ..normalize import clean_html as _ch107 out["skills"] = _ch(skills)[:2000]108109 loc = _first(node.get("jobLocation"))110 if isinstance(loc, dict):111 addr = loc.get("address") or {}112 if isinstance(addr, str):113 out["location_label"] = addr114 elif isinstance(addr, dict):115 out["city"] = addr.get("addressLocality") or ""116 out["region_code"] = addr.get("addressRegion") or ""117 out["postal_code"] = addr.get("postalCode") or ""118 out["address"] = addr.get("streetAddress") or ""119 geo = loc.get("geo") or {}120 if isinstance(geo, dict) and geo.get("latitude") is not None:121 try:122 out["lat"] = float(geo["latitude"])123 out["lng"] = float(geo["longitude"])124 except (TypeError, ValueError):125 pass126127 sal = node.get("baseSalary")128 if isinstance(sal, dict):129 val = sal.get("value")130 unit = None131 lo = hi = None132 if isinstance(val, dict):133 lo = val.get("minValue", val.get("value"))134 hi = val.get("maxValue")135 unit = val.get("unitText")136 elif isinstance(val, (int, float)):137 lo = val138 if isinstance(lo, str):139 try:140 lo = float(lo.replace(",", "."))141 except ValueError:142 lo = None143 if isinstance(hi, str):144 try:145 hi = float(hi.replace(",", "."))146 except ValueError:147 hi = None148 if lo:149 out["salary_min"] = float(lo)150 out["salary_max"] = float(hi) if hi else None151 out["salary_unit"] = _UNIT.get(str(unit or "").lower())152 return out153154155def apply_fields(job, fields: dict, *, override_employer: bool = False) -> None:156 """Verse les champs extraits dans un JobPosting sans écraser l'existant."""157 from ..normalize import clean_html158 if fields.get("description_html") and not job.description:159 job.description = clean_html(fields["description_html"])160 if fields.get("title") and not job.title:161 job.title = fields["title"]162 if fields.get("employer") and (override_employer or not job.employer):163 job.employer = fields["employer"]164 if fields.get("date_posted") and not job.date_posted:165 job.date_posted = fields["date_posted"]166 if fields.get("date_deadline") and not job.date_deadline:167 job.date_deadline = fields["date_deadline"]168 if fields.get("city") and not job.city:169 job.city = fields["city"]170 if fields.get("postal_code") and not job.postal_code:171 job.postal_code = fields["postal_code"]172 if fields.get("address") and not job.address:173 job.address = fields["address"]174 if fields.get("location_label") and not job.location_label:175 job.location_label = fields["location_label"]176 if fields.get("salary_min") is not None and job.salary_min is None:177 job.salary_min = fields["salary_min"]178 job.salary_max = fields.get("salary_max")179 job.salary_unit = fields.get("salary_unit")180 if fields.get("employment_label"):181 job.details.setdefault("employment_label", fields["employment_label"])182 if fields.get("company_logo") and not job.company_logo:183 job.company_logo = fields["company_logo"]184 if fields.get("industry"):185 job.details.setdefault("industry", fields["industry"])186 if fields.get("education"):187 job.requirements.setdefault("education", fields["education"])188 if fields.get("skills"):189 job.requirements.setdefault("skills", fields["skills"])190 if fields.get("lat") is not None and job.lat is None:191 job.lat = fields["lat"]192 job.lng = fields.get("lng")193