SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
5 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
5.9 KB · 128 lines python
Raw Blame History
1# =============================================================================2# Job·Ka — Groupe KA3# Auteur  : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : jobka/connectors/h2o_innovation.py6# Rôle    : Connecteur H2O Innovation — traitement de l'eau, Québec — ADP WFN7#           + repli « détail seulement » quand l'API liste échoue8#           (maintenances nocturnes récurrentes d'ADP)9# Créé    : 2026-08-25   Modifié : 2026-08-3010# =============================================================================11"""H2O Innovation — 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:19 (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 / 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 H2oInnovationConnector(ADPWorkforceNowConnector):42    source_id = "h2o_innovation"43    EMPLOYER = "H2O Innovation"44    CID = "7682e0ff-ab6e-4e2a-b4c4-4d3845982888"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