SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
2 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%

[ka6] fix connecteur cultures_genv: repli « détail seulement » pendant les maintenances nocturnes d'ADP WFN — cause de l'échec du 2026-08-29 22:01 : deuxième fenêtre de maintenance planifiée d'ADP Workforce Now (samedi soir ~22:00→02:00 ET, après celle de vendredi déjà documentée), pendant laquelle tout workforcenow.adp.com redirige vers sorry.adp.com et la liste job-requisitions renvoie du HTML (« Expecting value: line 1 column 1 »). TOUS les connecteurs ADP passés entre 22:00 et 23:27 ont échoué à l'identique (cookit, groupe_nordik, h2o_innovation, msi_gestion, mitsubishi_hc_capital, nemaska_lithium, richelieu, etc.) = systémique côté ADP, aucun tenant en cause. Comme le cycle met ~3 h entre deux passages, un seul passage raté pendant la fenêtre dépasse le seuil stale de 4 h. Fix (pattern ciena ae44d7c) : si la liste échoue en 5xx/non-JSON, revalidation une à une des offres actives connues via l'API détail job-requisitions/<itemID> (réquisition complète live : titre, date, lieux, description — aucune donnée inventée) ; 404 = offre retirée ; si RIEN n'est validable (maintenance totale) l'erreur d'origine est propagée (sync en échec, inventaire préservé). Le chemin liste normal reprend seul dès la fin de la maintenance. Tests réels : repli avec liste 502 simulée → 7 offres revalidées live ; run.py sync cultures_genv (chemin normal) → ok, found=7 (= médiane 7.0), 7 actives en base. pm2 restart job-ka-sync OK, site local 200, www.job-ka.com 200.

Simon-Pierre Boucher committed 25 days ago (Aug 30, 2026) parent ae44d7c

1 changed file +112 −3

modified jobka/connectors/cultures_genv.py +112 −3
@@ -4,10 +4,37 @@
4 4 # Contact : contact@spboucher.ai
5 5 # Fichier : jobka/connectors/cultures_genv.py
6 6 # Rôle : Connecteur Cultures Gen V (serres — Sainte-Clotilde, Montérégie)
7 −# — ADP Workforce Now
8 −# Créé : 2026-08-19 Modifié : 2026-08-19
7 +# — ADP Workforce Now + repli « détail seulement » quand l'API
8 +# liste échoue (maintenances nocturnes récurrentes d'ADP WFN)
9 +# Créé : 2026-08-19 Modifié : 2026-08-30
9 10 # =============================================================================
10 −from .adp import ADPWorkforceNowConnector
11 +"""Cultures Gen V — 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 » des syncs de 22:01 (29/08) et 22:04 (28/08). Comme le cycle
18 +complet met ~3 h entre deux passages de la source, un seul passage raté
19 +pendant la fenêtre suffit à dépasser le seuil stale de 4 h.
20 +
21 +D'où le repli ci-dessous (même pattern que ciena.py pour Workday) : si la
22 +pagination liste échoue en 5xx ou en non-JSON, on revalide une à une les
23 +offres actives connues en base via l'API détail job-requisitions/<itemID>
24 +(qui renvoie la réquisition complète : titre, date, lieux, description).
25 +Aucune donnée inventée — tout vient de la réponse live. Une offre en 404 est
26 +considérée retirée. Si AUCUNE offre n'est validable (maintenance totale :
27 +le détail redirige aussi vers sorry.adp.com), l'erreur d'origine est
28 +propagée pour que le sync reste un échec (inventaire préservé). Limite
29 +assumée : le repli ne découvre pas les NOUVELLES offres — le chemin liste
30 +normal reprend seul au cycle suivant la fin de la maintenance.
31 +"""
32 +from __future__ import annotations
33 +
34 +import requests
35 +
36 +from ..schema import JobPosting, clean_html
37 +from .adp import _API, ADPWorkforceNowConnector
11 38
12 39
13 40 class CulturesGenVConnector(ADPWorkforceNowConnector):
@@ -15,3 +42,85 @@ class CulturesGenVConnector(ADPWorkforceNowConnector):
15 42 EMPLOYER = "Cultures Gen V"
16 43 CID = "8b346731-d189-4cf2-a14c-9e48d38befde"
17 44 CCID = "36427051_2"
45 +
46 + 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 masquer
53 + jobs = self._fetch_known_jobs()
54 + if jobs is None:
55 + raise # API détail cassée aussi : vraie panne totale
56 + return jobs
57 +
58 + def _known_ids(self) -> list[str]:
59 + """itemID (external_id) des offres actives de la source en base."""
60 + from .. import db
61 + 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]
69 +
70 + def _fetch_known_jobs(self) -> list[JobPosting] | None:
71 + """Repli sans liste : chaque offre connue est revalidée via l'API
72 + 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 = 0
76 + 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'employeur
84 + errors += 1
85 + continue
86 + except (requests.RequestException, ValueError):
87 + errors += 1 # non-JSON (maintenance) ou réseau
88 + continue
89 + if not (r or {}).get("requisitionTitle"):
90 + errors += 1 # réponse inattendue : ne pas conclure au retrait
91 + continue
92 + locations = self._locations(r)
93 + if not self._keep(locations):
94 + continue
95 + qc = next((l for l in locations
96 + if l["prov"].upper() == "QC"),
97 + locations[0] if locations else
98 + {"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 None
118 + 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"] = wl
122 + 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 échouer
126 + return out
18 127