SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
6 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
5.2 KB · 128 lines python
Raw Blame History
1# =============================================================================2# Job·Ka — Groupe KA3# Auteur  : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : jobka/connectors/jazzhr.py6# Rôle    : Classe de plateforme JazzHR (applytojob.com) — liste HTML publique7#           /apply/ + JSON-LD JobPosting des pages détail (lieu, salaire…)8# Créé    : 2026-08-25   Modifié : 2026-09-04 (détection compte fermé)9# =============================================================================10"""Plateforme JazzHR (<org>.applytojob.com), répandue chez les PME.1112- liste  : GET https://<org>.applytojob.com/apply/13           -> liens /apply/<id>/<slug> (HTML léger, sans lieu fiable)14- détail : GET https://<org>.applytojob.com/apply/<id>/<slug>15           -> JSON-LD JobPosting (titre, lieu, dates, salaire, description).16           Cache BD + budget ; le filtre Québec est appliqué au détail —17           une offre sans détail frais (budget épuisé) est différée au18           prochain cycle plutôt que publiée sans lieu.19"""20from __future__ import annotations2122import os23import re24import time2526from ..schema import JobPosting, is_quebec_location27from . import _jsonld28from .base import BaseConnector2930MAX_DETAILS = int(os.environ.get("JOBKA_JAZZHR_DETAIL_LIMIT", "60"))3132_LINK_RE = re.compile(33    r'href="https?://[a-z0-9-]+\.applytojob\.com/apply/([A-Za-z0-9]+)/'34    r'([^"?#]+)"', re.I)353637class JazzHRConnector(BaseConnector):38    """Base JazzHR — sous-classes : définir source_id, EMPLOYER, ORG."""3940    ats = "jazzhr"41    request_delay = 1.04243    EMPLOYER = ""44    ORG = ""               # <ORG>.applytojob.com45    quebec_only = True4647    @property48    def _base(self) -> str:49        return f"https://{self.ORG}.applytojob.com"5051    def _fetch_detail(self, jid: str, slug: str) -> dict:52        html = self.get(f"{self._base}/apply/{jid}/{slug}").text53        node = _jsonld.extract_jobposting(html)54        return _jsonld.jobposting_fields(node) if node else {}5556    def _fetch_list(self) -> str:57        # JazzHR renvoie parfois 200 avec une page sans la structure du58        # jobboard (constaté le 2026-09-04 sur motosillimitees : sync à59        # 0 offre en 0,2 s alors que la page réelle en listait 2). Une60        # vraie page liste porte toujours le marqueur « resumator »61        # (classe body resumator-jobboard-home), même sans aucune offre :62        # son absence = page challenge/erreur, pas une liste vide — on63        # retente avec backoff puis on lève une erreur explicite plutôt64        # que de rapporter un faux « 0 offre ».65        last = ""66        for attempt in range(3):67            resp = self.get(f"{self._base}/apply/")68            html = resp.text69            if "resumator" in html:70                return html71            # Compte JazzHR fermé (constaté le 2026-09-04 sur72            # derumarketcatering) : JazzHR sert une vraie page 20073            # « Inactive Career Page » sans le marqueur resumator.74            # Condition permanente, pas un challenge — échec immédiat75            # avec un message explicite, sans retry/backoff inutile.76            if "Inactive Career Page" in html:77                raise RuntimeError(78                    f"jazzhr {self.ORG}: compte JazzHR désactivé "79                    "(« Inactive Career Page » — this account is no "80                    "longer active) — source fermée chez l'ATS")81            last = (f"HTTP {resp.status_code}, {len(html)} octets, "82                    f"content-type {resp.headers.get('content-type')}")83            if attempt < 2:84                time.sleep(10 * (attempt + 1))85        raise RuntimeError(86            f"jazzhr {self.ORG}: page liste sans structure jobboard "87            f"après 3 essais ({last})")8889    def fetch(self) -> list[JobPosting]:90        html = self._fetch_list()91        out: list[JobPosting] = []92        details_used = 093        seen: set[str] = set()94        for jid, slug in _LINK_RE.findall(html):95            if jid in seen:96                continue97            seen.add(jid)98            if details_used >= MAX_DETAILS:99                d = self.stale_detail(jid)100                if not d:101                    continue      # différée : pas de détail frais ni en cache102            else:103                fresh = [False]104105                def _fn(j=jid, s=slug, fresh=fresh):106                    fresh[0] = True107                    return self._fetch_detail(j, s)108109                d = self.detail(jid, slug, _fn)110                if fresh[0]:111                    details_used += 1112            city = d.get("city") or ""113            region = (d.get("region_code") or "").upper()114            if self.quebec_only and not (115                    region in ("QC", "QUÉBEC", "QUEBEC")116                    or is_quebec_location(f"{city} {region}")):117                continue118            job = JobPosting(119                source=self.source_id, external_id=jid,120                url=f"{self._base}/apply/{jid}/{slug}",121                employer=self.EMPLOYER,122                title=d.get("title") or slug.replace("-", " "),123                city=city, ats=self.ats,124            )125            _jsonld.apply_fields(job, d)126            out.append(job)127        return out128