1# =============================================================================2# Job·Ka — Groupe KA3# Auteur : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : jobka/connectors/glow.py6# Rôle : Classe de plateforme Glow in the Cloud (jobs.glowinthecloud.com)7# — liste HTML rendue serveur + JSON-LD JobPosting des pages détail8# Créé : 2026-10-27 Modifié : 2026-10-279# =============================================================================10"""Plateforme Glow in the Cloud (jobs.glowinthecloud.com/<org>), ATS québécois.1112- liste : GET https://jobs.glowinthecloud.com/<ORG>13 -> HTML rendu serveur (pas de SPA) : cartes <li> de la14 ul.jobs-listing, chacune avec un lien « /<ORG>/<id numérique> »15 (l'id ressemble à une date de parution : 20261015002), le titre16 dans l'attribut title de l'ancre et l'adresse COMPLÈTE dans la17 feature « Adresse » (« 561 Rue Joseph-Latour, Sherbrooke, QC18 J1C 0W2, Canada ») — le filtre Québec se décide dès la liste.19- détail : GET https://jobs.glowinthecloud.com/<ORG>/<id>20 -> bloc JSON-LD JobPosting (description HTML, datePosted,21 employmentType, jobLocation.address avec locality/postalCode,22 logo employeur). ⚠️ Le JSON contient des caractères de contrôle23 littéraux (tabulations dans la description) : l'extracteur24 tolérant de _jsonld les absorbe. Cache BD + budget de re-visites.25- garde-fou : une vraie page liste porte toujours le conteneur26 « jobSearch-result » (même board vide) — son absence = challenge/erreur,27 pas une liste vide : retry avec backoff puis erreur explicite plutôt28 qu'un faux « 0 offre » (même doctrine que JazzHR/motosillimitees).29- lieux : la feature « Adresse » de la liste peut MANQUER (3 offres30 groupepmi sur 10 au 2026-10-27) ou n'être qu'une rue sans ville31 (« 561 rue Joseph-Latour ») — et le ``<address>`` des pages détail est32 le siège social en pied de page, identique partout : ne JAMAIS s'en33 servir comme lieu d'offre. Cascade de décision Québec : adresse de34 liste si elle porte ville/province, sinon jobLocation du JSON-LD,35 sinon ville citée dans la description (« Chantier Joliette », « à36 Joliette ») validée contre le référentiel des municipalités QC — même37 doctrine que bento_sushi : rien d'inventé, une offre sans marqueur38 québécois est écartée.39"""40from __future__ import annotations4142import html as _html43import os44import re4546from ..regions import region_for_city47from ..schema import JobPosting, is_quebec_location48from . import _jsonld49from .base import BaseConnector5051BASE = "https://jobs.glowinthecloud.com"5253MAX_DETAILS = int(os.environ.get("JOBKA_GLOW_DETAIL_LIMIT", "60"))5455_ADDR_RE = re.compile(56 r'title="Adresse"[^>]*>.*?<span>([^<]+)</span>', re.S | re.I)57_POSTAL_RE = re.compile(r"[A-Z]\d[A-Z]\s?\d[A-Z]\d")58_PROV_RE = re.compile(r"^(QC|Qu[ée]bec)\b", re.I)59# Province canadienne hors Québec dans l'adresse de liste : rejet immédiat60# sans visiter le détail.61_PROV_HORS_QC_RE = re.compile(62 r",\s*(ON|AB|BC|MB|SK|NS|NB|NL|PE|YT|NT|NU)\b", re.I)63# Ville citée dans la description : « Chantier Joliette », « site … à64# Joliette », « chez notre client à Joliette ». Capture 1 à 3 mots65# capitalisés (traits d'union/apostrophes inclus : Val-Joli,66# Notre-Dame-des-Prairies, L'Assomption) — chaque candidat est ensuite67# VALIDÉ contre le référentiel des municipalités QC (region_for_city),68# donc aucun faux lieu possible.69_DESC_CITY_RE = re.compile(70 r"(?:[Cc]hantier|\bà)\s+"71 r"([A-ZÀ-ÿ][\w'’À-ÿ-]+(?:\s+[A-ZÀ-ÿ][\w'’À-ÿ-]+){0,2})")727374def _city_from_description(text: str) -> str:75 """Première ville QC reconnue citée dans la description, sinon ""."""76 for m in _DESC_CITY_RE.finditer(text or ""):77 words = m.group(1).split()78 # candidat le plus long d'abord (« Baie Comeau » avant « Baie »)79 for n in range(len(words), 0, -1):80 cand = " ".join(words[:n])81 if region_for_city(cand):82 return cand83 return ""848586def _parse_address(addr: str) -> dict:87 """« 561 Rue Joseph-Latour, Sherbrooke, QC J1C 0W2, Canada » ->88 rue / ville / code postal (formats courts « Ville, QC, Canada » aussi)."""89 out: dict = {}90 parts = [p.strip() for p in addr.split(",") if p.strip()]91 prov_i = next((i for i, p in enumerate(parts) if _PROV_RE.match(p)), None)92 if prov_i is None:93 return out94 m = _POSTAL_RE.search(parts[prov_i])95 if m:96 out["postal_code"] = m.group(0)97 if prov_i >= 1:98 out["city"] = parts[prov_i - 1]99 if prov_i >= 2:100 out["address"] = ", ".join(parts[:prov_i - 1])101 return out102103104class GlowConnector(BaseConnector):105 """Base Glow in the Cloud — sous-classes : définir source_id, EMPLOYER,106 ORG (jobs.glowinthecloud.com/<ORG>)."""107108 ats = "glow"109 request_delay = 1.0110111 EMPLOYER = ""112 ORG = "" # jobs.glowinthecloud.com/<ORG>113 quebec_only = True114115 def _fetch_list(self) -> str:116 import time117 last = ""118 for attempt in range(3):119 resp = self.get(f"{BASE}/{self.ORG}")120 page = resp.text121 if "jobSearch-result" in page or "jobs-listing" in page:122 return page123 last = (f"HTTP {resp.status_code}, {len(page)} octets, "124 f"content-type {resp.headers.get('content-type')}")125 if attempt < 2:126 time.sleep(10 * (attempt + 1))127 raise RuntimeError(128 f"glow {self.ORG}: page liste sans structure jobboard "129 f"après 3 essais ({last})")130131 def _fetch_detail(self, jid: str) -> dict:132 page = self.get(f"{BASE}/{self.ORG}/{jid}").text133 node = _jsonld.extract_jobposting(page)134 return _jsonld.jobposting_fields(node) if node else {}135136 def fetch(self) -> list[JobPosting]:137 page = self._fetch_list()138 link_re = re.compile(139 rf'<a\s+href="/{re.escape(self.ORG)}/(\d+)"[^>]*?'140 rf'title="([^"]*)"')141 matches = list(link_re.finditer(page))142 out: list[JobPosting] = []143 details_used = 0144 seen: set[str] = set()145 for i, m in enumerate(matches):146 jid = m.group(1)147 if jid in seen:148 continue149 seen.add(jid)150 title = _html.unescape(m.group(2)).strip()151 # bloc de la carte = entre cette ancre et la suivante (ou la fin)152 end = matches[i + 1].start() if i + 1 < len(matches) else len(page)153 block = page[m.end():end]154 m_addr = _ADDR_RE.search(block)155 addr = _html.unescape(m_addr.group(1)).strip() if m_addr else ""156 loc = _parse_address(addr)157 # Province hors Québec explicite dans la liste : inutile de158 # visiter le détail. Une adresse muette (rue seule, ou absente)159 # ne décide RIEN ici — le détail tranchera.160 if (self.quebec_only and not loc161 and _PROV_HORS_QC_RE.search(addr)):162 continue163 if details_used >= MAX_DETAILS:164 d = self.stale_detail(jid)165 if not d:166 continue # différée : pas de détail frais ni en cache167 else:168 fresh = [False]169170 def _fn(j=jid, fresh=fresh):171 fresh[0] = True172 return self._fetch_detail(j)173174 d = self.detail(jid, f"{title}|{addr}", _fn)175 if fresh[0]:176 details_used += 1177 desc_city = ""178 if self.quebec_only and not (179 loc or is_quebec_location(addr)):180 # cascade : jobLocation du JSON-LD, sinon ville citée dans181 # la description (validée municipalité QC), sinon écartée182 d_city = d.get("city") or ""183 if not is_quebec_location(184 f"{d_city} {d.get('region_code') or ''}".strip()):185 desc_city = _city_from_description(186 d.get("description_html") or "")187 if not desc_city:188 continue189 job = JobPosting(190 source=self.source_id, external_id=jid,191 url=f"{BASE}/{self.ORG}/{jid}",192 employer=self.EMPLOYER, title=title,193 address=loc.get("address") or "",194 city=loc.get("city") or desc_city,195 postal_code=loc.get("postal_code") or "",196 location_label=addr, ats=self.ats,197 )198 _jsonld.apply_fields(job, d)199 out.append(job)200 return out201