SPB Git

spb/job-ka

Public

Connecteurs phase 2 — correctifs et enrichissement champ par champ. Correctifs : villes SuccessFactors à trait d'union (préfixe municipal MAMH sur le slug, bogue « St »/« Pointe »), colonne date Taleo = date LIMITE chez agnico_eagle (+ garde-fou date future). Enrichissement : SmartRecruiters experienceLevel/function/industry/language/refNumber (lignes de liste, zéro requête), BambooHR locationType→work_mode, Breezy is_remote/logo_url/streetAddress, JSON-LD logo/industry/educationRequirements/skills (portails+icims+DR), Ashby applyUrl + composantes Bonus/Equity/Commission, Lever applyUrl + blocs opening/additional + allLocations, Workable application_url + function/industry, Recruitee careers_apply_url + description FR (translations) + expérience/scolarité + heures/sem, Greenhouse language + requisition_id, Workday externalUrl→apply_url + work_mode via facette Remote Type + clé cache v2, Guichet-Emplois avantages structurés + langue de travail, njoyn apply_url en colonne ; secours cache périmé quand le budget détail est épuisé (plus de fiches vidées)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Simon-Pierre Boucher committed (Aug 19, 2026) parent c618452 Browse files

20 changed files +321 −44

modified jobka/connectors/_jsonld.py +33 −1
@@ -78,10 +78,34 @@ def jobposting_fields(node: dict) -> dict:
78 78
79 org = _first(node.get("hiringOrganization")) 79 org = _first(node.get("hiringOrganization"))
80 if isinstance(org, dict): 80 if isinstance(org, dict):
81− out["employer"] = org.get("name") or "" 81+ out["employer"] = org.get("name") or org.get("legalName") or ""
82+ logo = _first(org.get("logo"))
83+ if isinstance(logo, dict): # ImageObject
84+ logo = logo.get("url") or logo.get("contentUrl")
85+ if isinstance(logo, str) and logo.startswith("http"):
86+ out["company_logo"] = logo
82 elif isinstance(org, str): 87 elif isinstance(org, str):
83 out["employer"] = org 88 out["employer"] = org
84 89
90+ ind = _first(node.get("industry"))
91+ if isinstance(ind, dict):
92+ ind = ind.get("name")
93+ if isinstance(ind, str) and ind.strip():
94+ out["industry"] = ind.strip()
95+
96+ edu = _first(node.get("educationRequirements"))
97+ if isinstance(edu, dict): # EducationalOccupationalCredential
98+ edu = edu.get("credentialCategory") or edu.get("name")
99+ if isinstance(edu, str) and edu.strip():
100+ out["education"] = edu.strip()
101+
102+ skills = node.get("skills")
103+ if isinstance(skills, list):
104+ skills = ", ".join(str(s) for s in skills if s)
105+ if isinstance(skills, str) and skills.strip():
106+ from ..normalize import clean_html as _ch
107+ out["skills"] = _ch(skills)[:2000]
108+
85 loc = _first(node.get("jobLocation")) 109 loc = _first(node.get("jobLocation"))
86 if isinstance(loc, dict): 110 if isinstance(loc, dict):
87 addr = loc.get("address") or {} 111 addr = loc.get("address") or {}
@@ -155,6 +179,14 @@ def apply_fields(job, fields: dict, *, override_employer: bool = False) -> None:
155 job.salary_unit = fields.get("salary_unit") 179 job.salary_unit = fields.get("salary_unit")
156 if fields.get("employment_label"): 180 if fields.get("employment_label"):
157 job.details.setdefault("employment_label", fields["employment_label"]) 181 job.details.setdefault("employment_label", fields["employment_label"])
182+ if fields.get("company_logo") and not job.company_logo:
183+ job.company_logo = fields["company_logo"]
184+ if fields.get("industry"):
185+ job.details.setdefault("industry", fields["industry"])
186+ if fields.get("education"):
187+ job.requirements.setdefault("education", fields["education"])
188+ if fields.get("skills"):
189+ job.requirements.setdefault("skills", fields["skills"])
158 if fields.get("lat") is not None and job.lat is None: 190 if fields.get("lat") is not None and job.lat is None:
159 job.lat = fields["lat"] 191 job.lat = fields["lat"]
160 job.lng = fields.get("lng") 192 job.lng = fields.get("lng")
modified jobka/connectors/agnico_eagle.py +3 −0
@@ -15,3 +15,6 @@ class AgnicoEagleConnector(TaleoConnector):
15 EMPLOYER = 'Agnico Eagle Mines' 15 EMPLOYER = 'Agnico Eagle Mines'
16 HOST = 'agnicoeagle' 16 HOST = 'agnicoeagle'
17 SECTION = '2' 17 SECTION = '2'
18+ # la dernière colonne de ce portail est la DATE LIMITE de candidature,
19+ # pas la date d'affichage (constat audit 2026-08-19 : dates futures)
20+ DATE_COL = 'deadline'
modified jobka/connectors/ashby.py +16 −2
@@ -67,14 +67,25 @@ class AshbyConnector(BaseConnector):
67 or comp.get("compensationTierSummary") or "") 67 or comp.get("compensationTierSummary") or "")
68 # salaires structurés (summaryComponents) : plus fiables que le libellé 68 # salaires structurés (summaryComponents) : plus fiables que le libellé
69 s_min = s_max = s_unit = None 69 s_min = s_max = s_unit = None
70+ extra_comp: list[dict] = []
70 for c in comp.get("summaryComponents") or []: 71 for c in comp.get("summaryComponents") or []:
71− if (c.get("compensationType") or "") == "Salary": 72+ ctype = c.get("compensationType") or ""
73+ if ctype == "Salary" and s_min is None:
72 s_min, s_max = c.get("minValue"), c.get("maxValue") 74 s_min, s_max = c.get("minValue"), c.get("maxValue")
73 interval = (c.get("interval") or "").upper() 75 interval = (c.get("interval") or "").upper()
74 s_unit = ("hour" if "HOUR" in interval 76 s_unit = ("hour" if "HOUR" in interval
75 else "month" if "MONTH" in interval 77 else "month" if "MONTH" in interval
76 else "year" if "YEAR" in interval else None) 78 else "year" if "YEAR" in interval else None)
77− break 79+ elif ctype and ctype != "Salary":
80+ # composantes Bonus / Equity / Commission (montants si publiés)
81+ entry = {"type": ctype}
82+ if c.get("minValue") is not None:
83+ entry["min"] = c["minValue"]
84+ if c.get("maxValue") is not None:
85+ entry["max"] = c["maxValue"]
86+ if c.get("interval") and (c.get("interval") or "").upper() != "NONE":
87+ entry["interval"] = c["interval"]
88+ extra_comp.append(entry)
78 workplace = (item.get("workplaceType") or "").lower() 89 workplace = (item.get("workplaceType") or "").lower()
79 work_mode = ("hybride" if workplace == "hybrid" 90 work_mode = ("hybride" if workplace == "hybrid"
80 else "teletravail" if item.get("isRemote") 91 else "teletravail" if item.get("isRemote")
@@ -93,9 +104,12 @@ class AshbyConnector(BaseConnector):
93 salary_min=s_min, salary_max=s_max, 104 salary_min=s_min, salary_max=s_max,
94 salary_unit=s_unit if s_min is not None else None, 105 salary_unit=s_unit if s_min is not None else None,
95 salary_label=salary_label, 106 salary_label=salary_label,
107+ apply_url=item.get("applyUrl") or "",
96 ats=self.ats, 108 ats=self.ats,
97 ) 109 )
98 if item.get("team"): 110 if item.get("team"):
99 job.details["team"] = item["team"] 111 job.details["team"] = item["team"]
112+ if extra_comp:
113+ job.details["compensation_components"] = extra_comp
100 out.append(job) 114 out.append(job)
101 return out 115 return out
modified jobka/connectors/bamboohr.py +7 −1
@@ -74,13 +74,19 @@ class BambooHRConnector(BaseConnector):
74 eid = str(item.get("id") or "") 74 eid = str(item.get("id") or "")
75 if not eid: 75 if not eid:
76 continue 76 continue
77+ # locationType (sur chaque ligne) : « 0 » présentiel, « 1 »
78+ # hybride, « 2 » télétravail — plus fiable que le seul isRemote
79+ work_mode = {"0": "presentiel", "1": "hybride",
80+ "2": "teletravail"}.get(str(item.get("locationType") or ""))
81+ if work_mode is None and item.get("isRemote"):
82+ work_mode = "teletravail"
77 job = JobPosting( 83 job = JobPosting(
78 source=self.source_id, external_id=eid, 84 source=self.source_id, external_id=eid,
79 url=f"https://{self.ORG}.bamboohr.com/careers/{eid}", 85 url=f"https://{self.ORG}.bamboohr.com/careers/{eid}",
80 employer=self.EMPLOYER, 86 employer=self.EMPLOYER,
81 title=item.get("jobOpeningName") or "", 87 title=item.get("jobOpeningName") or "",
82 location_label=self._loc_label(item), 88 location_label=self._loc_label(item),
83− work_mode="teletravail" if item.get("isRemote") else None, 89+ work_mode=work_mode,
84 ats=self.ats, 90 ats=self.ats,
85 ) 91 )
86 job.details["employment_label"] = item.get("employmentStatusLabel") or "" 92 job.details["employment_label"] = item.get("employmentStatusLabel") or ""
modified jobka/connectors/base.py +19 −0
@@ -141,10 +141,29 @@ class BaseConnector:
141 if cached is not None: 141 if cached is not None:
142 return cached 142 return cached
143 payload = fetch_fn() or {} 143 payload = fetch_fn() or {}
144+ # détail « vide » (aucune description alors que le connecteur en
145+ # attendait une) : NE PAS le mettre en cache — il sera re-tenté au
146+ # prochain cycle au lieu de figer une fiche sans contenu
147+ desc_keys = [k for k in ("description", "description_html")
148+ if k in payload]
149+ if desc_keys and not any((payload.get(k) or "").strip()
150+ for k in desc_keys):
151+ return payload
144 db.put_cached_detail(self._detail_con, self.source_id, 152 db.put_cached_detail(self._detail_con, self.source_id,
145 str(external_id), key, payload) 153 str(external_id), key, payload)
146 return payload 154 return payload
147 155
156+ def stale_detail(self, external_id: str) -> dict:
157+ """Payload détail en cache même si la clé a changé — secours quand le
158+ budget de re-visites du cycle est épuisé (voir db.get_stale_detail)."""
159+ if not self.use_detail_cache:
160+ return {}
161+ from .. import db
162+ if self._detail_con is None:
163+ self._detail_con = db.connect()
164+ return db.get_stale_detail(self._detail_con, self.source_id,
165+ str(external_id)) or {}
166+
148 # -- contrat -------------------------------------------------------------- 167 # -- contrat --------------------------------------------------------------
149 def fetch(self) -> list[JobPosting]: 168 def fetch(self) -> list[JobPosting]:
150 raise NotImplementedError 169 raise NotImplementedError
modified jobka/connectors/breezy.py +10 −0
@@ -62,17 +62,27 @@ class BreezyConnector(BaseConnector):
62 salary_label = sal 62 salary_label = sal
63 elif isinstance(sal, dict): 63 elif isinstance(sal, dict):
64 salary_label = sal.get("text") or "" 64 salary_label = sal.get("text") or ""
65+ # adresse complète géocodable (composantes Google) si publiée
66+ street = loc.get("streetAddress")
67+ address = ""
68+ if isinstance(street, dict):
69+ address = street.get("location") or ""
70+ elif isinstance(street, str):
71+ address = street
65 job = JobPosting( 72 job = JobPosting(
66 source=self.source_id, external_id=str(item.get("id") or ""), 73 source=self.source_id, external_id=str(item.get("id") or ""),
67 url=item.get("url") or "", 74 url=item.get("url") or "",
68 employer=self.EMPLOYER or (item.get("company") or {}).get("name", ""), 75 employer=self.EMPLOYER or (item.get("company") or {}).get("name", ""),
69 title=item.get("name") or "", 76 title=item.get("name") or "",
70 description=clean_html(item.get("description") or ""), 77 description=clean_html(item.get("description") or ""),
78+ address=address,
71 city=loc.get("city") or "", 79 city=loc.get("city") or "",
72 location_label=f"{loc.get('city') or ''}, " 80 location_label=f"{loc.get('city') or ''}, "
73 f"{(loc.get('state') or {}).get('name') or ''}".strip(", "), 81 f"{(loc.get('state') or {}).get('name') or ''}".strip(", "),
82+ work_mode="teletravail" if loc.get("is_remote") else None,
74 date_posted=item.get("published_date"), 83 date_posted=item.get("published_date"),
75 salary_label=salary_label, 84 salary_label=salary_label,
85+ company_logo=(item.get("company") or {}).get("logo_url") or "",
76 ats=self.ats, 86 ats=self.ats,
77 ) 87 )
78 job.details["employment_label"] = typ.get("name") or typ.get("id") or "" 88 job.details["employment_label"] = typ.get("name") or typ.get("id") or ""
modified jobka/connectors/greenhouse.py +3 −0
@@ -58,10 +58,13 @@ class GreenhouseConnector(BaseConnector):
58 location_label=(item.get("location") or {}).get("name") or "", 58 location_label=(item.get("location") or {}).get("name") or "",
59 date_posted=item.get("first_published") or item.get("updated_at"), 59 date_posted=item.get("first_published") or item.get("updated_at"),
60 date_deadline=deadline, 60 date_deadline=deadline,
61+ language=(item.get("language") or "")[:2], # « fr » / « en »
61 ats=self.ats, 62 ats=self.ats,
62 ) 63 )
63 deps = [d.get("name") for d in item.get("departments") or [] if d.get("name")] 64 deps = [d.get("name") for d in item.get("departments") or [] if d.get("name")]
64 if deps: 65 if deps:
65 job.details["team"] = deps[0] 66 job.details["team"] = deps[0]
67+ if item.get("requisition_id"):
68+ job.details["requisition"] = str(item["requisition_id"])
66 out.append(job) 69 out.append(job)
67 return out 70 return out
modified jobka/connectors/guichet_emplois.py +27 −4
@@ -71,6 +71,21 @@ class GuichetEmploisConnector(BaseConnector):
71 m = re.search(r'property="validThrough"[^>]*content="([^"]+)"', html) 71 m = re.search(r'property="validThrough"[^>]*content="([^"]+)"', html)
72 if m: 72 if m:
73 d["date_deadline"] = m.group(1) 73 d["date_deadline"] = m.group(1)
74+ # avantages sociaux : bloc structuré property="jobBenefits" (RDFa)
75+ m = re.search(r'property="jobBenefits"[^>]*>(.*?)</div>', html, re.S)
76+ if m:
77+ items = [_txt(li) for li in re.findall(r"<li[^>]*>(.*?)</li>",
78+ m.group(1), re.S)]
79+ items = [i for i in items if i and len(i) <= 120]
80+ if items:
81+ d["benefits"] = items[:12]
82+ # langue de travail exigée : <h4>Langues</h4><p property="qualification">
83+ m = re.search(r"<h4>\s*Langues?\s*</h4>\s*<p[^>]*>([^<]+)</p>", html)
84+ if m:
85+ lang = _txt(m.group(1)).lower()
86+ d["work_language"] = ("bilingue" if "biling" in lang
87+ else "fr" if "fran" in lang
88+ else "en" if "angl" in lang else "")
74 return d 89 return d
75 90
76 def fetch(self) -> list[JobPosting]: 91 def fetch(self) -> list[JobPosting]:
@@ -123,7 +138,8 @@ class GuichetEmploisConnector(BaseConnector):
123 ) 138 )
124 if not job.title or not job.employer: 139 if not job.title or not job.employer:
125 continue 140 continue
126− key = f"{fields['date']}|{job.title[:40]}" 141+ # clé « v2| » : re-visite progressive (avantages + langue)
142+ key = f"v2|{fields['date']}|{job.title[:40]}"
127 if details_used < MAX_DETAILS: 143 if details_used < MAX_DETAILS:
128 fresh = [False] 144 fresh = [False]
129 145
@@ -134,10 +150,17 @@ class GuichetEmploisConnector(BaseConnector):
134 try: 150 try:
135 d = self.detail(jid, key, _fn) 151 d = self.detail(jid, key, _fn)
136 except (requests.ConnectionError, requests.Timeout): 152 except (requests.ConnectionError, requests.Timeout):
137− d = {} # détail repris à la prochaine synchro 153+ d = self.stale_detail(jid) # repris au prochain cycle
138 if fresh[0]: 154 if fresh[0]:
139 details_used += 1 155 details_used += 1
140− job.description = d.get("description", "") 156+ else:
141− job.date_deadline = d.get("date_deadline") 157+ # budget épuisé : détail périmé plutôt que fiche vidée
158+ d = self.stale_detail(jid)
159+ job.description = d.get("description", "")
160+ job.date_deadline = d.get("date_deadline")
161+ if d.get("benefits"):
162+ job.benefits = d["benefits"]
163+ if d.get("work_language"):
164+ job.language = d["work_language"]
142 out.append(job) 165 out.append(job)
143 return out 166 return out
modified jobka/connectors/icims.py +7 −10
@@ -73,16 +73,11 @@ class ICIMSConnector(BaseConnector):
73 details_used = 0 73 details_used = 0
74 for jid, slug in seen.items(): 74 for jid, slug in seen.items():
75 if details_used >= MAX_DETAILS: 75 if details_used >= MAX_DETAILS:
76− # budget épuisé : ne servir que le cache (sans jamais y écrire 76+ # budget épuisé : ne servir que le cache (même périmé) —
77− # un vide) — l'offre sera reprise à la prochaine synchronisation 77+ # l'offre sera reprise à la prochaine synchronisation
78− from .. import db 78+ fields = self.stale_detail(jid)
79− if self._detail_con is None: 79+ if not fields:
80− self._detail_con = db.connect()
81− cached = db.get_cached_detail(self._detail_con, self.source_id,
82− jid, jid)
83− if cached is None:
84 continue 80 continue
85− fields = cached
86 else: 81 else:
87 fresh = [False] 82 fresh = [False]
88 83
@@ -90,7 +85,9 @@ class ICIMSConnector(BaseConnector):
90 fresh[0] = True 85 fresh[0] = True
91 return self._fetch_detail(j, s) 86 return self._fetch_detail(j, s)
92 87
93− fields = self.detail(jid, jid, _fn) 88+ # clé « v2| » : re-visite progressive (logo employeur,
89+ # industrie, scolarité, compétences du JSON-LD — 2026-08-19)
90+ fields = self.detail(jid, f"v2|{jid}", _fn)
94 if fresh[0]: 91 if fresh[0]:
95 details_used += 1 92 details_used += 1
96 if not fields: 93 if not fields:
modified jobka/connectors/jobillico.py +6 −6
@@ -68,6 +68,9 @@ class JobillicoConnector(BaseConnector):
68 continue 68 continue
69 seen.add(eid) 69 seen.add(eid)
70 fields: dict = {} 70 fields: dict = {}
71+ # clé « v2| » : re-visite progressive pour capter logo employeur,
72+ # industrie, scolarité et compétences du JSON-LD (2026-08-19)
73+ key = f"v2|{lastmod or eid}"
71 if details_used < MAX_DETAILS: 74 if details_used < MAX_DETAILS:
72 fresh = [False] 75 fresh = [False]
73 76
@@ -75,15 +78,12 @@ class JobillicoConnector(BaseConnector):
75 fresh[0] = True 78 fresh[0] = True
76 return self._fetch_detail(u) 79 return self._fetch_detail(u)
77 80
78− fields = self.detail(eid, lastmod or eid, _fn) 81+ fields = self.detail(eid, key, _fn)
79 if fresh[0]: 82 if fresh[0]:
80 details_used += 1 83 details_used += 1
81 else: 84 else:
82− from .. import db 85+ # budget épuisé : cache même périmé (mieux qu'une offre ratée)
83− if self._detail_con is None: 86+ fields = self.stale_detail(eid)
84− self._detail_con = db.connect()
85− fields = db.get_cached_detail(self._detail_con, self.source_id,
86− eid, lastmod or eid) or {}
87 if not fields or not fields.get("title"): 87 if not fields or not fields.get("title"):
88 continue 88 continue
89 region = (fields.get("region_code") or "").upper() 89 region = (fields.get("region_code") or "").upper()
modified jobka/connectors/lever.py +12 −0
@@ -58,6 +58,10 @@ class LeverConnector(BaseConnector):
58 sal = item.get("salaryRange") or {} 58 sal = item.get("salaryRange") or {}
59 desc = (item.get("descriptionBodyPlain") 59 desc = (item.get("descriptionBodyPlain")
60 or item.get("descriptionPlain") or "") 60 or item.get("descriptionPlain") or "")
61+ # bloc d'introduction (openingPlain) : AVANT le corps
62+ opening = item.get("openingPlain") or ""
63+ if opening and opening.strip() not in desc:
64+ desc = f"{opening.strip()}\n\n{desc}"
61 # les puces (exigences, avantages) sont dans lists[] 65 # les puces (exigences, avantages) sont dans lists[]
62 for lst in item.get("lists") or []: 66 for lst in item.get("lists") or []:
63 titre = lst.get("text") or "" 67 titre = lst.get("text") or ""
@@ -65,6 +69,10 @@ class LeverConnector(BaseConnector):
65 if contenu: 69 if contenu:
66 from ..normalize import clean_html 70 from ..normalize import clean_html
67 desc += f"\n\n{titre}\n{clean_html(contenu)}" 71 desc += f"\n\n{titre}\n{clean_html(contenu)}"
72+ # bloc de conclusion (additionalPlain) : APRÈS les puces
73+ additional = item.get("additionalPlain") or ""
74+ if additional and additional.strip() not in desc:
75+ desc += f"\n\n{additional.strip()}"
68 job = JobPosting( 76 job = JobPosting(
69 source=self.source_id, external_id=str(item.get("id") or ""), 77 source=self.source_id, external_id=str(item.get("id") or ""),
70 url=item.get("hostedUrl") or "", 78 url=item.get("hostedUrl") or "",
@@ -76,10 +84,14 @@ class LeverConnector(BaseConnector):
76 date_posted=item.get("createdAt"), 84 date_posted=item.get("createdAt"),
77 salary_min=sal.get("min"), salary_max=sal.get("max"), 85 salary_min=sal.get("min"), salary_max=sal.get("max"),
78 salary_unit=_INTERVAL.get((sal.get("interval") or "").lower()), 86 salary_unit=_INTERVAL.get((sal.get("interval") or "").lower()),
87+ apply_url=item.get("applyUrl") or "",
79 ats=self.ats, 88 ats=self.ats,
80 ) 89 )
81 job.details["employment_label"] = cats.get("commitment") or "" 90 job.details["employment_label"] = cats.get("commitment") or ""
82 if cats.get("team"): 91 if cats.get("team"):
83 job.details["team"] = cats["team"] 92 job.details["team"] = cats["team"]
93+ all_locs = [l for l in (cats.get("allLocations") or []) if l]
94+ if len(all_locs) > 1:
95+ job.details["all_locations"] = all_locs
84 out.append(job) 96 out.append(job)
85 return out 97 return out
modified jobka/connectors/njoyn.py +1 −0
@@ -164,6 +164,7 @@ class NjoynConnector(BaseConnector):
164 if f.get("niveau de scolarité"): 164 if f.get("niveau de scolarité"):
165 job.requirements["scolarite"] = f["niveau de scolarité"] 165 job.requirements["scolarite"] = f["niveau de scolarité"]
166 if it["apply_url"]: 166 if it["apply_url"]:
167+ job.apply_url = it["apply_url"]
167 job.details["apply_url"] = it["apply_url"] 168 job.details["apply_url"] = it["apply_url"]
168 key = hashlib.sha1( 169 key = hashlib.sha1(
169 f"{it['title']}|{it['date']}".encode()).hexdigest()[:12] 170 f"{it['title']}|{it['date']}".encode()).hexdigest()[:12]
modified jobka/connectors/recruitee.py +39 −3
@@ -63,8 +63,28 @@ class RecruiteeConnector(BaseConnector):
63 continue 63 continue
64 if not self._keep(item): 64 if not self._keep(item):
65 continue 65 continue
66− desc = clean_html(item.get("description") or "") 66+ # traductions : quand une version FRANÇAISE existe, c'est elle
67− reqs = clean_html(item.get("requirements") or "") 67+ # qu'on affiche (public québécois) — langue fr/bilingue déduite
68+ 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 None
74+ 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 title
79+ desc_html = fr.get("description") or desc_html
80+ reqs_html = fr.get("requirements") or reqs_html
81+ 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)
68 if reqs: 88 if reqs:
69 desc = f"{desc}\n\nExigences\n{reqs}" if desc else reqs 89 desc = f"{desc}\n\nExigences\n{reqs}" if desc else reqs
70 sal = item.get("salary") or {} 90 sal = item.get("salary") or {}
@@ -82,7 +102,7 @@ class RecruiteeConnector(BaseConnector):
82 source=self.source_id, external_id=str(item.get("id") or ""), 102 source=self.source_id, external_id=str(item.get("id") or ""),
83 url=item.get("careers_url") or "", 103 url=item.get("careers_url") or "",
84 employer=self.EMPLOYER or item.get("company_name") or "", 104 employer=self.EMPLOYER or item.get("company_name") or "",
85− title=item.get("title") or "", 105+ title=title,
86 description=desc, 106 description=desc,
87 city=item.get("city") or "", 107 city=item.get("city") or "",
88 postal_code=item.get("postal_code") or "", 108 postal_code=item.get("postal_code") or "",
@@ -95,9 +115,25 @@ class RecruiteeConnector(BaseConnector):
95 salary_unit=_PERIOD.get((sal.get("period") or "").lower()), 115 salary_unit=_PERIOD.get((sal.get("period") or "").lower()),
96 date_posted=item.get("published_at") or item.get("created_at"), 116 date_posted=item.get("published_at") or item.get("created_at"),
97 date_deadline=item.get("close_at"), 117 date_deadline=item.get("close_at"),
118+ language=language,
119+ apply_url=item.get("careers_apply_url") or "",
98 ats=self.ats, 120 ats=self.ats,
99 ) 121 )
100 if item.get("department"): 122 if item.get("department"):
101 job.details["team"] = item["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}")
102 out.append(job) 138 out.append(job)
103 return out 139 return out
modified jobka/connectors/smartrecruiters.py +20 −1
@@ -105,6 +105,22 @@ class SmartRecruitersConnector(BaseConnector):
105 ) 105 )
106 job.details["employment_label"] = \ 106 job.details["employment_label"] = \
107 (item.get("typeOfEmployment") or {}).get("label") or "" 107 (item.get("typeOfEmployment") or {}).get("label") or ""
108+ # champs structurés présents sur CHAQUE ligne de liste
109+ # (zéro requête supplémentaire) — enrichissement 2026-08-19
110+ 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"] = exp
114+ 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] = label
119+ 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"])
108 key = str(item.get("releasedDate") or "")[:19] 124 key = str(item.get("releasedDate") or "")[:19]
109 if details_used < MAX_DETAILS: 125 if details_used < MAX_DETAILS:
110 fresh = [False] 126 fresh = [False]
@@ -116,7 +132,10 @@ class SmartRecruitersConnector(BaseConnector):
116 d = self.detail(eid, key, _fn) 132 d = self.detail(eid, key, _fn)
117 if fresh[0]: 133 if fresh[0]:
118 details_used += 1 134 details_used += 1
119− job.description = clean_html(d.get("description_html", "")) 135+ else:
136+ # budget épuisé : détail périmé plutôt que fiche vidée
137+ d = self.stale_detail(eid)
138+ job.description = clean_html(d.get("description_html", ""))
120 out.append(job) 139 out.append(job)
121 offset += PAGE_SIZE 140 offset += PAGE_SIZE
122 if offset >= int(data.get("totalFound") or 0): 141 if offset >= int(data.get("totalFound") or 0):
modified jobka/connectors/successfactors.py +15 −2
@@ -120,7 +120,16 @@ class SuccessFactorsConnector(BaseConnector):
120 slug = urllib.parse.unquote(slug_raw) 120 slug = urllib.parse.unquote(slug_raw)
121 if self.quebec_only and not _QC_SLUG_RE.search(slug + "/"): 121 if self.quebec_only and not _QC_SLUG_RE.search(slug + "/"):
122 continue 122 continue
123− city = slug.split("-")[0] 123+ # ⚠ Villes à trait d'union (« St-Laurent », « Pointe-Claire ») :
124+ # le slug utilise « - » à la fois DANS la ville et comme
125+ # séparateur ville/titre. On découpe avant le décodage (certains
126+ # tenants encodent %2D) puis on reconnaît le plus long préfixe
127+ # correspondant à une municipalité connue (répertoire MAMH).
128+ # Bogue « city='St' » corrigé 2026-08-19.
129+ parts = [urllib.parse.unquote(p) for p in slug_raw.split("-")]
130+ from ..regions import city_from_slug_tokens
131+ city = city_from_slug_tokens(parts) or parts[0]
132+ n_city = max(1, len(city.split("-")))
124 postal = "" 133 postal = ""
125 m_cp = re.search(r"([GHJ]\d[A-Z])[- ]?(\d[A-Z]\d)", slug, re.I) 134 m_cp = re.search(r"([GHJ]\d[A-Z])[- ]?(\d[A-Z]\d)", slug, re.I)
126 if m_cp: 135 if m_cp:
@@ -128,7 +137,7 @@ class SuccessFactorsConnector(BaseConnector):
128 job = JobPosting( 137 job = JobPosting(
129 source=self.source_id, external_id=eid, url=loc, 138 source=self.source_id, external_id=eid, url=loc,
130 employer=self.EMPLOYER, 139 employer=self.EMPLOYER,
131− title=" ".join(slug.split("-")[1:-3]) or slug, 140+ title=" ".join(parts[n_city:-3]) or slug,
132 city=city, postal_code=postal, 141 city=city, postal_code=postal,
133 date_posted=lastmod or None, 142 date_posted=lastmod or None,
134 ats=self.ats, 143 ats=self.ats,
@@ -143,6 +152,10 @@ class SuccessFactorsConnector(BaseConnector):
143 d = self.detail(eid, lastmod or eid, _fn) 152 d = self.detail(eid, lastmod or eid, _fn)
144 if fresh[0]: 153 if fresh[0]:
145 details_used += 1 154 details_used += 1
155+ else:
156+ # budget épuisé : détail périmé plutôt que fiche vidée
157+ d = self.stale_detail(eid)
158+ if d:
146 if d.get("description"): 159 if d.get("description"):
147 job.description = d["description"] 160 job.description = d["description"]
148 if d.get("datePosted"): 161 if d.get("datePosted"):
modified jobka/connectors/taleo.py +20 −1
@@ -31,6 +31,7 @@ import json
31 import os 31 import os
32 import re 32 import re
33 33
34+from ..normalize import parse_date
34 from ..schema import JobPosting, clean_html, is_quebec_location 35 from ..schema import JobPosting, clean_html, is_quebec_location
35 from .base import BaseConnector 36 from .base import BaseConnector
36 37
@@ -54,6 +55,12 @@ class TaleoConnector(BaseConnector):
54 LANG = "fr" 55 LANG = "fr"
55 quebec_only = True 56 quebec_only = True
56 max_pages = 40 57 max_pages = 40
58+ # La dernière colonne du tableau searchjobs dépend de la configuration du
59+ # portail : date d'affichage (« posted ») pour la plupart, mais DATE
60+ # LIMITE (« deadline ») chez certains (agnico_eagle). Une date future est
61+ # de toute façon toujours traitée comme date limite (une date de
62+ # publication ne peut pas être dans le futur).
63+ DATE_COL = "posted" # posted | deadline
57 64
58 @property 65 @property
59 def _base(self) -> str: 66 def _base(self) -> str:
@@ -151,6 +158,18 @@ class TaleoConnector(BaseConnector):
151 seen.add(contest) 158 seen.add(contest)
152 if not self._keep(locations): 159 if not self._keep(locations):
153 continue 160 continue
161+ # colonne date : publication ou date limite selon le portail ;
162+ # une date FUTURE est toujours une date limite (jamais une
163+ # date de publication) — correctif du bogue agnico_eagle
164+ date_posted = date_deadline = None
165+ iso = parse_date(date_raw) if date_raw else None
166+ if iso:
167+ import datetime as _dt
168+ if (self.DATE_COL == "deadline"
169+ or iso > _dt.date.today().isoformat()):
170+ date_deadline = iso
171+ else:
172+ date_posted = iso
154 job = JobPosting( 173 job = JobPosting(
155 source=self.source_id, external_id=contest, 174 source=self.source_id, external_id=contest,
156 url=(f"{self._base}/{self.SECTION}/jobdetail.ftl" 175 url=(f"{self._base}/{self.SECTION}/jobdetail.ftl"
@@ -159,7 +178,7 @@ class TaleoConnector(BaseConnector):
159 title=title, 178 title=title,
160 location_label=locations[0].replace("-", ", ") 179 location_label=locations[0].replace("-", ", ")
161 if locations else "", 180 if locations else "",
162− date_posted=date_raw or None, 181+ date_posted=date_posted, date_deadline=date_deadline,
163 ats=self.ats, 182 ats=self.ats,
164 ) 183 )
165 key = hashlib.sha1( 184 key = hashlib.sha1(
modified jobka/connectors/workable.py +5 −0
@@ -66,11 +66,16 @@ class WorkableConnector(BaseConnector):
66 location_label=locs[0] if locs else "", 66 location_label=locs[0] if locs else "",
67 work_mode="teletravail" if item.get("telecommuting") else None, 67 work_mode="teletravail" if item.get("telecommuting") else None,
68 date_posted=item.get("published_on") or item.get("created_at"), 68 date_posted=item.get("published_on") or item.get("created_at"),
69+ apply_url=item.get("application_url") or "",
69 ats=self.ats, 70 ats=self.ats,
70 ) 71 )
71 job.details["employment_label"] = item.get("employment_type") or "" 72 job.details["employment_label"] = item.get("employment_type") or ""
72 if item.get("department"): 73 if item.get("department"):
73 job.details["team"] = item["department"] 74 job.details["team"] = item["department"]
75+ if item.get("function"):
76+ job.details["function"] = item["function"]
77+ if item.get("industry"):
78+ job.details["industry"] = item["industry"]
74 if item.get("experience"): 79 if item.get("experience"):
75 job.requirements["experience"] = item["experience"] 80 job.requirements["experience"] = item["experience"]
76 if item.get("education"): 81 if item.get("education"):
modified jobka/connectors/workday.py +70 −5
@@ -84,9 +84,39 @@ class WorkdayConnector(BaseConnector):
84 return found 84 return found
85 return None 85 return None
86 86
87− def _list_pages(self): 87+ # facette « Remote Type » (présente sur beaucoup de tenants) : libellé
88+ # de valeur -> work_mode standard
89+ _REMOTE_FACET_VALUES = {
90+ "remote": "teletravail", "fully remote": "teletravail",
91+ "teletravail": "teletravail", "télétravail": "teletravail",
92+ "hybrid": "hybride", "hybride": "hybride",
93+ "on-site": "presentiel", "onsite": "presentiel",
94+ "on site": "presentiel", "in office": "presentiel",
95+ "field-based": "presentiel", "presentiel": "presentiel",
96+ "présentiel": "presentiel",
97+ }
98+
99+ @classmethod
100+ def _find_remote_facet(cls, facets: list) -> list[tuple[str, dict]] | None:
101+ """Repère une facette de type « Remote Type » et retourne
102+ [(work_mode, {facetParameter: [id]}), …] pour requêtes filtrées."""
103+ for f in facets or []:
104+ param = f.get("facetParameter") or ""
105+ if "remote" not in param.lower():
106+ continue
107+ out = []
108+ for v in f.get("values") or []:
109+ mode = cls._REMOTE_FACET_VALUES.get(
110+ (v.get("descriptor") or "").strip().lower())
111+ if mode and v.get("id"):
112+ out.append((mode, {param: [v["id"]]}))
113+ if out:
114+ return out
115+ return None
116+
117+ def _list_pages(self, extra_facets: dict | None = None):
88 url = f"{self._base}/wday/cxs/{self.TENANT}/{self.SITE}/jobs" 118 url = f"{self._base}/wday/cxs/{self.TENANT}/{self.SITE}/jobs"
89− applied: dict = {} 119+ applied: dict = dict(extra_facets or {})
90 offset = 0 120 offset = 0
91 total = 0 121 total = 0
92 first = True 122 first = True
@@ -96,12 +126,14 @@ class WorkdayConnector(BaseConnector):
96 "offset": offset, "searchText": ""}).json() 126 "offset": offset, "searchText": ""}).json()
97 if first: 127 if first:
98 first = False 128 first = False
129+ self._first_facets = data.get("facets") or []
99 if self.quebec_only: 130 if self.quebec_only:
100 qc = self._find_qc_facet(data.get("facets")) 131 qc = self._find_qc_facet(data.get("facets"))
101 if qc: 132 if qc:
102 # filtre serveur trouvé : rejouer la pagination avec, et 133 # filtre serveur trouvé : rejouer la pagination avec, et
103 # neutraliser le filtre client (libellés de villes nues) 134 # neutraliser le filtre client (libellés de villes nues)
104− applied = qc 135+ applied = {**qc, **(extra_facets or {})}
136+ self._qc_facet = qc
105 self._server_filtered = True 137 self._server_filtered = True
106 continue 138 continue
107 items = data.get("jobPostings") or [] 139 items = data.get("jobPostings") or []
@@ -118,6 +150,24 @@ class WorkdayConnector(BaseConnector):
118 if len(items) < PAGE_SIZE or (total and offset >= total): 150 if len(items) < PAGE_SIZE or (total and offset >= total):
119 return 151 return
120 152
153+ def _tag_remote_modes(self, by_path: dict) -> None:
154+ """Enrichit work_mode via la facette « Remote Type » quand le tenant
155+ l'expose : une pagination filtrée (QC + valeur de facette) par mode,
156+ les offres retrouvées héritent du mode. Tolérant aux échecs réseau —
157+ l'enrichissement est optionnel, jamais bloquant."""
158+ modes = self._find_remote_facet(getattr(self, "_first_facets", []))
159+ if not modes or not by_path:
160+ return
161+ qc = getattr(self, "_qc_facet", None) or {}
162+ for mode, facet in modes:
163+ try:
164+ for item in self._list_pages({**facet, **qc}):
165+ job = by_path.get(item.get("externalPath") or "")
166+ if job is not None and job.work_mode is None:
167+ job.work_mode = mode
168+ except Exception:
169+ continue # facette indisponible : on n'invente rien
170+
121 def _fetch_detail(self, external_path: str) -> dict: 171 def _fetch_detail(self, external_path: str) -> dict:
122 data = self.get(f"{self._base}/wday/cxs/{self.TENANT}/{self.SITE}" 172 data = self.get(f"{self._base}/wday/cxs/{self.TENANT}/{self.SITE}"
123 f"{external_path}").json() 173 f"{external_path}").json()
@@ -128,12 +178,17 @@ class WorkdayConnector(BaseConnector):
128 "time_type": info.get("timeType") or "", 178 "time_type": info.get("timeType") or "",
129 "posted_on": info.get("postedOn") or "", 179 "posted_on": info.get("postedOn") or "",
130 "req_id": info.get("jobReqId") or "", 180 "req_id": info.get("jobReqId") or "",
181+ # lien de candidature canonique (jobPostingInfo.externalUrl)
182+ "apply_url": info.get("externalUrl") or "",
131 } 183 }
132 184
133 def fetch(self) -> list[JobPosting]: 185 def fetch(self) -> list[JobPosting]:
134 out: list[JobPosting] = [] 186 out: list[JobPosting] = []
187+ by_path: dict[str, JobPosting] = {}
135 details_used = 0 188 details_used = 0
136 self._server_filtered = False 189 self._server_filtered = False
190+ self._first_facets: list = []
191+ self._qc_facet = None
137 for item in self._list_pages(): 192 for item in self._list_pages():
138 if not self._server_filtered and not self._keep(item): 193 if not self._server_filtered and not self._keep(item):
139 continue 194 continue
@@ -151,9 +206,10 @@ class WorkdayConnector(BaseConnector):
151 date_posted=item.get("postedOn") or None, 206 date_posted=item.get("postedOn") or None,
152 ats=self.ats, 207 ats=self.ats,
153 ) 208 )
154− # page détail (description, type d'emploi) avec cache + budget 209+ # page détail (description, type d'emploi) avec cache + budget —
210+ # clé « v2| » : re-visite progressive pour capter apply_url
155 key = hashlib.sha1( 211 key = hashlib.sha1(
156− f"{path}|{item.get('postedOn','')}".encode()).hexdigest()[:12] 212+ f"v2|{path}|{item.get('postedOn','')}".encode()).hexdigest()[:12]
157 if details_used < MAX_DETAILS: 213 if details_used < MAX_DETAILS:
158 fresh = [False] 214 fresh = [False]
159 215
@@ -164,11 +220,20 @@ class WorkdayConnector(BaseConnector):
164 d = self.detail(str(eid), key, _fn) 220 d = self.detail(str(eid), key, _fn)
165 if fresh[0]: 221 if fresh[0]:
166 details_used += 1 222 details_used += 1
223+ else:
224+ # budget épuisé : détail périmé plutôt que fiche vidée
225+ d = self.stale_detail(str(eid))
226+ if d:
167 job.description = clean_html(d.get("description_html", "")) 227 job.description = clean_html(d.get("description_html", ""))
168 job.details["employment_label"] = d.get("time_type", "") 228 job.details["employment_label"] = d.get("time_type", "")
229+ job.apply_url = d.get("apply_url", "") or ""
169 # « 2 Locations » : le vrai lieu principal est sur la page détail 230 # « 2 Locations » : le vrai lieu principal est sur la page détail
170 if d.get("location") and (not job.location_label or re.match( 231 if d.get("location") and (not job.location_label or re.match(
171 r"^\d+\s+locations?$", job.location_label, re.I)): 232 r"^\d+\s+locations?$", job.location_label, re.I)):
172 job.location_label = d["location"] 233 job.location_label = d["location"]
173 out.append(job) 234 out.append(job)
235+ by_path[path] = job
236+ # work_mode via la facette « Remote Type » (si le tenant l'expose)
237+ if self.quebec_only:
238+ self._tag_remote_modes(by_path)
174 return out 239 return out
modified tests/fixtures/mistplay/expected.json +3 −3
@@ -13,7 +13,7 @@
13 "salary_unit": null, 13 "salary_unit": null,
14 "employment_type": null, 14 "employment_type": null,
15 "ats": "lever", 15 "ats": "lever",
16− "desc_len": 6414 16+ "desc_len": 9262
17 }, 17 },
18 { 18 {
19 "uid": "mistplay:6cf16909-e4a6-439a-8773-21a2827566ba", 19 "uid": "mistplay:6cf16909-e4a6-439a-8773-21a2827566ba",
@@ -27,7 +27,7 @@
27 "salary_unit": "year", 27 "salary_unit": "year",
28 "employment_type": null, 28 "employment_type": null,
29 "ats": "lever", 29 "ats": "lever",
30− "desc_len": 6735 30+ "desc_len": 9583
31 }, 31 },
32 { 32 {
33 "uid": "mistplay:8d459210-edd2-45dd-9e30-b2e804508018", 33 "uid": "mistplay:8d459210-edd2-45dd-9e30-b2e804508018",
@@ -41,7 +41,7 @@
41 "salary_unit": null, 41 "salary_unit": null,
42 "employment_type": null, 42 "employment_type": null,
43 "ats": "lever", 43 "ats": "lever",
44− "desc_len": 7707 44+ "desc_len": 10555
45 } 45 }
46 ] 46 ]
47 } 47 }
\ No newline at end of file
modified tests/fixtures/poka/expected.json +5 −5
@@ -13,7 +13,7 @@
13 "salary_unit": null, 13 "salary_unit": null,
14 "employment_type": null, 14 "employment_type": null,
15 "ats": "greenhouse", 15 "ats": "greenhouse",
16− "desc_len": 7044 16+ "desc_len": 7031
17 }, 17 },
18 { 18 {
19 "uid": "poka:6103724004", 19 "uid": "poka:6103724004",
@@ -27,7 +27,7 @@
27 "salary_unit": null, 27 "salary_unit": null,
28 "employment_type": null, 28 "employment_type": null,
29 "ats": "greenhouse", 29 "ats": "greenhouse",
30− "desc_len": 6992 30+ "desc_len": 6979
31 }, 31 },
32 { 32 {
33 "uid": "poka:6114006004", 33 "uid": "poka:6114006004",
@@ -41,7 +41,7 @@
41 "salary_unit": null, 41 "salary_unit": null,
42 "employment_type": null, 42 "employment_type": null,
43 "ats": "greenhouse", 43 "ats": "greenhouse",
44− "desc_len": 4419 44+ "desc_len": 4406
45 }, 45 },
46 { 46 {
47 "uid": "poka:6115034004", 47 "uid": "poka:6115034004",
@@ -55,7 +55,7 @@
55 "salary_unit": null, 55 "salary_unit": null,
56 "employment_type": null, 56 "employment_type": null,
57 "ats": "greenhouse", 57 "ats": "greenhouse",
58− "desc_len": 5664 58+ "desc_len": 5651
59 }, 59 },
60 { 60 {
61 "uid": "poka:6134510004", 61 "uid": "poka:6134510004",
@@ -69,7 +69,7 @@
69 "salary_unit": null, 69 "salary_unit": null,
70 "employment_type": null, 70 "employment_type": null,
71 "ats": "greenhouse", 71 "ats": "greenhouse",
72− "desc_len": 4152 72+ "desc_len": 4129
73 } 73 }
74 ] 74 ]
75 } 75 }
\ No newline at end of file
76 76
Menu

Theme