# ============================================================================= # Job·Ka — Groupe KA # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : jobka/connectors/odoo.py # Rôle : Classe de plateforme Odoo website_hr_recruitment — page /jobs # rendue serveur + microdonnées schema.org/JobPosting des pages détail # Créé : 2026-10-27 Modifié : 2026-10-27 # ============================================================================= """Plateforme Odoo (module « website_hr_recruitment ») — page carrières ``/jobs`` hébergée sur le SITE de l'employeur (pas un domaine ATS partagé). - liste : GET /jobs -> HTML rendu serveur (pas de SPA) : cartes dans le conteneur ``id="jobs_grid"``, chacune avec une ancre « /jobs/- », le titre dans le

de la carte et un libellé de lieu libre dans le bloc
(« Remote », « Hybride (Bureau/Remote) », parfois une vraie adresse). Pagination Odoo « /jobs/page/N » suivie si présente. - détail : GET /jobs/- -> MICRODONNÉES schema.org (pas de JSON-LD) dans le bloc ``itemscope itemtype=…/JobPosting`` : meta datePosted/employmentType/title, div itemprop="description". ⚠️ La div description ne porte souvent que l'INTRO : le corps de l'offre (responsabilités, compétences, avantages) vit dans les blocs « website builder » qui la suivent — la description capturée court de la div jusqu'au footer. Cache BD + budget de re-visites (JOBKA_ODOO_DETAIL_LIMIT). - garde-fou : une vraie page liste porte toujours le conteneur ``jobs_grid`` (même board vide) — son absence = challenge/erreur, pas une liste vide : retry avec backoff puis erreur explicite plutôt qu'un faux « 0 offre » (même doctrine que glow/JazzHR). - lieux : Odoo publie rarement une addressLocality structurée — le bloc
est souvent un libellé libre (« Remote »). Le pied de page du site porte l'adresse du siège, identique partout : tout ce qui suit `` s'il porte ville/province QC, sinon ville citée dans le bloc JobPosting (« à notre bureau de Montréal », « Située à Montréal », « région de Montréal ») validée contre le référentiel des municipalités QC — une offre sans aucun marqueur québécois est écartée. """ from __future__ import annotations import html as _html import os import re from ..normalize import clean_html, parse_employment_type, parse_work_mode from ..regions import region_for_city from ..schema import JobPosting, is_quebec_location from .base import BaseConnector MAX_DETAILS = int(os.environ.get("JOBKA_ODOO_DETAIL_LIMIT", "60")) MAX_PAGES = 20 _LINK_RE = re.compile(r'href="(/jobs/[^"/?#]*-(\d+))"') _H3_RE = re.compile(r"]*>(.*?)

", re.S) _ADDRESS_RE = re.compile(r"", re.S) _META_RE = r'itemprop="%s"[^>]*\bcontent="([^"]*)"' _LOCALITY_RE = re.compile( r'itemprop="addressLocality"[^>]*>([^<]+)<|' + _META_RE % "addressLocality") _JOBPOSTING_RE = re.compile(r'<[^>]*itemtype="https?://schema\.org/JobPosting"') _DESC_OPEN_RE = re.compile(r']*itemprop="description"[^>]*>') # Ville citée dans le bloc JobPosting : « à notre bureau de Montréal », # « Située à Montréal », « région de Montréal ». Capture 1 à 3 mots # capitalisés (traits d'union/apostrophes inclus) — chaque candidat est # ensuite VALIDÉ contre le référentiel des municipalités QC # (region_for_city), donc aucun faux lieu possible. _CITY_RE = re.compile( r"(?:bureau(?:x)?\s+de|r[ée]gion\s+de|situ[ée]{1,2}\s+à|\bà)\s+" r"([A-ZÀ-ÿ][\w'’À-ÿ-]+(?:\s+[A-ZÀ-ÿ][\w'’À-ÿ-]+){0,2})") def _text(markup: str) -> str: """Texte brut d'un fragment HTML (balises retirées, entités décodées).""" return re.sub(r"\s+", " ", _html.unescape(re.sub(r"<[^>]+>", " ", markup or ""))).strip() def _city_from_text(text: str) -> str: """Première ville QC reconnue citée dans le texte, sinon "".""" for m in _CITY_RE.finditer(text or ""): words = m.group(1).split() # candidat le plus long d'abord (« Baie Comeau » avant « Baie ») for n in range(len(words), 0, -1): cand = " ".join(words[:n]) if region_for_city(cand): return cand return "" class OdooConnector(BaseConnector): """Base Odoo website_hr_recruitment — sous-classes : définir source_id, EMPLOYER, SITE (racine https du site employeur, sans / final).""" ats = "odoo" request_delay = 1.0 EMPLOYER = "" SITE = "" # ex. https://www.ingeniusolutions.com JOBS_PATH = "/jobs" quebec_only = True def _fetch_list_page(self, url: str) -> str: import time last = "" for attempt in range(3): resp = self.get(url) page = resp.text if "jobs_grid" in page: return page last = (f"HTTP {resp.status_code}, {len(page)} octets, " f"content-type {resp.headers.get('content-type')}") if attempt < 2: time.sleep(10 * (attempt + 1)) raise RuntimeError( f"odoo {self.SITE}: page liste sans conteneur jobs_grid " f"après 3 essais ({last})") def _fetch_detail(self, path: str) -> dict: page = self.get(f"{self.SITE}{path}").text m = _JOBPOSTING_RE.search(page) zone = page[m.start():] if m else page foot = zone.find(" 0: zone = zone[:foot] # siège social en pied de page : jamais lu out: dict = {} for prop in ("datePosted", "employmentType"): mm = re.search(_META_RE % prop, zone) if mm and mm.group(1): out["date_posted" if prop == "datePosted" else "employment_label"] = mm.group(1) mm = _LOCALITY_RE.search(zone) if mm: out["city"] = _html.unescape(mm.group(1) or mm.group(2) or "").strip() mm = _DESC_OPEN_RE.search(zone) if mm: # la div itemprop="description" ne porte souvent que l'intro — # le corps de l'offre (responsabilités, compétences, avantages) # vit dans les blocs « website builder » qui la SUIVENT, jusqu'au # footer (déjà coupé) : tout prendre, CTA de candidature retiré body = zone[mm.end():] body = re.sub(r"Postuler\s+maintenant\s*!?", " ", body) out["description_html"] = body # texte d'entête (hors description) : mode/type/ville y sont affichés head = zone[:mm.start()] if mm else zone out["header_text"] = _text(head)[:2000] return out def fetch(self) -> list[JobPosting]: url = f"{self.SITE}{self.JOBS_PATH}" pages, seen_pages = [self._fetch_list_page(url)], {url} for page in pages: if len(pages) >= MAX_PAGES: break for mm in re.finditer(r'href="(/jobs/page/\d+)"', page): nxt = f"{self.SITE}{mm.group(1)}" if nxt not in seen_pages: seen_pages.add(nxt) pages.append(self._fetch_list_page(nxt)) out: list[JobPosting] = [] details_used = 0 seen: set[str] = set() for page in pages: matches = list(_LINK_RE.finditer(page)) for i, m in enumerate(matches): path, jid = m.group(1), m.group(2) if jid in seen: continue seen.add(jid) # bloc de la carte = entre cette ancre et la suivante end = (matches[i + 1].start() if i + 1 < len(matches) else len(page)) block = page[m.end():end] m_t = _H3_RE.search(block) title = _text(m_t.group(1)) if m_t else "" m_a = _ADDRESS_RE.search(block) label = _text(m_a.group(0)).strip("- ") if m_a else "" if details_used >= MAX_DETAILS: d = self.stale_detail(jid) if not d: continue # différée : ni détail frais ni cache else: fresh = [False] def _fn(p=path, fresh=fresh): fresh[0] = True return self._fetch_detail(p) d = self.detail(jid, f"{title}|{label}", _fn) if fresh[0]: details_used += 1 head = d.get("header_text") or "" city = (d.get("city") or "").strip() if city and not region_for_city(city): city = "" # localité structurée hors référentiel QC if not city: city = _city_from_text( f"{head} {_text(d.get('description_html') or '')}") if self.quebec_only and not city and not ( is_quebec_location(label)): continue # aucun marqueur québécois : écartée mode_src = f"{label} {head}" job = JobPosting( source=self.source_id, external_id=jid, url=f"{self.SITE}{path}", employer=self.EMPLOYER, title=title or _text(d.get("title") or ""), description=clean_html(d.get("description_html") or ""), city=city, location_label=label, work_mode=parse_work_mode(mode_src), employment_type=parse_employment_type( d.get("employment_label") or mode_src), date_posted=d.get("date_posted"), ats=self.ats, ) out.append(job) return out