SPB Git

spb/job-ka

Public
10.0 KB · 218 lines python
1# =============================================================================2# Job·Ka — Groupe KA3# Auteur  : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : jobka/connectors/odoo.py6# Rôle    : Classe de plateforme Odoo website_hr_recruitment — page /jobs7#           rendue serveur + microdonnées schema.org/JobPosting des pages détail8# Créé    : 2026-10-27   Modifié : 2026-10-279# =============================================================================10"""Plateforme Odoo (module « website_hr_recruitment ») — page carrières11``/jobs`` hébergée sur le SITE de l'employeur (pas un domaine ATS partagé).1213- liste  : GET <SITE>/jobs -> HTML rendu serveur (pas de SPA) : cartes dans14           le conteneur ``id="jobs_grid"``, chacune avec une ancre15           « /jobs/<slug>-<id numérique> », le titre dans le <h3> de la16           carte et un libellé de lieu libre dans le bloc <address>17           (« Remote », « Hybride (Bureau/Remote) », parfois une vraie18           adresse). Pagination Odoo « /jobs/page/N » suivie si présente.19- détail : GET <SITE>/jobs/<slug>-<id> -> MICRODONNÉES schema.org (pas de20           JSON-LD) dans le bloc ``itemscope itemtype=…/JobPosting`` :21           meta datePosted/employmentType/title, div itemprop="description".22           ⚠️ La div description ne porte souvent que l'INTRO : le corps de23           l'offre (responsabilités, compétences, avantages) vit dans les24           blocs « website builder » qui la suivent — la description capturée25           court de la div jusqu'au footer. Cache BD + budget de re-visites26           (JOBKA_ODOO_DETAIL_LIMIT).27- garde-fou : une vraie page liste porte toujours le conteneur28  ``jobs_grid`` (même board vide) — son absence = challenge/erreur, pas une29  liste vide : retry avec backoff puis erreur explicite plutôt qu'un faux30  « 0 offre » (même doctrine que glow/JazzHR).31- lieux : Odoo publie rarement une addressLocality structurée — le bloc32  <address> est souvent un libellé libre (« Remote »). Le pied de page du33  site porte l'adresse du siège, identique partout : tout ce qui suit34  ``<footer`` est IGNORÉ. Cascade de décision Québec (doctrine bento_sushi,35  rien d'inventé) : addressLocality des microdonnées si présente, sinon36  libellé <address> s'il porte ville/province QC, sinon ville citée dans le37  bloc JobPosting (« à notre bureau de Montréal », « Située à Montréal »,38  « région de Montréal ») validée contre le référentiel des municipalités39  QC — une offre sans aucun marqueur québécois est écartée.40"""41from __future__ import annotations4243import html as _html44import os45import re4647from ..normalize import clean_html, parse_employment_type, parse_work_mode48from ..regions import region_for_city49from ..schema import JobPosting, is_quebec_location50from .base import BaseConnector5152MAX_DETAILS = int(os.environ.get("JOBKA_ODOO_DETAIL_LIMIT", "60"))53MAX_PAGES = 205455_LINK_RE = re.compile(r'href="(/jobs/[^"/?#]*-(\d+))"')56_H3_RE = re.compile(r"<h3[^>]*>(.*?)</h3>", re.S)57_ADDRESS_RE = re.compile(r"<address.*?</address>", re.S)58_META_RE = r'itemprop="%s"[^>]*\bcontent="([^"]*)"'59_LOCALITY_RE = re.compile(60    r'itemprop="addressLocality"[^>]*>([^<]+)<|' + _META_RE % "addressLocality")61_JOBPOSTING_RE = re.compile(r'<[^>]*itemtype="https?://schema\.org/JobPosting"')62_DESC_OPEN_RE = re.compile(r'<div[^>]*itemprop="description"[^>]*>')63# Ville citée dans le bloc JobPosting : « à notre bureau de Montréal »,64# « Située à Montréal », « région de Montréal ». Capture 1 à 3 mots65# capitalisés (traits d'union/apostrophes inclus) — chaque candidat est66# ensuite VALIDÉ contre le référentiel des municipalités QC67# (region_for_city), donc aucun faux lieu possible.68_CITY_RE = re.compile(69    r"(?:bureau(?:x)?\s+de|r[ée]gion\s+de|situ[ée]{1,2}\s+à|\bà)\s+"70    r"([A-ZÀ-ÿ][\w'’À-ÿ-]+(?:\s+[A-ZÀ-ÿ][\w'’À-ÿ-]+){0,2})")717273def _text(markup: str) -> str:74    """Texte brut d'un fragment HTML (balises retirées, entités décodées)."""75    return re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ",76                                                     markup or ""))).strip()777879def _city_from_text(text: str) -> str:80    """Première ville QC reconnue citée dans le texte, sinon ""."""81    for m in _CITY_RE.finditer(text or ""):82        words = m.group(1).split()83        # candidat le plus long d'abord (« Baie Comeau » avant « Baie »)84        for n in range(len(words), 0, -1):85            cand = " ".join(words[:n])86            if region_for_city(cand):87                return cand88    return ""899091class OdooConnector(BaseConnector):92    """Base Odoo website_hr_recruitment — sous-classes : définir source_id,93    EMPLOYER, SITE (racine https du site employeur, sans / final)."""9495    ats = "odoo"96    request_delay = 1.09798    EMPLOYER = ""99    SITE = ""              # ex. https://www.ingeniusolutions.com100    JOBS_PATH = "/jobs"101    quebec_only = True102103    def _fetch_list_page(self, url: str) -> str:104        import time105        last = ""106        for attempt in range(3):107            resp = self.get(url)108            page = resp.text109            if "jobs_grid" in page:110                return page111            last = (f"HTTP {resp.status_code}, {len(page)} octets, "112                    f"content-type {resp.headers.get('content-type')}")113            if attempt < 2:114                time.sleep(10 * (attempt + 1))115        raise RuntimeError(116            f"odoo {self.SITE}: page liste sans conteneur jobs_grid "117            f"après 3 essais ({last})")118119    def _fetch_detail(self, path: str) -> dict:120        page = self.get(f"{self.SITE}{path}").text121        m = _JOBPOSTING_RE.search(page)122        zone = page[m.start():] if m else page123        foot = zone.find("<footer")124        if foot > 0:125            zone = zone[:foot]      # siège social en pied de page : jamais lu126        out: dict = {}127        for prop in ("datePosted", "employmentType"):128            mm = re.search(_META_RE % prop, zone)129            if mm and mm.group(1):130                out["date_posted" if prop == "datePosted"131                    else "employment_label"] = mm.group(1)132        mm = _LOCALITY_RE.search(zone)133        if mm:134            out["city"] = _html.unescape(mm.group(1) or mm.group(2) or "").strip()135        mm = _DESC_OPEN_RE.search(zone)136        if mm:137            # la div itemprop="description" ne porte souvent que l'intro —138            # le corps de l'offre (responsabilités, compétences, avantages)139            # vit dans les blocs « website builder » qui la SUIVENT, jusqu'au140            # footer (déjà coupé) : tout prendre, CTA de candidature retiré141            body = zone[mm.end():]142            body = re.sub(r"Postuler\s+maintenant\s*!?", " ", body)143            out["description_html"] = body144        # texte d'entête (hors description) : mode/type/ville y sont affichés145        head = zone[:mm.start()] if mm else zone146        out["header_text"] = _text(head)[:2000]147        return out148149    def fetch(self) -> list[JobPosting]:150        url = f"{self.SITE}{self.JOBS_PATH}"151        pages, seen_pages = [self._fetch_list_page(url)], {url}152        for page in pages:153            if len(pages) >= MAX_PAGES:154                break155            for mm in re.finditer(r'href="(/jobs/page/\d+)"', page):156                nxt = f"{self.SITE}{mm.group(1)}"157                if nxt not in seen_pages:158                    seen_pages.add(nxt)159                    pages.append(self._fetch_list_page(nxt))160        out: list[JobPosting] = []161        details_used = 0162        seen: set[str] = set()163        for page in pages:164            matches = list(_LINK_RE.finditer(page))165            for i, m in enumerate(matches):166                path, jid = m.group(1), m.group(2)167                if jid in seen:168                    continue169                seen.add(jid)170                # bloc de la carte = entre cette ancre et la suivante171                end = (matches[i + 1].start() if i + 1 < len(matches)172                       else len(page))173                block = page[m.end():end]174                m_t = _H3_RE.search(block)175                title = _text(m_t.group(1)) if m_t else ""176                m_a = _ADDRESS_RE.search(block)177                label = _text(m_a.group(0)).strip("- ") if m_a else ""178                if details_used >= MAX_DETAILS:179                    d = self.stale_detail(jid)180                    if not d:181                        continue   # différée : ni détail frais ni cache182                else:183                    fresh = [False]184185                    def _fn(p=path, fresh=fresh):186                        fresh[0] = True187                        return self._fetch_detail(p)188189                    d = self.detail(jid, f"{title}|{label}", _fn)190                    if fresh[0]:191                        details_used += 1192                head = d.get("header_text") or ""193                city = (d.get("city") or "").strip()194                if city and not region_for_city(city):195                    city = ""      # localité structurée hors référentiel QC196                if not city:197                    city = _city_from_text(198                        f"{head} {_text(d.get('description_html') or '')}")199                if self.quebec_only and not city and not (200                        is_quebec_location(label)):201                    continue       # aucun marqueur québécois : écartée202                mode_src = f"{label} {head}"203                job = JobPosting(204                    source=self.source_id, external_id=jid,205                    url=f"{self.SITE}{path}",206                    employer=self.EMPLOYER,207                    title=title or _text(d.get("title") or ""),208                    description=clean_html(d.get("description_html") or ""),209                    city=city, location_label=label,210                    work_mode=parse_work_mode(mode_src),211                    employment_type=parse_employment_type(212                        d.get("employment_label") or mode_src),213                    date_posted=d.get("date_posted"),214                    ats=self.ats,215                )216                out.append(job)217        return out218
Files · main
Menu

Theme