# ============================================================================= # Job·Ka — Groupe KA # Auteur : Simon-Pierre Boucher # Contact : contact@spboucher.ai # Fichier : jobka/connectors/glow.py # Rôle : Classe de plateforme Glow in the Cloud (jobs.glowinthecloud.com) # — liste HTML rendue serveur + JSON-LD JobPosting des pages détail # Créé : 2026-10-27 Modifié : 2026-10-27 # ============================================================================= """Plateforme Glow in the Cloud (jobs.glowinthecloud.com/), ATS québécois. - liste : GET https://jobs.glowinthecloud.com/ -> HTML rendu serveur (pas de SPA) : cartes
  • de la ul.jobs-listing, chacune avec un lien « // » (l'id ressemble à une date de parution : 20261015002), le titre dans l'attribut title de l'ancre et l'adresse COMPLÈTE dans la feature « Adresse » (« 561 Rue Joseph-Latour, Sherbrooke, QC J1C 0W2, Canada ») — le filtre Québec se décide dès la liste. - détail : GET https://jobs.glowinthecloud.com// -> bloc JSON-LD JobPosting (description HTML, datePosted, employmentType, jobLocation.address avec locality/postalCode, logo employeur). ⚠️ Le JSON contient des caractères de contrôle littéraux (tabulations dans la description) : l'extracteur tolérant de _jsonld les absorbe. Cache BD + budget de re-visites. - garde-fou : une vraie page liste porte toujours le conteneur « jobSearch-result » (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 JazzHR/motosillimitees). - lieux : la feature « Adresse » de la liste peut MANQUER (3 offres groupepmi sur 10 au 2026-10-27) ou n'être qu'une rue sans ville (« 561 rue Joseph-Latour ») — et le ``
    `` des pages détail est le siège social en pied de page, identique partout : ne JAMAIS s'en servir comme lieu d'offre. Cascade de décision Québec : adresse de liste si elle porte ville/province, sinon jobLocation du JSON-LD, sinon ville citée dans la description (« Chantier Joliette », « à Joliette ») validée contre le référentiel des municipalités QC — même doctrine que bento_sushi : rien d'inventé, une offre sans marqueur québécois est écartée. """ from __future__ import annotations import html as _html import os import re from ..regions import region_for_city from ..schema import JobPosting, is_quebec_location from . import _jsonld from .base import BaseConnector BASE = "https://jobs.glowinthecloud.com" MAX_DETAILS = int(os.environ.get("JOBKA_GLOW_DETAIL_LIMIT", "60")) _ADDR_RE = re.compile( r'title="Adresse"[^>]*>.*?([^<]+)', re.S | re.I) _POSTAL_RE = re.compile(r"[A-Z]\d[A-Z]\s?\d[A-Z]\d") _PROV_RE = re.compile(r"^(QC|Qu[ée]bec)\b", re.I) # Province canadienne hors Québec dans l'adresse de liste : rejet immédiat # sans visiter le détail. _PROV_HORS_QC_RE = re.compile( r",\s*(ON|AB|BC|MB|SK|NS|NB|NL|PE|YT|NT|NU)\b", re.I) # Ville citée dans la description : « Chantier Joliette », « site … à # Joliette », « chez notre client à Joliette ». Capture 1 à 3 mots # capitalisés (traits d'union/apostrophes inclus : Val-Joli, # Notre-Dame-des-Prairies, L'Assomption) — chaque candidat est ensuite # VALIDÉ contre le référentiel des municipalités QC (region_for_city), # donc aucun faux lieu possible. _DESC_CITY_RE = re.compile( r"(?:[Cc]hantier|\bà)\s+" r"([A-ZÀ-ÿ][\w'’À-ÿ-]+(?:\s+[A-ZÀ-ÿ][\w'’À-ÿ-]+){0,2})") def _city_from_description(text: str) -> str: """Première ville QC reconnue citée dans la description, sinon "".""" for m in _DESC_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 "" def _parse_address(addr: str) -> dict: """« 561 Rue Joseph-Latour, Sherbrooke, QC J1C 0W2, Canada » -> rue / ville / code postal (formats courts « Ville, QC, Canada » aussi).""" out: dict = {} parts = [p.strip() for p in addr.split(",") if p.strip()] prov_i = next((i for i, p in enumerate(parts) if _PROV_RE.match(p)), None) if prov_i is None: return out m = _POSTAL_RE.search(parts[prov_i]) if m: out["postal_code"] = m.group(0) if prov_i >= 1: out["city"] = parts[prov_i - 1] if prov_i >= 2: out["address"] = ", ".join(parts[:prov_i - 1]) return out class GlowConnector(BaseConnector): """Base Glow in the Cloud — sous-classes : définir source_id, EMPLOYER, ORG (jobs.glowinthecloud.com/).""" ats = "glow" request_delay = 1.0 EMPLOYER = "" ORG = "" # jobs.glowinthecloud.com/ quebec_only = True def _fetch_list(self) -> str: import time last = "" for attempt in range(3): resp = self.get(f"{BASE}/{self.ORG}") page = resp.text if "jobSearch-result" in page or "jobs-listing" 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"glow {self.ORG}: page liste sans structure jobboard " f"après 3 essais ({last})") def _fetch_detail(self, jid: str) -> dict: page = self.get(f"{BASE}/{self.ORG}/{jid}").text node = _jsonld.extract_jobposting(page) return _jsonld.jobposting_fields(node) if node else {} def fetch(self) -> list[JobPosting]: page = self._fetch_list() link_re = re.compile( rf']*?' rf'title="([^"]*)"') matches = list(link_re.finditer(page)) out: list[JobPosting] = [] details_used = 0 seen: set[str] = set() for i, m in enumerate(matches): jid = m.group(1) if jid in seen: continue seen.add(jid) title = _html.unescape(m.group(2)).strip() # bloc de la carte = entre cette ancre et la suivante (ou la fin) end = matches[i + 1].start() if i + 1 < len(matches) else len(page) block = page[m.end():end] m_addr = _ADDR_RE.search(block) addr = _html.unescape(m_addr.group(1)).strip() if m_addr else "" loc = _parse_address(addr) # Province hors Québec explicite dans la liste : inutile de # visiter le détail. Une adresse muette (rue seule, ou absente) # ne décide RIEN ici — le détail tranchera. if (self.quebec_only and not loc and _PROV_HORS_QC_RE.search(addr)): continue if details_used >= MAX_DETAILS: d = self.stale_detail(jid) if not d: continue # différée : pas de détail frais ni en cache else: fresh = [False] def _fn(j=jid, fresh=fresh): fresh[0] = True return self._fetch_detail(j) d = self.detail(jid, f"{title}|{addr}", _fn) if fresh[0]: details_used += 1 desc_city = "" if self.quebec_only and not ( loc or is_quebec_location(addr)): # cascade : jobLocation du JSON-LD, sinon ville citée dans # la description (validée municipalité QC), sinon écartée d_city = d.get("city") or "" if not is_quebec_location( f"{d_city} {d.get('region_code') or ''}".strip()): desc_city = _city_from_description( d.get("description_html") or "") if not desc_city: continue job = JobPosting( source=self.source_id, external_id=jid, url=f"{BASE}/{self.ORG}/{jid}", employer=self.EMPLOYER, title=title, address=loc.get("address") or "", city=loc.get("city") or desc_city, postal_code=loc.get("postal_code") or "", location_label=addr, ats=self.ats, ) _jsonld.apply_fields(job, d) out.append(job) return out