# ============================================================================= # Job·Ka — Groupe KA # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : jobka/connectors/ultipro.py # Rôle : Classe de plateforme UKG Pro Recruiting (UltiPro) — API JSON # publique LoadSearchResults, un employeur = une sous-classe # Créé : 2026-08-18 Modifié : 2026-08-18 # ============================================================================= """Plateforme UKG Pro Recruiting (recruiting.ultipro.com). API JSON publique (celle du frontal) : - liste : POST https://recruiting.ultipro.com//JobBoard// JobBoardView/LoadSearchResults body {opportunitySearch:{Top, Skip, OrderBy…}} -> {opportunities:[{Id, Title, RequisitionNumber, BriefDescription, FullTime, JobCategoryName, PostedDate, Locations:[{Address}]}], totalCount} - détail : POST .../JobBoardView/LoadOpportunity {opportunityId} -> Description complète (HTML). Visité avec cache BD + budget. """ from __future__ import annotations import os import re from ..schema import JobPosting, clean_html, is_quebec_location from .base import BaseConnector PAGE_SIZE = 50 MAX_DETAILS = int(os.environ.get("JOBKA_ULTIPRO_DETAIL_LIMIT", "80")) class UltiProConnector(BaseConnector): """Base UltiPro — sous-classes : définir source_id, EMPLOYER, ORG, BOARD.""" ats = "ultipro" request_delay = 0.8 EMPLOYER = "" ORG = "" # ex. MES1005MESR BOARD = "" # GUID du JobBoard HOST = "recruiting.ultipro.com" # tenants canadiens : recruiting.ultipro.ca quebec_only = True max_pages = 20 @property def _board_url(self) -> str: return f"https://{self.HOST}/{self.ORG}/JobBoard/{self.BOARD}" @staticmethod def _loc_fields(loc: dict) -> tuple[str, str, str, str]: """(ville, province, code postal, libellé) d'un objet Location.""" addr = loc.get("Address") or {} city = addr.get("City") or "" state = ((addr.get("State") or {}).get("Code") if isinstance(addr.get("State"), dict) else addr.get("State")) or "" postal = addr.get("PostalCode") or "" label = loc.get("LocalizedDescription") or city return city, str(state), postal, label def _keep(self, locations: list[dict]) -> bool: if not self.quebec_only: return True for loc in locations: city, state, postal, label = self._loc_fields(loc) if state.upper() == "QC" or re.match(r"^[GHJ]\d[A-Z]", postal.upper()): return True if is_quebec_location(f"{label} {city}"): return True return False _DESC_RE = re.compile(r'"Description"\s*:\s*"((?:[^"\\]|\\.)*)"') def _fetch_detail(self, opp_id: str) -> dict: """La page OpportunityDetail embarque l'objet JSON de l'offre — on en extrait la description complète (chaîne JSON échappée).""" html = self.get(f"{self._board_url}/OpportunityDetail", params={"opportunityId": opp_id}).text best = "" for m in self._DESC_RE.finditer(html): try: import json as _json val = _json.loads(f'"{m.group(1)}"') except ValueError: continue if len(val) > len(best): best = val return {"description": clean_html(best)} def fetch(self) -> list[JobPosting]: url = f"{self._board_url}/JobBoardView/LoadSearchResults" out: list[JobPosting] = [] details_used = 0 skip = 0 for _ in range(self.max_pages): body = { "opportunitySearch": { "Top": PAGE_SIZE, "Skip": skip, "QueryString": "", "OrderBy": [{"Value": "postedDateDesc", "PropertyName": "PostedDate", "Ascending": False}], "Filters": [], }, "matchCriteria": {"PreferredJobs": [], "Educations": [], "LicenseAndCertifications": [], "Skills": [], "hasNoLicenses": False, "SkippedSkills": []}, } data = self.post(url, json=body, headers={"Accept": "application/json"}).json() opps = data.get("opportunities") or [] if not opps: break for o in opps: locations = o.get("Locations") or [] if not self._keep(locations): continue oid = str(o.get("Id") or "") if not oid: continue city = label = postal = "" for loc in locations: c, s, p, lbl = self._loc_fields(loc) if s.upper() == "QC" or is_quebec_location(f"{lbl} {c}"): city, postal, label = c or lbl, p, lbl break job = JobPosting( source=self.source_id, external_id=oid, url=f"{self._board_url}/OpportunityDetail?opportunityId={oid}", employer=self.EMPLOYER, title=o.get("Title") or "", city=city, postal_code=postal, location_label=label, date_posted=o.get("PostedDate") or None, ats=self.ats, ) job.details["requisition"] = o.get("RequisitionNumber") or "" if o.get("JobCategoryName"): job.details["team"] = o["JobCategoryName"] if o.get("FullTime") is True: job.details["employment_label"] = "Temps plein" elif o.get("FullTime") is False: job.details["employment_label"] = "Temps partiel" key = (o.get("PostedDate") or "") + (o.get("Title") or "")[:40] if details_used < MAX_DETAILS: fresh = [False] def _fn(i=oid, fresh=fresh): fresh[0] = True return self._fetch_detail(i) d = self.detail(oid, key, _fn) if fresh[0]: details_used += 1 job.description = d.get("description", "") if not job.description: job.description = clean_html(o.get("BriefDescription") or "") out.append(job) skip += PAGE_SIZE total = int(data.get("totalCount") or 0) if total and skip >= total: break return out