SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
4 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
6.9 KB · 153 lines python
Raw Blame History
1# =============================================================================2# Job·Ka — Groupe KA3# Auteur  : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : jobka/connectors/eq3_ltd.py6# Rôle    : Connecteur EQ3 — breezy (org « eq3-ltd »)7#           [généré par scripts/gen_connectors.py, flux vérifié le 2026-08-17]8#           + repli « board délisté » : le 2026-09-15, EQ3 a masqué le listing9#           public de son board Breezy (/json = [], sitemap vide, HTML10#           %LABEL_NO_POSITIONS%) alors que les pages /p/ des offres restent11#           EN LIGNE et ouvertes aux candidatures (vérifié live) et que la12#           page carrières officielle eq3.com pointe toujours vers ce board.13#           Quand la liste est vide, on re-vérifie donc chaque offre active14#           connue via sa page /p/ (JSON-LD JobPosting exigé, rejet des pages15#           « Position Closed ») — données réelles constatées à la source,16#           jamais inventées. Limite assumée : pas de découverte de NOUVELLES17#           offres tant que le board est délisté ; dès que /json redevient18#           non vide, le chemin normal reprend seul.19#           + retrait immédiat des offres CONFIRMÉES fermées (2026-09-16) :20#           dans la nuit du 15 au 16, EQ3 a fermé ses 12 postes (« Position21#           Closed » vérifié live sur chaque page /p/). La garde anti-dérive22#           du moteur suspend les retraits quand found=0 vs médiane — correct23#           pour un connecteur cassé, mais ici la fermeture est PROUVÉE page24#           par page : ces offres sont donc désactivées directement, sans25#           attendre que la fenêtre de médiane se vide.26# Créé    : 2026-08-17   Modifié : 2026-09-1627# =============================================================================28from __future__ import annotations2930import json31import re3233from ..schema import JobPosting, clean_html, is_quebec_location34from .breezy import BreezyConnector3536_LD_RE = re.compile(r'<script type="application/ld\+json">(.*?)</script>',37                    re.S)38# page d'offre fermée : Breezy garde le JSON-LD mais affiche39# <h1>Position Closed</h1> « no longer accepting candidates »40_CLOSED_RE = re.compile(r"position closed|no longer accepting", re.I)414243class Eq3LtdConnector(BreezyConnector):44    source_id = 'eq3_ltd'45    EMPLOYER = 'EQ3'46    ORG = 'eq3-ltd'4748    def fetch(self) -> list[JobPosting]:49        jobs = super().fetch()50        if jobs:51            return jobs52        return self._fetch_board_delisted()5354    def _known_active_rows(self) -> list[dict]:55        """Offres actives déjà en base pour cette source (dernier état vu56        via l'API liste) — servent de liste de rappel à re-vérifier live."""57        from .. import db58        con = db.connect()59        try:60            rows = con.execute(61                "SELECT uid, external_id, url, title, city, location_label,"62                "       salary_label, date_posted, details, company_logo"63                "  FROM jobs WHERE source=? AND active=1 AND url<>''",64                (self.source_id,)).fetchall()65            return [dict(r) for r in rows]66        finally:67            con.close()6869    def _deactivate_closed(self, uids: list[str]) -> None:70        """Retire immédiatement les offres dont la page /p/ affiche71        « Position Closed » : fermeture VÉRIFIÉE à la source, pas une simple72        disparition de liste. Sans ce retrait direct, la garde anti-dérive du73        moteur (retraits suspendus quand found=0 vs médiane) maintiendrait en74        ligne des offres prouvées fermées pendant plusieurs cycles."""75        import time7677        from .. import db78        con = db.connect()79        try:80            now = time.time()81            con.executemany(82                "UPDATE jobs SET active=0, updated_at=? WHERE uid=? AND active=1",83                [(now, uid) for uid in uids])84            con.commit()85        finally:86            con.close()87        print(f"[job-ka]   eq3_ltd: {len(uids)} offre(s) confirmée(s) "88              f"fermée(s) sur leur page /p/ (« Position Closed ») — retirées")8990    def _fetch_board_delisted(self) -> list[JobPosting]:91        rows = self._known_active_rows()92        if not rows:93            return []94        print(f"[job-ka]   eq3_ltd: board Breezy délisté (/json vide) — "95              f"re-vérification live de {len(rows)} offre(s) connue(s)")96        out: list[JobPosting] = []97        closed_uids: list[str] = []98        for row in rows:99            try:100                html = self.get(row["url"]).text101            except Exception:102                # page injoignable ce cycle : l'offre manque le sync et suivra103                # le chemin normal miss_count -> retrait après délai de grâce104                continue105            if _CLOSED_RE.search(html):106                # réellement fermée (constaté sur la page) : retrait direct107                closed_uids.append(row["uid"])108                continue109            ld = None110            for m in _LD_RE.finditer(html):111                try:112                    cand = json.loads(m.group(1))113                except ValueError:114                    continue115                if isinstance(cand, dict) and cand.get("@type") == "JobPosting":116                    ld = cand117                    break118            if ld is None:119                continue                     # pas une fiche d'offre rendue120            addr = ((ld.get("jobLocation") or {}).get("address") or {})121            region = (addr.get("addressRegion") or "").upper()122            city = addr.get("addressLocality") or row["city"] or ""123            if region != "QC" and not is_quebec_location(f"{city}, {region}"):124                continue125            job = JobPosting(126                source=self.source_id,127                external_id=str(row["external_id"]),128                url=row["url"],129                employer=self.EMPLOYER,130                title=ld.get("title") or row["title"] or "",131                description=clean_html(ld.get("description") or ""),132                city=city,133                location_label=row["location_label"] or city,134                date_posted=ld.get("datePosted") or row["date_posted"],135                # le libellé salarial n'existe que dans l'API liste — on136                # conserve le dernier vu (finalize() sait aussi l'extraire137                # de la description au besoin)138                salary_label=row["salary_label"] or "",139                company_logo=row["company_logo"] or "",140                ats=self.ats,141            )142            try:143                details = json.loads(row["details"] or "{}")144            except ValueError:145                details = {}146            for key in ("employment_label", "team"):147                if details.get(key):148                    job.details[key] = details[key]149            out.append(job)150        if closed_uids:151            self._deactivate_closed(closed_uids)152        return out153