SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
5 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
7.6 KB · 193 lines python
Raw Blame History
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