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/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