[ka6] fix connecteur gus: repli sur le listing JSON embarqué de la page carrières dans la plateforme zoho_recruit — le flux RSS (/jobs/<PAGE>/rss) ne reflète que les offres « publiées aux job boards » par le tenant : chez gus il n'a jamais listé qu'1 offre et est devenu VIDE le 2026-09-10 (200, 0 <item>) alors que le site carrières publie 51 offres QC bien vivantes (page détail 200, dont l'offre en base 715384000005984001). La page /jobs/<PAGE> est une SPA mais embarque le listing complet dans un <input type=hidden id=jobs> (JSON Posting_Title/City/State/Country/Job_Description/Job_Type/Date_Opened/Publish) : quand le RSS n'a aucun item, fetch() bascule sur ce JSON embarqué (filtre Publish + Québec, villes débarrassées des suffixes de quartier Zoho « Gatineau Northeast » → « Gatineau », localité entre parenthèses extraite « Vaudreuil-Soulanges (Coteau-du-Lac) » → « Coteau-du-Lac »). Sync rejoué : found=51 en 2,7 s (+50, ~1, -0), l'offre existante réactivée miss_count=0 — le RSS sous-rapportait depuis le début (médiane 1.0). Non-régression : voie RSS inchangée pour les 16 autres tenants zoho_recruit (mallette_sag 26, altitude_sports 11, maintenancexpert 1 = volumes en base), 101 tests pytest verts. pm2 restart job-ka-sync OK, site 200.
1 changed file +74 −4
modified
jobka/connectors/zoho_recruit.py
+74 −4
@@ -5,19 +5,25 @@ | ||
| 5 | 5 | # Fichier : jobka/connectors/zoho_recruit.py |
| 6 | 6 | # Rôle : Classe de plateforme Zoho Recruit (<org>.zohorecruit.com) — flux |
| 7 | 7 | # RSS public du site carrières (contenu complet en une requête) |
| 8 | −# Créé : 2026-08-25 Modifié : 2026-08-25 | |
| 8 | +# Créé : 2026-08-25 Modifié : 2026-09-10 | |
| 9 | 9 | # ============================================================================= |
| 10 | 10 | """Plateforme Zoho Recruit (sites carrières <org>.zohorecruit.com). |
| 11 | 11 | |
| 12 | 12 | - flux : GET https://<org>.zohorecruit.com/jobs/<PAGE>/rss |
| 13 | 13 | -> items avec titre, lien (id numérique), description HTML complète |
| 14 | 14 | précédée de « Catégorie: X » et « Lieu: Ville Région Pays ». |
| 15 | −La page carrières elle-même est une SPA (aucun HTML utile) : le RSS est la | |
| 16 | −seule porte publique — tout est dans une requête, pas de budget détail. | |
| 15 | +- repli : le RSS ne reflète que les offres « publiées aux job boards » par le | |
| 16 | + tenant — il peut être VIDE alors que le site carrières affiche des dizaines | |
| 17 | + d'offres (cas gus, 2026-09-10 : RSS 0 item, 51 offres QC publiées). La page | |
| 18 | + /jobs/<PAGE> est une SPA, mais elle embarque le listing complet dans un | |
| 19 | + <input type="hidden" id="jobs"> (JSON : Posting_Title, City/State/Country, | |
| 20 | + Job_Description, Job_Type, Date_Opened, Publish…) : quand le RSS n'a aucun | |
| 21 | + <item>, on parse ce JSON embarqué. | |
| 17 | 22 | """ |
| 18 | 23 | from __future__ import annotations |
| 19 | 24 | |
| 20 | 25 | import html as _html |
| 26 | +import json as _json | |
| 21 | 27 | import re |
| 22 | 28 | |
| 23 | 29 | from ..schema import JobPosting, clean_html, is_quebec_location |
@@ -30,6 +36,12 @@ _ID_RE = re.compile(r"/jobs/[^/]+/(\d+)/") | ||
| 30 | 36 | _LIEU_RE = re.compile(r"Lieu\s*:\s*([^<]+)", re.I) |
| 31 | 37 | _CAT_RE = re.compile(r"Cat[ée]gorie\s*:\s*([^<]+)", re.I) |
| 32 | 38 | _QC_RE = re.compile(r"\b(qu[ée]bec|qc)\b", re.I) |
| 39 | +_INPUT_RE = re.compile( | |
| 40 | + r'<input type="hidden" value="(.*?)" id="([\w-]+)">', re.S) | |
| 41 | +# suffixes de quartier des villes Zoho (« Gatineau Northeast », | |
| 42 | +# « Cap-de-la-Madeleine Central and southeast ») | |
| 43 | +_DISTRICT_WORDS = {"north", "south", "east", "west", "northeast", "northwest", | |
| 44 | + "southeast", "southwest", "central", "and"} | |
| 33 | 45 | |
| 34 | 46 | |
| 35 | 47 | class ZohoRecruitConnector(BaseConnector): |
@@ -58,11 +70,69 @@ class ZohoRecruitConnector(BaseConnector): | ||
| 58 | 70 | # « Lieu: Ville Région Pays » sans virgule : tester la 1re moitié |
| 59 | 71 | return bool(lieu.strip()) and is_quebec_location(lieu) |
| 60 | 72 | |
| 73 | + def _clean_city(self, city: str) -> str: | |
| 74 | + """« Vaudreuil-Soulanges (Coteau-du-Lac) » -> la localité entre | |
| 75 | + parenthèses ; sinon retirer les suffixes de quartier anglais de Zoho | |
| 76 | + (« Gatineau Northeast » -> « Gatineau »).""" | |
| 77 | + city = (city or "").strip() | |
| 78 | + m = re.search(r"\(([^)]+)\)\s*$", city) | |
| 79 | + if m: | |
| 80 | + return m.group(1).strip() | |
| 81 | + words = city.split() | |
| 82 | + while words and words[-1].lower() in _DISTRICT_WORDS: | |
| 83 | + words.pop() | |
| 84 | + return " ".join(words) or city | |
| 85 | + | |
| 86 | + def _fetch_career_page(self) -> list[JobPosting]: | |
| 87 | + """Repli : listing JSON embarqué (<input id="jobs">) de la page | |
| 88 | + carrières — utilisé quand le RSS ne contient aucun <item>.""" | |
| 89 | + page = self.get( | |
| 90 | + f"https://{self.ORG}.zohorecruit.com/jobs/{self.PAGE}").text | |
| 91 | + rows = [] | |
| 92 | + for m in _INPUT_RE.finditer(page): | |
| 93 | + if m.group(2) == "jobs": | |
| 94 | + rows = _json.loads(_html.unescape(m.group(1))) | |
| 95 | + break | |
| 96 | + out: list[JobPosting] = [] | |
| 97 | + for row in rows: | |
| 98 | + jid = str(row.get("id") or "") | |
| 99 | + title = (row.get("Posting_Title") | |
| 100 | + or row.get("Job_Opening_Name") or "").strip() | |
| 101 | + if not jid or not title or not row.get("Publish", True): | |
| 102 | + continue | |
| 103 | + lieu = " ".join(s for s in (row.get("City"), row.get("State"), | |
| 104 | + row.get("Country")) if s).strip() | |
| 105 | + if self.quebec_only and not self._is_qc(lieu): | |
| 106 | + continue | |
| 107 | + job = JobPosting( | |
| 108 | + source=self.source_id, external_id=jid, | |
| 109 | + url=(f"https://{self.ORG}.zohorecruit.com" | |
| 110 | + f"/jobs/{self.PAGE}/{jid}/"), | |
| 111 | + employer=self.EMPLOYER, title=title, | |
| 112 | + description=clean_html(row.get("Job_Description") or ""), | |
| 113 | + city=self._clean_city(row.get("City") or ""), | |
| 114 | + location_label=lieu, | |
| 115 | + date_posted=row.get("Date_Opened") or None, | |
| 116 | + work_mode="teletravail" if row.get("Remote_Job") else None, | |
| 117 | + ats=self.ats, | |
| 118 | + ) | |
| 119 | + if row.get("Job_Type"): | |
| 120 | + job.details["employment_label"] = str(row["Job_Type"]).strip() | |
| 121 | + if row.get("Industry"): | |
| 122 | + job.details["category_label"] = str(row["Industry"]).strip() | |
| 123 | + out.append(job) | |
| 124 | + return out | |
| 125 | + | |
| 61 | 126 | def fetch(self) -> list[JobPosting]: |
| 62 | 127 | xml = self.get( |
| 63 | 128 | f"https://{self.ORG}.zohorecruit.com/jobs/{self.PAGE}/rss").text |
| 129 | + items = _ITEM_RE.findall(xml) | |
| 130 | + if not items: | |
| 131 | + # RSS vide (le tenant ne publie pas/plus aux job boards) alors que | |
| 132 | + # le site carrières peut afficher des offres : repli page embarquée | |
| 133 | + return self._fetch_career_page() | |
| 64 | 134 | out: list[JobPosting] = [] |
| 65 | − for raw in _ITEM_RE.findall(xml): | |
| 135 | + for raw in items: | |
| 66 | 136 | def g(tag: str, raw=raw) -> str: |
| 67 | 137 | m = _TAG_RE[tag].search(raw) |
| 68 | 138 | return self._cdata(m.group(1)) if m else "" |
| 69 | 139 | |