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/groupe_nordik.py6# Rôle : Connecteur Groupe Nordik (Nordik Spa-Nature) — spa/tourisme,7# Chelsea (Outaouais) — ADP Workforce Now + repli « détail seulement »8# quand l'API liste échoue (maintenances nocturnes récurrentes d'ADP)9# Créé : 2026-08-25 Modifié : 2026-08-3010# =============================================================================11"""Groupe Nordik — ADP Workforce Now, avec repli de revalidation.1213Contexte (2026-08-28/29) : ADP Workforce Now a des fenêtres de maintenance14nocturnes récurrentes (vendredi ET samedi soir ~22:00 → 02:00 ET) pendant15lesquelles TOUT workforcenow.adp.com redirige vers sorry.adp.com — la liste16job-requisitions renvoie alors du HTML, d'où le « Expecting value: line 117column 1 » du sync de 22:16 (29/08). Comme le cycle complet met ~3 h entre18deux passages de la source, un seul passage raté pendant la fenêtre suffit à19dépasser le seuil stale de 4 h.2021D'où le repli ci-dessous (même pattern que cultures_genv.py / ciena.py) : si22la pagination liste échoue en 5xx ou en non-JSON, on revalide une à une les23offres actives connues en base via l'API détail job-requisitions/<itemID>24(qui renvoie la réquisition complète : titre, date, lieux, description).25Aucune donnée inventée — tout vient de la réponse live. Une offre en 404 est26considérée retirée. Si AUCUNE offre n'est validable (maintenance totale :27le détail redirige aussi vers sorry.adp.com), l'erreur d'origine est28propagée pour que le sync reste un échec (inventaire préservé). Limite29assumée : le repli ne découvre pas les NOUVELLES offres — le chemin liste30normal reprend seul au cycle suivant la fin de la maintenance.31"""32from __future__ import annotations3334import requests3536from ..schema import JobPosting, clean_html37from .adp import _API, ADPWorkforceNowConnector383940class GroupeNordikConnector(ADPWorkforceNowConnector):41 source_id = "groupe_nordik"42 EMPLOYER = "Groupe Nordik (Nordik Spa-Nature)"43 CID = "b0ed2582-382c-4558-a5a6-be185625046e"44 CCID = "168215231511_9449"4546 def fetch(self) -> list[JobPosting]:47 try:48 return super().fetch()49 except (requests.RequestException, ValueError) as exc:50 resp = getattr(exc, "response", None)51 if resp is not None and resp.status_code < 500:52 raise # 4xx franc : pas une panne liste, ne pas masquer53 jobs = self._fetch_known_jobs()54 if jobs is None:55 raise # API détail cassée aussi : vraie panne totale56 return jobs5758 def _known_ids(self) -> list[str]:59 """itemID (external_id) des offres actives de la source en base."""60 from .. import db61 con = db.connect()62 try:63 rows = con.execute(64 "SELECT external_id FROM jobs WHERE source=? AND active=1",65 (self.source_id,)).fetchall()66 finally:67 con.close()68 return [str(eid) for (eid,) in rows if eid]6970 def _fetch_known_jobs(self) -> list[JobPosting] | None:71 """Repli sans liste : chaque offre connue est revalidée via l'API72 détail (la réquisition complète, source de vérité de son existence).73 Aucune donnée inventée — tout vient de la réponse du moment."""74 out: list[JobPosting] = []75 errors = 076 for iid in self._known_ids():77 try:78 r = self.get(f"{_API}/{iid}", params=self._params(),79 headers={"Accept": "application/json"}).json()80 except requests.HTTPError as exc:81 h = getattr(exc, "response", None)82 if h is not None and h.status_code == 404:83 continue # offre retirée chez l'employeur84 errors += 185 continue86 except (requests.RequestException, ValueError):87 errors += 1 # non-JSON (maintenance) ou réseau88 continue89 if not (r or {}).get("requisitionTitle"):90 errors += 1 # réponse inattendue : ne pas conclure au retrait91 continue92 locations = self._locations(r)93 if not self._keep(locations):94 continue95 qc = next((l for l in locations96 if l["prov"].upper() == "QC"),97 locations[0] if locations else98 {"city": self.FORCE_CITY, "postal": "",99 "label": self.FORCE_CITY})100 job = JobPosting(101 source=self.source_id, external_id=iid,102 url=("https://workforcenow.adp.com/mascsr/default/mdf/"103 f"recruitment/recruitment.html?cid={self.CID}"104 f"&ccId={self.CCID}&lang={self.LANG}&jobId={iid}"),105 employer=self.EMPLOYER,106 title=r.get("requisitionTitle") or "",107 city=qc["city"], postal_code=qc["postal"],108 location_label=qc["label"],109 date_posted=r.get("postDate") or None,110 ats=self.ats,111 )112 pay = r.get("payGradeRange") or {}113 lo = (pay.get("minimumRate") or {}).get("amountValue")114 hi = (pay.get("maximumRate") or {}).get("amountValue")115 if lo:116 job.salary_min = float(lo)117 job.salary_max = float(hi) if hi else None118 job.salary_unit = "year" if float(lo) > 5000 else "hour"119 wl = (r.get("workLevelCode") or {}).get("shortName")120 if wl:121 job.details["employment_label"] = wl122 job.description = clean_html(r.get("requisitionDescription") or "")123 out.append(job)124 if not out and errors:125 return None # rien de validable : laisser le sync échouer126 return out127