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/richelieu.py6# Rôle : Connecteur Quincaillerie Richelieu — ADP WFN + repli « détail7# seulement » quand l'API liste échoue (maintenances nocturnes ADP)8# Créé : 2026-08-25 Modifié : 2026-08-309# =============================================================================10"""Quincaillerie Richelieu — ADP Workforce Now, avec repli de revalidation.1112Contexte (2026-08-28/29) : ADP Workforce Now a des fenêtres de maintenance13nocturnes récurrentes (vendredi ET samedi soir ~22:00 → 02:00 ET) pendant14lesquelles TOUT workforcenow.adp.com redirige vers sorry.adp.com — la liste15job-requisitions renvoie alors du HTML, d'où le « Expecting value: line 116column 1 » du sync de 23:00 (29/08). Comme le cycle complet met ~3 h entre17deux passages de la source, un seul passage raté pendant la fenêtre suffit à18dépasser le seuil stale de 4 h.1920D'où le repli ci-dessous (même pattern que cultures_genv.py /21groupe_nordik.py / h2o_innovation.py / mitsubishi_hc_capital.py /22msi_gestion.py / ciena.py) : si la pagination liste échoue en 5xx ou en23non-JSON, on revalide une à une les offres actives connues en base via l'API24détail job-requisitions/<itemID> (qui renvoie la réquisition complète :25titre, date, lieux, description). Aucune donnée inventée — tout vient de la26réponse live. Une offre en 404 est considérée retirée. Si AUCUNE offre n'est27validable (maintenance totale : le détail redirige aussi vers28sorry.adp.com), l'erreur d'origine est propagée pour que le sync reste un29échec (inventaire préservé). Limite assumée : le repli ne découvre pas les30NOUVELLES offres — le chemin liste normal reprend seul au cycle suivant la31fin de la maintenance.32"""33from __future__ import annotations3435import requests3637from ..schema import JobPosting, clean_html38from .adp import _API, ADPWorkforceNowConnector394041class RichelieuConnector(ADPWorkforceNowConnector):42 source_id = "richelieu"43 EMPLOYER = "Quincaillerie Richelieu"44 CID = "a43f2867-2f0c-434c-8680-d6c5543c4dc5"45 CCID = "19000101_000001"4647 def fetch(self) -> list[JobPosting]:48 try:49 return super().fetch()50 except (requests.RequestException, ValueError) as exc:51 resp = getattr(exc, "response", None)52 if resp is not None and resp.status_code < 500:53 raise # 4xx franc : pas une panne liste, ne pas masquer54 jobs = self._fetch_known_jobs()55 if jobs is None:56 raise # API détail cassée aussi : vraie panne totale57 return jobs5859 def _known_ids(self) -> list[str]:60 """itemID (external_id) des offres actives de la source en base."""61 from .. import db62 con = db.connect()63 try:64 rows = con.execute(65 "SELECT external_id FROM jobs WHERE source=? AND active=1",66 (self.source_id,)).fetchall()67 finally:68 con.close()69 return [str(eid) for (eid,) in rows if eid]7071 def _fetch_known_jobs(self) -> list[JobPosting] | None:72 """Repli sans liste : chaque offre connue est revalidée via l'API73 détail (la réquisition complète, source de vérité de son existence).74 Aucune donnée inventée — tout vient de la réponse du moment."""75 out: list[JobPosting] = []76 errors = 077 for iid in self._known_ids():78 try:79 r = self.get(f"{_API}/{iid}", params=self._params(),80 headers={"Accept": "application/json"}).json()81 except requests.HTTPError as exc:82 h = getattr(exc, "response", None)83 if h is not None and h.status_code == 404:84 continue # offre retirée chez l'employeur85 errors += 186 continue87 except (requests.RequestException, ValueError):88 errors += 1 # non-JSON (maintenance) ou réseau89 continue90 if not (r or {}).get("requisitionTitle"):91 errors += 1 # réponse inattendue : ne pas conclure au retrait92 continue93 locations = self._locations(r)94 if not self._keep(locations):95 continue96 qc = next((l for l in locations97 if l["prov"].upper() == "QC"),98 locations[0] if locations else99 {"city": self.FORCE_CITY, "postal": "",100 "label": self.FORCE_CITY})101 job = JobPosting(102 source=self.source_id, external_id=iid,103 url=("https://workforcenow.adp.com/mascsr/default/mdf/"104 f"recruitment/recruitment.html?cid={self.CID}"105 f"&ccId={self.CCID}&lang={self.LANG}&jobId={iid}"),106 employer=self.EMPLOYER,107 title=r.get("requisitionTitle") or "",108 city=qc["city"], postal_code=qc["postal"],109 location_label=qc["label"],110 date_posted=r.get("postDate") or None,111 ats=self.ats,112 )113 pay = r.get("payGradeRange") or {}114 lo = (pay.get("minimumRate") or {}).get("amountValue")115 hi = (pay.get("maximumRate") or {}).get("amountValue")116 if lo:117 job.salary_min = float(lo)118 job.salary_max = float(hi) if hi else None119 job.salary_unit = "year" if float(lo) > 5000 else "hour"120 wl = (r.get("workLevelCode") or {}).get("shortName")121 if wl:122 job.details["employment_label"] = wl123 job.description = clean_html(r.get("requisitionDescription") or "")124 out.append(job)125 if not out and errors:126 return None # rien de validable : laisser le sync échouer127 return out128