HTML 82.1%
Python 14.6%
TypeScript 1.9%
CSS 1%
JavaScript 0.5%
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