HTML 82.1%
Python 14.6%
TypeScript 1.9%
CSS 1%
JavaScript 0.5%
1# =============================================================================2# Job·Ka — Groupe KA3# Auteur : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : jobka/connectors/zoho_recruit.py6# Rôle : Classe de plateforme Zoho Recruit (<org>.zohorecruit.com) — flux7# RSS public du site carrières (contenu complet en une requête)8# Créé : 2026-08-25 Modifié : 2026-09-109# =============================================================================10"""Plateforme Zoho Recruit (sites carrières <org>.zohorecruit.com).1112- flux : GET https://<org>.zohorecruit.com/jobs/<PAGE>/rss13 -> items avec titre, lien (id numérique), description HTML complète14 précédée de « Catégorie: X » et « Lieu: Ville Région Pays ».15- repli : le RSS ne reflète que les offres « publiées aux job boards » par le16 tenant — il peut être VIDE alors que le site carrières affiche des dizaines17 d'offres (cas gus, 2026-09-10 : RSS 0 item, 51 offres QC publiées). La page18 /jobs/<PAGE> est une SPA, mais elle embarque le listing complet dans un19 <input type="hidden" id="jobs"> (JSON : Posting_Title, City/State/Country,20 Job_Description, Job_Type, Date_Opened, Publish…) : quand le RSS n'a aucun21 <item>, on parse ce JSON embarqué.22"""23from __future__ import annotations2425import html as _html26import json as _json27import re2829from ..schema import JobPosting, clean_html, is_quebec_location30from .base import BaseConnector3132_ITEM_RE = re.compile(r"<item>(.*?)</item>", re.S | re.I)33_TAG_RE = {t: re.compile(rf"<{t}>(.*?)</{t}>", re.S | re.I)34 for t in ("title", "link", "description", "pubDate")}35_ID_RE = re.compile(r"/jobs/[^/]+/(\d+)/")36_LIEU_RE = re.compile(r"Lieu\s*:\s*([^<]+)", re.I)37_CAT_RE = re.compile(r"Cat[ée]gorie\s*:\s*([^<]+)", 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"}454647class ZohoRecruitConnector(BaseConnector):48 """Base Zoho Recruit — sous-classes : définir source_id, EMPLOYER, ORG,49 PAGE (nom du site carrières dans l'URL, ex. « Careers »)."""5051 ats = "zoho_recruit"52 request_delay = 1.05354 EMPLOYER = ""55 ORG = "" # <ORG>.zohorecruit.com56 PAGE = "Careers" # /jobs/<PAGE>/rss57 quebec_only = True5859 def _cdata(self, s: str) -> str:60 s = (s or "").strip()61 if s.startswith("<![CDATA["):62 s = s[9:]63 if s.endswith("]]>"):64 s = s[:-3]65 return s.strip()6667 def _is_qc(self, lieu: str) -> bool:68 if _QC_RE.search(lieu):69 return True70 # « Lieu: Ville Région Pays » sans virgule : tester la 1re moitié71 return bool(lieu.strip()) and is_quebec_location(lieu)7273 def _clean_city(self, city: str) -> str:74 """« Vaudreuil-Soulanges (Coteau-du-Lac) » -> la localité entre75 parenthèses ; sinon retirer les suffixes de quartier anglais de Zoho76 (« 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 city8586 def _fetch_career_page(self) -> list[JobPosting]:87 """Repli : listing JSON embarqué (<input id="jobs">) de la page88 carrières — utilisé quand le RSS ne contient aucun <item>."""89 page = self.get(90 f"https://{self.ORG}.zohorecruit.com/jobs/{self.PAGE}").text91 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 break96 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 continue103 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 continue107 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 out125126 def fetch(self) -> list[JobPosting]:127 xml = self.get(128 f"https://{self.ORG}.zohorecruit.com/jobs/{self.PAGE}/rss").text129 items = _ITEM_RE.findall(xml)130 if not items:131 # RSS vide (le tenant ne publie pas/plus aux job boards) alors que132 # le site carrières peut afficher des offres : repli page embarquée133 return self._fetch_career_page()134 out: list[JobPosting] = []135 for raw in items:136 def g(tag: str, raw=raw) -> str:137 m = _TAG_RE[tag].search(raw)138 return self._cdata(m.group(1)) if m else ""139 link = _html.unescape(g("link"))140 m_id = _ID_RE.search(link)141 if not link or not m_id:142 continue143 desc = _html.unescape(g("description"))144 m_lieu = _LIEU_RE.search(desc)145 lieu = _html.unescape(m_lieu.group(1)).strip() if m_lieu else ""146 if self.quebec_only and not self._is_qc(lieu):147 continue148 m_cat = _CAT_RE.search(desc)149 # ville = premier segment du lieu (« Quebec City Quebec Canada »)150 city = re.sub(r"\s+(Quebec|Québec|QC|Canada)\b.*$", "", lieu,151 flags=re.I).strip()152 job = JobPosting(153 source=self.source_id, external_id=m_id.group(1), url=link,154 employer=self.EMPLOYER,155 title=_html.unescape(g("title")),156 description=clean_html(desc),157 city=city, location_label=lieu,158 date_posted=g("pubDate") or None,159 ats=self.ats,160 )161 if m_cat:162 job.details["category_label"] = \163 _html.unescape(m_cat.group(1)).strip()164 out.append(job)165 return out166