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.0 KB · 144 lines python
Raw Blame History
1# =============================================================================2# Job·Ka — Groupe KA3# Auteur  : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : jobka/connectors/smartrecruiters.py6# Rôle    : Classe de plateforme SmartRecruiters (api.smartrecruiters.com) —7#           un employeur = une sous-classe de ~8 lignes (COMPANY, EMPLOYER)8# Créé    : 2026-08-17   Modifié : 2026-08-179# =============================================================================10"""Plateforme SmartRecruiters.1112API publique :13- liste  : GET https://api.smartrecruiters.com/v1/companies/<company>/postings14           ?limit=100&offset=N -> {totalFound, content:[{id, name, releasedDate,15           location:{city, region, country, remote, latitude, longitude},16           typeOfEmployment:{label}, experienceLevel, ref}]}17- détail : GET .../postings/<id> -> {jobAd:{sections:{jobDescription,18           qualifications, additionalInformation:{title, text(HTML)}}}}1920Le lieu inclut lat/lng : on les passe directement (validés par finalize()).21Page détail avec cache BD (clé = releasedDate) + budget de requêtes.22"""23from __future__ import annotations2425import os2627from ..schema import JobPosting, clean_html28from .base import BaseConnector2930PAGE_SIZE = 10031MAX_DETAILS = int(os.environ.get("JOBKA_SR_DETAIL_LIMIT", "300"))323334class SmartRecruitersConnector(BaseConnector):35    """Base SmartRecruiters — sous-classes : source_id, EMPLOYER, COMPANY."""3637    ats = "smartrecruiters"38    request_delay = 0.73940    EMPLOYER = ""41    COMPANY = ""42    max_pages = 2043    quebec_only = True4445    @property46    def _base(self) -> str:47        return f"https://api.smartrecruiters.com/v1/companies/{self.COMPANY}/postings"4849    def _keep(self, item: dict) -> bool:50        if not self.quebec_only:51            return True52        loc = item.get("location") or {}53        if (loc.get("country") or "").lower() != "ca":54            return False55        from ..schema import is_quebec_location56        return is_quebec_location(57            f"{loc.get('city') or ''}, {loc.get('region') or ''}")5859    def _fetch_detail(self, posting_id: str) -> dict:60        data = self.get(f"{self._base}/{posting_id}").json()61        sections = ((data.get("jobAd") or {}).get("sections") or {})62        parts = []63        for name in ("companyDescription", "jobDescription", "qualifications",64                     "additionalInformation"):65            sec = sections.get(name) or {}66            if sec.get("text"):67                parts.append(f"{sec.get('title') or ''}\n{sec['text']}")68        return {"description_html": "\n\n".join(parts)}6970    def fetch(self) -> list[JobPosting]:71        out: list[JobPosting] = []72        details_used = 073        offset = 074        for _ in range(self.max_pages):75            data = self.get(self._base, params={"limit": PAGE_SIZE,76                                                "offset": offset}).json()77            items = data.get("content") or []78            if not items:79                break80            for item in items:81                if not self._keep(item):82                    continue83                loc = item.get("location") or {}84                eid = str(item.get("id") or "")85                if not eid:86                    continue87                try:88                    lat = float(loc.get("latitude"))89                    lng = float(loc.get("longitude"))90                except (TypeError, ValueError):91                    lat = lng = None92                job = JobPosting(93                    source=self.source_id, external_id=eid,94                    url=f"https://jobs.smartrecruiters.com/{self.COMPANY}/{eid}",95                    employer=self.EMPLOYER or (item.get("company") or {}).get("name", ""),96                    title=item.get("name") or "",97                    city=(loc.get("city") or ""),98                    location_label=loc.get("fullLocation")99                    or f"{loc.get('city') or ''}, {loc.get('region') or ''}",100                    work_mode="teletravail" if loc.get("remote")101                    else ("hybride" if loc.get("hybrid") else None),102                    date_posted=item.get("releasedDate"),103                    lat=lat, lng=lng,104                    ats=self.ats,105                )106                job.details["employment_label"] = \107                    (item.get("typeOfEmployment") or {}).get("label") or ""108                # champs structurés présents sur CHAQUE ligne de liste109                # (zéro requête supplémentaire) — enrichissement 2026-08-19110                job.language = (item.get("language") or {}).get("code") or ""111                exp = (item.get("experienceLevel") or {}).get("label") or ""112                if exp:113                    job.requirements["experience"] = exp114                for src_key, dst_key in (("function", "function"),115                                         ("industry", "industry")):116                    label = (item.get(src_key) or {}).get("label") or ""117                    if label:118                        job.details[dst_key] = label119                dept = (item.get("department") or {}).get("label") or ""120                if dept:121                    job.details.setdefault("team", dept)122                if item.get("refNumber"):123                    job.details["requisition"] = str(item["refNumber"])124                key = str(item.get("releasedDate") or "")[:19]125                if details_used < MAX_DETAILS:126                    fresh = [False]127128                    def _fn(pid=eid, fresh=fresh):129                        fresh[0] = True130                        return self._fetch_detail(pid)131132                    d = self.detail(eid, key, _fn)133                    if fresh[0]:134                        details_used += 1135                else:136                    # budget épuisé : détail périmé plutôt que fiche vidée137                    d = self.stale_detail(eid)138                job.description = clean_html(d.get("description_html", ""))139                out.append(job)140            offset += PAGE_SIZE141            if offset >= int(data.get("totalFound") or 0):142                break143        return out144