SPB Git forge

spb/job-ka

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

[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 13 days ago (Sep 11, 2026) parent 1309720

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