[ka6] fix connecteur ciena: repli « détail seulement » pendant la panne d'index Workday du tenant — l'endpoint liste POST /wday/cxs/ciena/Careers/jobs renvoie 502 backend (errorCaseId Workday) pour tout le monde depuis ~12:39 EDT le 2026-08-28 (reconfirmé le 2026-08-29 depuis le nœud, via proxy résidentiel CA Oxylabs et en navigateur réel : le board React rend une page vide), MAIS l'API détail GET /wday/cxs/ciena/Careers/job/<externalPath> répond 200 avec données complètes et ciena.com/careers pointe toujours vers ce board (pas de migration). Le connecteur revalide donc les offres actives connues en base via l'API détail quand la liste échoue en 5xx : 404/posted=false = offre retirée, aucune donnée inventée (tout vient du jobPostingInfo live), et si RIEN n'est validable l'erreur d'origine est propagée (sync en échec, inventaire préservé). Dès que l'index Workday revient, le chemin liste normal reprend seul. Test réel : run.py sync ciena → ok, found=7 (= médiane 7.0), updated=5, removed=0, 7 actives en base. pm2 restart job-ka-sync OK, site local 200, www.job-ka.com 200.
1 changed file +99 −1
modified
jobka/connectors/ciena.py
+99 −1
@@ -5,8 +5,35 @@ | ||
| 5 | 5 | # Fichier : jobka/connectors/ciena.py |
| 6 | 6 | # Rôle : Connecteur Ciena — workday (tenant ciena.wd5, site Careers) |
| 7 | 7 | # [généré par scripts/gen_connectors.py, flux vérifié le 2026-08-23] |
| 8 | −# Créé : 2026-08-23 Modifié : 2026-08-23 | |
| 8 | +# + repli « détail seulement » quand l'API liste du tenant est en | |
| 9 | +# panne côté Workday (502 backend depuis le 2026-08-28) | |
| 10 | +# Créé : 2026-08-23 Modifié : 2026-08-29 | |
| 9 | 11 | # ============================================================================= |
| 12 | +"""Ciena — cas particulier du tenant ciena.wd5. | |
| 13 | + | |
| 14 | +Depuis ~12:39 EDT le 2026-08-28, l'endpoint liste POST | |
| 15 | +/wday/cxs/ciena/Careers/jobs renvoie 502 (errorCaseId Workday) pour tout le | |
| 16 | +monde — vérifié depuis le nœud ET via proxy résidentiel canadien, ainsi qu'en | |
| 17 | +navigateur réel (le board React rend une page vide car son propre appel /jobs | |
| 18 | +reçoit le 502). L'index de recherche du tenant est cassé côté Workday, mais | |
| 19 | +l'API détail GET /wday/cxs/ciena/Careers/job/<externalPath> répond 200 avec | |
| 20 | +les données complètes (posted, startDate, description…), et le site officiel | |
| 21 | +ciena.com/careers pointe toujours vers ce board (pas de migration d'ATS). | |
| 22 | + | |
| 23 | +D'où le repli ci-dessous : si la pagination liste échoue en 5xx, on revalide | |
| 24 | +une à une les offres actives connues en base via l'API détail. Une offre qui | |
| 25 | +répond 404 ou `posted: false` est considérée retirée (elle disparaît du | |
| 26 | +résultat, la mécanique standard miss_count fait le reste). Si AUCUNE offre | |
| 27 | +n'est validable (API détail cassée elle aussi), on propage l'erreur d'origine | |
| 28 | +pour que le sync reste un échec (inventaire préservé). Limite assumée : le | |
| 29 | +repli ne découvre pas les NOUVELLES offres — dès que l'index Workday revient, | |
| 30 | +`super().fetch()` reprend le chemin normal au cycle suivant. | |
| 31 | +""" | |
| 32 | +from __future__ import annotations | |
| 33 | + | |
| 34 | +import requests | |
| 35 | + | |
| 36 | +from ..schema import JobPosting, clean_html | |
| 10 | 37 | from .workday import WorkdayConnector |
| 11 | 38 | |
| 12 | 39 | |
@@ -16,3 +43,74 @@ class CienaConnector(WorkdayConnector): | ||
| 16 | 43 | TENANT = 'ciena' |
| 17 | 44 | HOST = 'wd5' |
| 18 | 45 | SITE = 'Careers' |
| 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 : pas la panne d'index, 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_paths(self) -> list[str]: | |
| 60 | + """externalPath (« /job/… ») des offres actives de la source en base.""" | |
| 61 | + from .. import db | |
| 62 | + con = db.connect() | |
| 63 | + try: | |
| 64 | + rows = con.execute( | |
| 65 | + "SELECT url FROM jobs WHERE source=? AND active=1", | |
| 66 | + (self.source_id,)).fetchall() | |
| 67 | + finally: | |
| 68 | + con.close() | |
| 69 | + marker = f"/{self.SITE}/job/" | |
| 70 | + paths = [] | |
| 71 | + for (url,) in rows: | |
| 72 | + idx = (url or "").find(marker) | |
| 73 | + if idx >= 0: | |
| 74 | + paths.append(url[idx + len(marker) - len("/job/"):]) | |
| 75 | + return paths | |
| 76 | + | |
| 77 | + def _fetch_known_jobs(self) -> list[JobPosting] | None: | |
| 78 | + """Repli sans liste : chaque offre connue est revalidée via l'API | |
| 79 | + détail (source de vérité de son existence). Aucune donnée inventée — | |
| 80 | + tout vient de la réponse jobPostingInfo du moment.""" | |
| 81 | + out: list[JobPosting] = [] | |
| 82 | + errors = 0 | |
| 83 | + for path in self._known_paths(): | |
| 84 | + try: | |
| 85 | + data = self.get( | |
| 86 | + f"{self._base}/wday/cxs/{self.TENANT}/{self.SITE}{path}", | |
| 87 | + headers={"Accept": "application/json"}).json() | |
| 88 | + except requests.HTTPError as exc: | |
| 89 | + r = getattr(exc, "response", None) | |
| 90 | + if r is not None and r.status_code == 404: | |
| 91 | + continue # offre retirée chez l'employeur | |
| 92 | + errors += 1 | |
| 93 | + continue | |
| 94 | + except (requests.RequestException, ValueError): | |
| 95 | + errors += 1 | |
| 96 | + continue | |
| 97 | + info = (data or {}).get("jobPostingInfo") or {} | |
| 98 | + if not info or not info.get("posted", True): | |
| 99 | + continue # dépubliée | |
| 100 | + eid = info.get("jobReqId") or path.rsplit("_", 1)[-1] | |
| 101 | + job = JobPosting( | |
| 102 | + source=self.source_id, external_id=str(eid), | |
| 103 | + url=f"{self._base}/{self.SITE}{path}", | |
| 104 | + employer=self.EMPLOYER, | |
| 105 | + title=(info.get("title") or "").strip(), | |
| 106 | + location_label=info.get("location") or "", | |
| 107 | + date_posted=info.get("startDate") or None, | |
| 108 | + ats=self.ats, | |
| 109 | + ) | |
| 110 | + job.description = clean_html(info.get("jobDescription") or "") | |
| 111 | + job.details["employment_label"] = info.get("timeType") or "" | |
| 112 | + job.apply_url = info.get("externalUrl") or "" | |
| 113 | + out.append(job) | |
| 114 | + if not out and errors: | |
| 115 | + return None # rien de validable : laisser le sync échouer | |
| 116 | + return out | |
| 19 | 117 | |