[ka6] snapshot pré-mission niobec
1 changed file +115 −3
modified
jobka/connectors/nemaska_lithium.py
+115 −3
@@ -2,11 +2,41 @@ | ||
| 2 | 2 | # Job·Ka — Groupe KA |
| 3 | 3 | # Auteur : Simon-Pierre Boucher |
| 4 | 4 | # Contact : contact@spboucher.ai |
| 5 | −# Fichier : jobka/connectors/jobka/connectors/nemaska_lithium.py | |
| 5 | +# Fichier : jobka/connectors/nemaska_lithium.py | |
| 6 | 6 | # Rôle : Connecteur Nemaska Lithium — lithium (Whabouchi/Bécancour) — ADP WFN |
| 7 | −# Créé : 2026-08-25 Modifié : 2026-08-25 | |
| 7 | +# + repli « détail seulement » quand l'API liste échoue (maintenances | |
| 8 | +# nocturnes ADP) | |
| 9 | +# Créé : 2026-08-25 Modifié : 2026-08-30 | |
| 8 | 10 | # ============================================================================= |
| 9 | −from .adp import ADPWorkforceNowConnector | |
| 11 | +"""Nemaska Lithium — ADP Workforce Now, avec repli de revalidation. | |
| 12 | + | |
| 13 | +Contexte (2026-08-28/29) : ADP Workforce Now a des fenêtres de maintenance | |
| 14 | +nocturnes récurrentes (vendredi ET samedi soir ~22:00 → 02:00 ET) pendant | |
| 15 | +lesquelles TOUT workforcenow.adp.com redirige vers sorry.adp.com — la liste | |
| 16 | +job-requisitions renvoie alors du HTML, d'où le « Expecting value: line 1 | |
| 17 | +column 1 » du sync de 22:52 (29/08). Comme le cycle complet met ~3 h entre | |
| 18 | +deux passages de la source, un seul passage raté pendant la fenêtre suffit à | |
| 19 | +dépasser le seuil stale de 4 h. | |
| 20 | + | |
| 21 | +D'où le repli ci-dessous (même pattern que cultures_genv.py / | |
| 22 | +groupe_nordik.py / h2o_innovation.py / mitsubishi_hc_capital.py / | |
| 23 | +msi_gestion.py / ciena.py) : si la pagination liste échoue en 5xx ou en | |
| 24 | +non-JSON, on revalide une à une les offres actives connues en base via l'API | |
| 25 | +détail job-requisitions/<itemID> (qui renvoie la réquisition complète : | |
| 26 | +titre, date, lieux, description). Aucune donnée inventée — tout vient de la | |
| 27 | +réponse live. Une offre en 404 est considérée retirée. Si AUCUNE offre n'est | |
| 28 | +validable (maintenance totale : le détail redirige aussi vers | |
| 29 | +sorry.adp.com), l'erreur d'origine est propagée pour que le sync reste un | |
| 30 | +échec (inventaire préservé). Limite assumée : le repli ne découvre pas les | |
| 31 | +NOUVELLES offres — le chemin liste normal reprend seul au cycle suivant la | |
| 32 | +fin de la maintenance. | |
| 33 | +""" | |
| 34 | +from __future__ import annotations | |
| 35 | + | |
| 36 | +import requests | |
| 37 | + | |
| 38 | +from ..schema import JobPosting, clean_html | |
| 39 | +from .adp import _API, ADPWorkforceNowConnector | |
| 10 | 40 | |
| 11 | 41 | |
| 12 | 42 | class NemaskaLithiumConnector(ADPWorkforceNowConnector): |
@@ -14,3 +44,85 @@ class NemaskaLithiumConnector(ADPWorkforceNowConnector): | ||
| 14 | 44 | EMPLOYER = "Nemaska Lithium" |
| 15 | 45 | CID = "79ec02f1-3a83-4cbd-93d3-571a0f8432e8" |
| 16 | 46 | CCID = "19000101_000001" |
| 47 | + | |
| 48 | + def fetch(self) -> list[JobPosting]: | |
| 49 | + try: | |
| 50 | + return super().fetch() | |
| 51 | + except (requests.RequestException, ValueError) as exc: | |
| 52 | + resp = getattr(exc, "response", None) | |
| 53 | + if resp is not None and resp.status_code < 500: | |
| 54 | + raise # 4xx franc : pas une panne liste, ne pas masquer | |
| 55 | + jobs = self._fetch_known_jobs() | |
| 56 | + if jobs is None: | |
| 57 | + raise # API détail cassée aussi : vraie panne totale | |
| 58 | + return jobs | |
| 59 | + | |
| 60 | + def _known_ids(self) -> list[str]: | |
| 61 | + """itemID (external_id) des offres actives de la source en base.""" | |
| 62 | + from .. import db | |
| 63 | + con = db.connect() | |
| 64 | + try: | |
| 65 | + rows = con.execute( | |
| 66 | + "SELECT external_id FROM jobs WHERE source=? AND active=1", | |
| 67 | + (self.source_id,)).fetchall() | |
| 68 | + finally: | |
| 69 | + con.close() | |
| 70 | + return [str(eid) for (eid,) in rows if eid] | |
| 71 | + | |
| 72 | + def _fetch_known_jobs(self) -> list[JobPosting] | None: | |
| 73 | + """Repli sans liste : chaque offre connue est revalidée via l'API | |
| 74 | + détail (la réquisition complète, source de vérité de son existence). | |
| 75 | + Aucune donnée inventée — tout vient de la réponse du moment.""" | |
| 76 | + out: list[JobPosting] = [] | |
| 77 | + errors = 0 | |
| 78 | + for iid in self._known_ids(): | |
| 79 | + try: | |
| 80 | + r = self.get(f"{_API}/{iid}", params=self._params(), | |
| 81 | + headers={"Accept": "application/json"}).json() | |
| 82 | + except requests.HTTPError as exc: | |
| 83 | + h = getattr(exc, "response", None) | |
| 84 | + if h is not None and h.status_code == 404: | |
| 85 | + continue # offre retirée chez l'employeur | |
| 86 | + errors += 1 | |
| 87 | + continue | |
| 88 | + except (requests.RequestException, ValueError): | |
| 89 | + errors += 1 # non-JSON (maintenance) ou réseau | |
| 90 | + continue | |
| 91 | + if not (r or {}).get("requisitionTitle"): | |
| 92 | + errors += 1 # réponse inattendue : ne pas conclure au retrait | |
| 93 | + continue | |
| 94 | + locations = self._locations(r) | |
| 95 | + if not self._keep(locations): | |
| 96 | + continue | |
| 97 | + qc = next((l for l in locations | |
| 98 | + if l["prov"].upper() == "QC"), | |
| 99 | + locations[0] if locations else | |
| 100 | + {"city": self.FORCE_CITY, "postal": "", | |
| 101 | + "label": self.FORCE_CITY}) | |
| 102 | + job = JobPosting( | |
| 103 | + source=self.source_id, external_id=iid, | |
| 104 | + url=("https://workforcenow.adp.com/mascsr/default/mdf/" | |
| 105 | + f"recruitment/recruitment.html?cid={self.CID}" | |
| 106 | + f"&ccId={self.CCID}&lang={self.LANG}&jobId={iid}"), | |
| 107 | + employer=self.EMPLOYER, | |
| 108 | + title=r.get("requisitionTitle") or "", | |
| 109 | + city=qc["city"], postal_code=qc["postal"], | |
| 110 | + location_label=qc["label"], | |
| 111 | + date_posted=r.get("postDate") or None, | |
| 112 | + ats=self.ats, | |
| 113 | + ) | |
| 114 | + pay = r.get("payGradeRange") or {} | |
| 115 | + lo = (pay.get("minimumRate") or {}).get("amountValue") | |
| 116 | + hi = (pay.get("maximumRate") or {}).get("amountValue") | |
| 117 | + if lo: | |
| 118 | + job.salary_min = float(lo) | |
| 119 | + job.salary_max = float(hi) if hi else None | |
| 120 | + job.salary_unit = "year" if float(lo) > 5000 else "hour" | |
| 121 | + wl = (r.get("workLevelCode") or {}).get("shortName") | |
| 122 | + if wl: | |
| 123 | + job.details["employment_label"] = wl | |
| 124 | + job.description = clean_html(r.get("requisitionDescription") or "") | |
| 125 | + out.append(job) | |
| 126 | + if not out and errors: | |
| 127 | + return None # rien de validable : laisser le sync échouer | |
| 128 | + return out | |
| 17 | 129 | |