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