SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
4 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
7.2 KB · 166 lines python
Raw Blame History
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