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 · 127 lines python
Raw Blame History
1# =============================================================================2# Job·Ka — Groupe KA3# Auteur  : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : jobka/connectors/cultures_genv.py6# Rôle    : Connecteur Cultures Gen V (serres — Sainte-Clotilde, Montérégie)7#           — ADP Workforce Now + repli « détail seulement » quand l'API8#           liste échoue (maintenances nocturnes récurrentes d'ADP WFN)9# Créé    : 2026-08-19   Modifié : 2026-08-3010# =============================================================================11"""Cultures Gen V — 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 » des syncs de 22:01 (29/08) et 22:04 (28/08). Comme le cycle18complet met ~3 h entre deux passages de la source, un seul passage raté19pendant la fenêtre suffit à dépasser le seuil stale de 4 h.2021D'où le repli ci-dessous (même pattern que ciena.py pour Workday) : si la22pagination 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 CulturesGenVConnector(ADPWorkforceNowConnector):41    source_id = "cultures_genv"42    EMPLOYER = "Cultures Gen V"43    CID = "8b346731-d189-4cf2-a14c-9e48d38befde"44    CCID = "36427051_2"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