SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
5 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
6.2 KB · 140 lines python
Raw Blame History
1# =============================================================================2# Job·Ka — Groupe KA3# Auteur  : Simon-Pierre Boucher4# Contact : contact@spboucher.ai5# Fichier : jobka/connectors/recruitee.py6# Rôle    : Classe de plateforme Recruitee (<org>.recruitee.com/api/offers) —7#           un employeur = une sous-classe de ~8 lignes (ORG, EMPLOYER)8# Créé    : 2026-08-17   Modifié : 2026-08-179# =============================================================================10"""Plateforme Recruitee.1112API publique : GET https://<org>.recruitee.com/api/offers/13-> {offers:[{id, title, city, state_code, state_name, country_code,14   postal_code, remote, hybrid, on_site, employment_type_code,15   description(HTML, dans la liste), requirements(HTML),16   salary:{min, max, period, currency}, careers_url, published_at17   ("2026-08-11 19:27:47 UTC"), close_at, company_name}]}.1819Une seule requête liste TOUT (descriptions + salaires structurés).20"""21from __future__ import annotations2223from ..schema import JobPosting, clean_html, is_quebec_location24from .base import BaseConnector2526_EMPLOYMENT = {27    "fulltime": "temps_plein", "fulltime_permanent": "temps_plein",28    "parttime": "temps_partiel", "parttime_permanent": "temps_partiel",29    "internship": "stage", "traineeship": "stage",30    "temporary": "contractuel", "contract": "contractuel",31    "freelance": "contractuel", "seasonal": "saisonnier",32}33_PERIOD = {"hour": "hour", "hourly": "hour", "day": "day", "week": "week",34           "month": "month", "monthly": "month", "year": "year",35           "yearly": "year", "annual": "year"}363738class RecruiteeConnector(BaseConnector):39    """Base Recruitee — sous-classes : définir source_id, EMPLOYER, ORG."""4041    ats = "recruitee"42    request_delay = 1.04344    EMPLOYER = ""45    ORG = ""46    quebec_only = True4748    def _keep(self, item: dict) -> bool:49        if not self.quebec_only:50            return True51        if (item.get("country_code") or "").upper() not in ("CA", ""):52            return False53        if (item.get("state_code") or "").upper() == "QC":54            return True55        return is_quebec_location(56            f"{item.get('city') or ''}, {item.get('state_name') or ''}")5758    def fetch(self) -> list[JobPosting]:59        data = self.get(f"https://{self.ORG}.recruitee.com/api/offers/").json()60        out: list[JobPosting] = []61        for item in data.get("offers") or []:62            if (item.get("status") or "published") != "published":63                continue64            if not self._keep(item):65                continue66            # traductions : quand une version FRANÇAISE existe, c'est elle67            # qu'on affiche (public québécois) — langue fr/bilingue déduite68            translations = item.get("translations") or {}69            langs = sorted(k for k, v in translations.items()70                           if isinstance(v, dict)71                           and (v.get("title") or v.get("description")))72            fr = translations.get("fr") if isinstance(73                translations.get("fr"), dict) else None74            title = item.get("title") or ""75            desc_html = item.get("description") or ""76            reqs_html = item.get("requirements") or ""77            if fr:78                title = fr.get("title") or title79                desc_html = fr.get("description") or desc_html80                reqs_html = fr.get("requirements") or reqs_html81            language = ""82            if "fr" in langs:83                language = "bilingue" if "en" in langs else "fr"84            elif langs == ["en"]:85                language = "en"86            desc = clean_html(desc_html)87            reqs = clean_html(reqs_html)88            if reqs:89                desc = f"{desc}\n\nExigences\n{reqs}" if desc else reqs90            sal = item.get("salary") or {}9192            def _num(v):93                try:94                    return float(v) if v not in (None, "") else None95                except (TypeError, ValueError):96                    return None9798            work_mode = ("teletravail" if item.get("remote")99                         else "hybride" if item.get("hybrid")100                         else "presentiel" if item.get("on_site") else None)101            job = JobPosting(102                source=self.source_id, external_id=str(item.get("id") or ""),103                url=item.get("careers_url") or "",104                employer=self.EMPLOYER or item.get("company_name") or "",105                title=title,106                description=desc,107                city=item.get("city") or "",108                postal_code=item.get("postal_code") or "",109                location_label=f"{item.get('city') or ''}, "110                               f"{item.get('state_name') or ''}".strip(", "),111                work_mode=work_mode,112                employment_type=_EMPLOYMENT.get(113                    (item.get("employment_type_code") or "").lower()),114                salary_min=_num(sal.get("min")), salary_max=_num(sal.get("max")),115                salary_unit=_PERIOD.get((sal.get("period") or "").lower()),116                date_posted=item.get("published_at") or item.get("created_at"),117                date_deadline=item.get("close_at"),118                language=language,119                apply_url=item.get("careers_apply_url") or "",120                ats=self.ats,121            )122            if item.get("department"):123                job.details["team"] = item["department"]124            # niveau d'expérience / scolarité structurés (codes Recruitee)125            if item.get("experience_code"):126                job.requirements.setdefault(127                    "experience", str(item["experience_code"]).replace("_", " "))128            if item.get("education_code"):129                job.requirements.setdefault(130                    "education", str(item["education_code"]).replace("_", " "))131            hours_min = item.get("min_hours_per_week") or item.get("min_hours")132            hours_max = item.get("max_hours_per_week") or item.get("max_hours")133            if hours_min or hours_max:134                job.details["hours_per_week"] = (135                    f"{hours_min or hours_max}"136                    if not (hours_min and hours_max and hours_min != hours_max)137                    else f"{hours_min}-{hours_max}")138            out.append(job)139        return out140