# ============================================================================= # Job·Ka — Groupe KA # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : jobka/connectors/smartrecruiters.py # Rôle : Classe de plateforme SmartRecruiters (api.smartrecruiters.com) — # un employeur = une sous-classe de ~8 lignes (COMPANY, EMPLOYER) # Créé : 2026-08-17 Modifié : 2026-08-17 # ============================================================================= """Plateforme SmartRecruiters. API publique : - liste : GET https://api.smartrecruiters.com/v1/companies//postings ?limit=100&offset=N -> {totalFound, content:[{id, name, releasedDate, location:{city, region, country, remote, latitude, longitude}, typeOfEmployment:{label}, experienceLevel, ref}]} - détail : GET .../postings/ -> {jobAd:{sections:{jobDescription, qualifications, additionalInformation:{title, text(HTML)}}}} Le lieu inclut lat/lng : on les passe directement (validés par finalize()). Page détail avec cache BD (clé = releasedDate) + budget de requêtes. """ from __future__ import annotations import os from ..schema import JobPosting, clean_html from .base import BaseConnector PAGE_SIZE = 100 MAX_DETAILS = int(os.environ.get("JOBKA_SR_DETAIL_LIMIT", "300")) class SmartRecruitersConnector(BaseConnector): """Base SmartRecruiters — sous-classes : source_id, EMPLOYER, COMPANY.""" ats = "smartrecruiters" request_delay = 0.7 EMPLOYER = "" COMPANY = "" max_pages = 20 quebec_only = True @property def _base(self) -> str: return f"https://api.smartrecruiters.com/v1/companies/{self.COMPANY}/postings" def _keep(self, item: dict) -> bool: if not self.quebec_only: return True loc = item.get("location") or {} if (loc.get("country") or "").lower() != "ca": return False from ..schema import is_quebec_location return is_quebec_location( f"{loc.get('city') or ''}, {loc.get('region') or ''}") def _fetch_detail(self, posting_id: str) -> dict: data = self.get(f"{self._base}/{posting_id}").json() sections = ((data.get("jobAd") or {}).get("sections") or {}) parts = [] for name in ("companyDescription", "jobDescription", "qualifications", "additionalInformation"): sec = sections.get(name) or {} if sec.get("text"): parts.append(f"{sec.get('title') or ''}\n{sec['text']}") return {"description_html": "\n\n".join(parts)} def fetch(self) -> list[JobPosting]: out: list[JobPosting] = [] details_used = 0 offset = 0 for _ in range(self.max_pages): data = self.get(self._base, params={"limit": PAGE_SIZE, "offset": offset}).json() items = data.get("content") or [] if not items: break for item in items: if not self._keep(item): continue loc = item.get("location") or {} eid = str(item.get("id") or "") if not eid: continue try: lat = float(loc.get("latitude")) lng = float(loc.get("longitude")) except (TypeError, ValueError): lat = lng = None job = JobPosting( source=self.source_id, external_id=eid, url=f"https://jobs.smartrecruiters.com/{self.COMPANY}/{eid}", employer=self.EMPLOYER or (item.get("company") or {}).get("name", ""), title=item.get("name") or "", city=(loc.get("city") or ""), location_label=loc.get("fullLocation") or f"{loc.get('city') or ''}, {loc.get('region') or ''}", work_mode="teletravail" if loc.get("remote") else ("hybride" if loc.get("hybrid") else None), date_posted=item.get("releasedDate"), lat=lat, lng=lng, ats=self.ats, ) job.details["employment_label"] = \ (item.get("typeOfEmployment") or {}).get("label") or "" # champs structurés présents sur CHAQUE ligne de liste # (zéro requête supplémentaire) — enrichissement 2026-08-19 job.language = (item.get("language") or {}).get("code") or "" exp = (item.get("experienceLevel") or {}).get("label") or "" if exp: job.requirements["experience"] = exp for src_key, dst_key in (("function", "function"), ("industry", "industry")): label = (item.get(src_key) or {}).get("label") or "" if label: job.details[dst_key] = label dept = (item.get("department") or {}).get("label") or "" if dept: job.details.setdefault("team", dept) if item.get("refNumber"): job.details["requisition"] = str(item["refNumber"]) key = str(item.get("releasedDate") or "")[:19] if details_used < MAX_DETAILS: fresh = [False] def _fn(pid=eid, fresh=fresh): fresh[0] = True return self._fetch_detail(pid) d = self.detail(eid, key, _fn) if fresh[0]: details_used += 1 else: # budget épuisé : détail périmé plutôt que fiche vidée d = self.stale_detail(eid) job.description = clean_html(d.get("description_html", "")) out.append(job) offset += PAGE_SIZE if offset >= int(data.get("totalFound") or 0): break return out