# ============================================================================= # Job·Ka — Groupe KA # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : jobka/connectors/jazzhr.py # Rôle : Classe de plateforme JazzHR (applytojob.com) — liste HTML publique # /apply/ + JSON-LD JobPosting des pages détail (lieu, salaire…) # Créé : 2026-08-25 Modifié : 2026-09-04 (détection compte fermé) # ============================================================================= """Plateforme JazzHR (.applytojob.com), répandue chez les PME. - liste : GET https://.applytojob.com/apply/ -> liens /apply// (HTML léger, sans lieu fiable) - détail : GET https://.applytojob.com/apply// -> JSON-LD JobPosting (titre, lieu, dates, salaire, description). Cache BD + budget ; le filtre Québec est appliqué au détail — une offre sans détail frais (budget épuisé) est différée au prochain cycle plutôt que publiée sans lieu. """ from __future__ import annotations import os import re import time from ..schema import JobPosting, is_quebec_location from . import _jsonld from .base import BaseConnector MAX_DETAILS = int(os.environ.get("JOBKA_JAZZHR_DETAIL_LIMIT", "60")) _LINK_RE = re.compile( r'href="https?://[a-z0-9-]+\.applytojob\.com/apply/([A-Za-z0-9]+)/' r'([^"?#]+)"', re.I) class JazzHRConnector(BaseConnector): """Base JazzHR — sous-classes : définir source_id, EMPLOYER, ORG.""" ats = "jazzhr" request_delay = 1.0 EMPLOYER = "" ORG = "" # .applytojob.com quebec_only = True @property def _base(self) -> str: return f"https://{self.ORG}.applytojob.com" def _fetch_detail(self, jid: str, slug: str) -> dict: html = self.get(f"{self._base}/apply/{jid}/{slug}").text node = _jsonld.extract_jobposting(html) return _jsonld.jobposting_fields(node) if node else {} def _fetch_list(self) -> str: # JazzHR renvoie parfois 200 avec une page sans la structure du # jobboard (constaté le 2026-09-04 sur motosillimitees : sync à # 0 offre en 0,2 s alors que la page réelle en listait 2). Une # vraie page liste porte toujours le marqueur « resumator » # (classe body resumator-jobboard-home), même sans aucune offre : # son absence = page challenge/erreur, pas une liste vide — on # retente avec backoff puis on lève une erreur explicite plutôt # que de rapporter un faux « 0 offre ». last = "" for attempt in range(3): resp = self.get(f"{self._base}/apply/") html = resp.text if "resumator" in html: return html # Compte JazzHR fermé (constaté le 2026-09-04 sur # derumarketcatering) : JazzHR sert une vraie page 200 # « Inactive Career Page » sans le marqueur resumator. # Condition permanente, pas un challenge — échec immédiat # avec un message explicite, sans retry/backoff inutile. if "Inactive Career Page" in html: raise RuntimeError( f"jazzhr {self.ORG}: compte JazzHR désactivé " "(« Inactive Career Page » — this account is no " "longer active) — source fermée chez l'ATS") last = (f"HTTP {resp.status_code}, {len(html)} octets, " f"content-type {resp.headers.get('content-type')}") if attempt < 2: time.sleep(10 * (attempt + 1)) raise RuntimeError( f"jazzhr {self.ORG}: page liste sans structure jobboard " f"après 3 essais ({last})") def fetch(self) -> list[JobPosting]: html = self._fetch_list() out: list[JobPosting] = [] details_used = 0 seen: set[str] = set() for jid, slug in _LINK_RE.findall(html): if jid in seen: continue seen.add(jid) if details_used >= MAX_DETAILS: d = self.stale_detail(jid) if not d: continue # différée : pas de détail frais ni en cache else: fresh = [False] def _fn(j=jid, s=slug, fresh=fresh): fresh[0] = True return self._fetch_detail(j, s) d = self.detail(jid, slug, _fn) if fresh[0]: details_used += 1 city = d.get("city") or "" region = (d.get("region_code") or "").upper() if self.quebec_only and not ( region in ("QC", "QUÉBEC", "QUEBEC") or is_quebec_location(f"{city} {region}")): continue job = JobPosting( source=self.source_id, external_id=jid, url=f"{self._base}/apply/{jid}/{slug}", employer=self.EMPLOYER, title=d.get("title") or slug.replace("-", " "), city=city, ats=self.ats, ) _jsonld.apply_fields(job, d) out.append(job) return out