SPB Git

spb/job-ka

Public

[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.

Simon-Pierre Boucher committed (Sep 11, 2026) parent 1309720 Browse files

1 changed file +74 −4

Unified Split
modified jobka/connectors/zoho_recruit.py +74 −4
@@ -5,19 +5,25 @@
5 # Fichier : jobka/connectors/zoho_recruit.py 5 # Fichier : jobka/connectors/zoho_recruit.py
6 # Rôle : Classe de plateforme Zoho Recruit (<org>.zohorecruit.com) — flux 6 # Rôle : Classe de plateforme Zoho Recruit (<org>.zohorecruit.com) — flux
7 # RSS public du site carrières (contenu complet en une requête) 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 """Plateforme Zoho Recruit (sites carrières <org>.zohorecruit.com). 10 """Plateforme Zoho Recruit (sites carrières <org>.zohorecruit.com).
11 11
12 - flux : GET https://<org>.zohorecruit.com/jobs/<PAGE>/rss 12 - flux : GET https://<org>.zohorecruit.com/jobs/<PAGE>/rss
13 -> items avec titre, lien (id numérique), description HTML complète 13 -> items avec titre, lien (id numérique), description HTML complète
14 précédée de « Catégorie: X » et « Lieu: Ville Région Pays ». 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 15+- repli : le RSS ne reflète que les offres « publiées aux job boards » par le
16−seule porte publique — tout est dans une requête, pas de budget détail. 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 from __future__ import annotations 23 from __future__ import annotations
19 24
20 import html as _html 25 import html as _html
26+import json as _json
21 import re 27 import re
22 28
23 from ..schema import JobPosting, clean_html, is_quebec_location 29 from ..schema import JobPosting, clean_html, is_quebec_location
@@ -30,6 +36,12 @@ _ID_RE = re.compile(r"/jobs/[^/]+/(\d+)/")
30 _LIEU_RE = re.compile(r"Lieu\s*:\s*([^<]+)", re.I) 36 _LIEU_RE = re.compile(r"Lieu\s*:\s*([^<]+)", re.I)
31 _CAT_RE = re.compile(r"Cat[ée]gorie\s*:\s*([^<]+)", re.I) 37 _CAT_RE = re.compile(r"Cat[ée]gorie\s*:\s*([^<]+)", re.I)
32 _QC_RE = re.compile(r"\b(qu[ée]bec|qc)\b", re.I) 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 class ZohoRecruitConnector(BaseConnector): 47 class ZohoRecruitConnector(BaseConnector):
@@ -58,11 +70,69 @@ class ZohoRecruitConnector(BaseConnector):
58 # « Lieu: Ville Région Pays » sans virgule : tester la 1re moitié 70 # « Lieu: Ville Région Pays » sans virgule : tester la 1re moitié
59 return bool(lieu.strip()) and is_quebec_location(lieu) 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 def fetch(self) -> list[JobPosting]: 126 def fetch(self) -> list[JobPosting]:
62 xml = self.get( 127 xml = self.get(
63 f"https://{self.ORG}.zohorecruit.com/jobs/{self.PAGE}/rss").text 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 out: list[JobPosting] = [] 134 out: list[JobPosting] = []
65− for raw in _ITEM_RE.findall(xml): 135+ for raw in items:
66 def g(tag: str, raw=raw) -> str: 136 def g(tag: str, raw=raw) -> str:
67 m = _TAG_RE[tag].search(raw) 137 m = _TAG_RE[tag].search(raw)
68 return self._cdata(m.group(1)) if m else "" 138 return self._cdata(m.group(1)) if m else ""
69 139
Menu

Theme