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/niobec.py6# Rôle : Connecteur Niobec (Magris Performance Materials) — mine de niobium,7# Saint-Honoré (Saguenay) — ADP WFN + repli « détail seulement »8# quand l'API liste échoue (maintenances nocturnes ADP)9# Créé : 2026-08-25 Modifié : 2026-08-3010# =============================================================================11"""Niobec (Magris Performance Materials) — ADP Workforce Now, avec repli.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:52 (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 /22groupe_nordik.py / h2o_innovation.py / mitsubishi_hc_capital.py /23msi_gestion.py / ciena.py) : si la pagination liste échoue en 5xx ou en24non-JSON, on revalide une à une les offres actives connues en base via l'API25détail job-requisitions/<itemID> (qui renvoie la réquisition complète :26titre, date, lieux, description). Aucune donnée inventée — tout vient de la27réponse live. Une offre en 404 est considérée retirée. Si AUCUNE offre n'est28validable (maintenance totale : le détail redirige aussi vers29sorry.adp.com), l'erreur d'origine est propagée pour que le sync reste un30échec (inventaire préservé). Limite assumée : le repli ne découvre pas les31NOUVELLES offres — le chemin liste normal reprend seul au cycle suivant la32fin de la maintenance.33"""34from __future__ import annotations3536import requests3738from ..schema import JobPosting, clean_html39from .adp import _API, ADPWorkforceNowConnector404142class NiobecConnector(ADPWorkforceNowConnector):43 source_id = "niobec"44 EMPLOYER = "Niobec (Magris Performance Materials)"45 CID = "099e24b7-1868-4283-a5d0-994ec5a80995"46 CCID = "19000101_000001"47 FORCE_CITY = "Saint-Honoré" # mine mono-site Saint-Honoré (Saguenay) : l'API n'expose aucune localisation4849 def fetch(self) -> list[JobPosting]:50 try:51 return super().fetch()52 except (requests.RequestException, ValueError) as exc:53 resp = getattr(exc, "response", None)54 if resp is not None and resp.status_code < 500:55 raise # 4xx franc : pas une panne liste, ne pas masquer56 jobs = self._fetch_known_jobs()57 if jobs is None:58 raise # API détail cassée aussi : vraie panne totale59 return jobs6061 def _known_ids(self) -> list[str]:62 """itemID (external_id) des offres actives de la source en base."""63 from .. import db64 con = db.connect()65 try:66 rows = con.execute(67 "SELECT external_id FROM jobs WHERE source=? AND active=1",68 (self.source_id,)).fetchall()69 finally:70 con.close()71 return [str(eid) for (eid,) in rows if eid]7273 def _fetch_known_jobs(self) -> list[JobPosting] | None:74 """Repli sans liste : chaque offre connue est revalidée via l'API75 détail (la réquisition complète, source de vérité de son existence).76 Aucune donnée inventée — tout vient de la réponse du moment."""77 out: list[JobPosting] = []78 errors = 079 for iid in self._known_ids():80 try:81 r = self.get(f"{_API}/{iid}", params=self._params(),82 headers={"Accept": "application/json"}).json()83 except requests.HTTPError as exc:84 h = getattr(exc, "response", None)85 if h is not None and h.status_code == 404:86 continue # offre retirée chez l'employeur87 errors += 188 continue89 except (requests.RequestException, ValueError):90 errors += 1 # non-JSON (maintenance) ou réseau91 continue92 if not (r or {}).get("requisitionTitle"):93 errors += 1 # réponse inattendue : ne pas conclure au retrait94 continue95 locations = self._locations(r)96 if not self._keep(locations):97 continue98 qc = next((l for l in locations99 if l["prov"].upper() == "QC"),100 locations[0] if locations else101 {"city": self.FORCE_CITY, "postal": "",102 "label": self.FORCE_CITY})103 job = JobPosting(104 source=self.source_id, external_id=iid,105 url=("https://workforcenow.adp.com/mascsr/default/mdf/"106 f"recruitment/recruitment.html?cid={self.CID}"107 f"&ccId={self.CCID}&lang={self.LANG}&jobId={iid}"),108 employer=self.EMPLOYER,109 title=r.get("requisitionTitle") or "",110 city=qc["city"], postal_code=qc["postal"],111 location_label=qc["label"],112 date_posted=r.get("postDate") or None,113 ats=self.ats,114 )115 pay = r.get("payGradeRange") or {}116 lo = (pay.get("minimumRate") or {}).get("amountValue")117 hi = (pay.get("maximumRate") or {}).get("amountValue")118 if lo:119 job.salary_min = float(lo)120 job.salary_max = float(hi) if hi else None121 job.salary_unit = "year" if float(lo) > 5000 else "hour"122 wl = (r.get("workLevelCode") or {}).get("shortName")123 if wl:124 job.details["employment_label"] = wl125 job.description = clean_html(r.get("requisitionDescription") or "")126 out.append(job)127 if not out and errors:128 return None # rien de validable : laisser le sync échouer129 return out130