SPB Git

spb/job-ka

Public

[ka6] snapshot pré-mission bento_sushi

Simon-Pierre Boucher committed (Aug 30, 2026) parent 6952882 Browse files

1 changed file +115 −4

Unified Split
modified jobka/connectors/strathmore.py +115 −4
@@ -2,11 +2,40 @@
2 # Job·Ka — Groupe KA 2 # Job·Ka — Groupe KA
3 # Auteur : Simon-Pierre Boucher 3 # Auteur : Simon-Pierre Boucher
4 # Contact : contact@spboucher.ai 4 # Contact : contact@spboucher.ai
5−# Fichier : jobka/connectors/jobka/connectors/strathmore.py 5+# Fichier : jobka/connectors/strathmore.py
6−# Rôle : Connecteur Strathmore — produits d'entretien, Saint-Hyacinthe — ADP WFN 6+# Rôle : Connecteur Strathmore — produits d'entretien, Saint-Hyacinthe —
7−# Créé : 2026-08-25 Modifié : 2026-08-25 7+# ADP WFN + repli « détail seulement » quand l'API liste échoue
8+# (maintenances nocturnes ADP)
9+# Créé : 2026-08-25 Modifié : 2026-08-30
8 # ============================================================================= 10 # =============================================================================
9−from .adp import ADPWorkforceNowConnector 11+"""Strathmore — ADP Workforce Now, avec repli de revalidation.
12+
13+Contexte (2026-08-29/30) : 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 00:31 (30/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 msi_gestion.py / cultures_genv.py /
22+groupe_nordik.py / h2o_innovation.py / mitsubishi_hc_capital.py / ciena.py) :
23+si la pagination liste échoue en 5xx ou en non-JSON, on revalide une à une
24+les offres actives connues en base via l'API détail
25+job-requisitions/<itemID> (qui renvoie la réquisition complète : titre, date,
26+lieux, description). Aucune donnée inventée — tout vient de la réponse live.
27+Une offre en 404 est considérée retirée. Si AUCUNE offre n'est validable
28+(maintenance totale : le détail redirige aussi vers sorry.adp.com), l'erreur
29+d'origine est propagée pour que le sync reste un échec (inventaire préservé).
30+Limite assumée : le repli ne découvre pas les NOUVELLES offres — le chemin
31+liste normal reprend seul au cycle suivant la fin de la maintenance.
32+"""
33+from __future__ import annotations
34+
35+import requests
36+
37+from ..schema import JobPosting, clean_html
38+from .adp import _API, ADPWorkforceNowConnector
10 39
11 40
12 class StrathmoreConnector(ADPWorkforceNowConnector): 41 class StrathmoreConnector(ADPWorkforceNowConnector):
@@ -14,3 +43,85 @@ class StrathmoreConnector(ADPWorkforceNowConnector):
14 EMPLOYER = "Strathmore" 43 EMPLOYER = "Strathmore"
15 CID = "e6cc8038-8e63-4af3-af60-97ea91f1747c" 44 CID = "e6cc8038-8e63-4af3-af60-97ea91f1747c"
16 CCID = "19000101_000001" 45 CCID = "19000101_000001"
46+
47+ 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 masquer
54+ jobs = self._fetch_known_jobs()
55+ if jobs is None:
56+ raise # API détail cassée aussi : vraie panne totale
57+ return jobs
58+
59+ def _known_ids(self) -> list[str]:
60+ """itemID (external_id) des offres actives de la source en base."""
61+ from .. import db
62+ 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]
70+
71+ def _fetch_known_jobs(self) -> list[JobPosting] | None:
72+ """Repli sans liste : chaque offre connue est revalidée via l'API
73+ 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 = 0
77+ 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'employeur
85+ errors += 1
86+ continue
87+ except (requests.RequestException, ValueError):
88+ errors += 1 # non-JSON (maintenance) ou réseau
89+ continue
90+ if not (r or {}).get("requisitionTitle"):
91+ errors += 1 # réponse inattendue : ne pas conclure au retrait
92+ continue
93+ locations = self._locations(r)
94+ if not self._keep(locations):
95+ continue
96+ qc = next((l for l in locations
97+ if l["prov"].upper() == "QC"),
98+ locations[0] if locations else
99+ {"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 None
119+ 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"] = wl
123+ 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 échouer
127+ return out
17 128
Menu

Theme