SPB Git

spb/job-ka

Public
8.6 KB · 201 lines python
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
Files · main
Menu

Theme