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>
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 | 79 | org = _first(node.get("hiringOrganization")) |
| 80 | 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 | 87 | elif isinstance(org, str): |
| 83 | 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 | 109 | loc = _first(node.get("jobLocation")) |
| 86 | 110 | if isinstance(loc, dict): |
| 87 | 111 | addr = loc.get("address") or {} |
@@ -155,6 +179,14 @@ def apply_fields(job, fields: dict, *, override_employer: bool = False) -> None: | ||
| 155 | 179 | job.salary_unit = fields.get("salary_unit") |
| 156 | 180 | if fields.get("employment_label"): |
| 157 | 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 | 190 | if fields.get("lat") is not None and job.lat is None: |
| 159 | 191 | job.lat = fields["lat"] |
| 160 | 192 | job.lng = fields.get("lng") |
modified
jobka/connectors/agnico_eagle.py
+3 −0
@@ -15,3 +15,6 @@ class AgnicoEagleConnector(TaleoConnector): | ||
| 15 | 15 | EMPLOYER = 'Agnico Eagle Mines' |
| 16 | 16 | HOST = 'agnicoeagle' |
| 17 | 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 | 67 | or comp.get("compensationTierSummary") or "") |
| 68 | 68 | # salaires structurés (summaryComponents) : plus fiables que le libellé |
| 69 | 69 | s_min = s_max = s_unit = None |
| 70 | + extra_comp: list[dict] = [] | |
| 70 | 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 | 74 | s_min, s_max = c.get("minValue"), c.get("maxValue") |
| 73 | 75 | interval = (c.get("interval") or "").upper() |
| 74 | 76 | s_unit = ("hour" if "HOUR" in interval |
| 75 | 77 | else "month" if "MONTH" in interval |
| 76 | 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 | 89 | workplace = (item.get("workplaceType") or "").lower() |
| 79 | 90 | work_mode = ("hybride" if workplace == "hybrid" |
| 80 | 91 | else "teletravail" if item.get("isRemote") |
@@ -93,9 +104,12 @@ class AshbyConnector(BaseConnector): | ||
| 93 | 104 | salary_min=s_min, salary_max=s_max, |
| 94 | 105 | salary_unit=s_unit if s_min is not None else None, |
| 95 | 106 | salary_label=salary_label, |
| 107 | + apply_url=item.get("applyUrl") or "", | |
| 96 | 108 | ats=self.ats, |
| 97 | 109 | ) |
| 98 | 110 | if item.get("team"): |
| 99 | 111 | job.details["team"] = item["team"] |
| 112 | + if extra_comp: | |
| 113 | + job.details["compensation_components"] = extra_comp | |
| 100 | 114 | out.append(job) |
| 101 | 115 | return out |
modified
jobka/connectors/bamboohr.py
+7 −1
@@ -74,13 +74,19 @@ class BambooHRConnector(BaseConnector): | ||
| 74 | 74 | eid = str(item.get("id") or "") |
| 75 | 75 | if not eid: |
| 76 | 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 | 83 | job = JobPosting( |
| 78 | 84 | source=self.source_id, external_id=eid, |
| 79 | 85 | url=f"https://{self.ORG}.bamboohr.com/careers/{eid}", |
| 80 | 86 | employer=self.EMPLOYER, |
| 81 | 87 | title=item.get("jobOpeningName") or "", |
| 82 | 88 | location_label=self._loc_label(item), |
| 83 | − work_mode="teletravail" if item.get("isRemote") else None, | |
| 89 | + work_mode=work_mode, | |
| 84 | 90 | ats=self.ats, |
| 85 | 91 | ) |
| 86 | 92 | job.details["employment_label"] = item.get("employmentStatusLabel") or "" |
modified
jobka/connectors/base.py
+19 −0
@@ -141,10 +141,29 @@ class BaseConnector: | ||
| 141 | 141 | if cached is not None: |
| 142 | 142 | return cached |
| 143 | 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 | 152 | db.put_cached_detail(self._detail_con, self.source_id, |
| 145 | 153 | str(external_id), key, payload) |
| 146 | 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 | 167 | # -- contrat -------------------------------------------------------------- |
| 149 | 168 | def fetch(self) -> list[JobPosting]: |
| 150 | 169 | raise NotImplementedError |
modified
jobka/connectors/breezy.py
+10 −0
@@ -62,17 +62,27 @@ class BreezyConnector(BaseConnector): | ||
| 62 | 62 | salary_label = sal |
| 63 | 63 | elif isinstance(sal, dict): |
| 64 | 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 | 72 | job = JobPosting( |
| 66 | 73 | source=self.source_id, external_id=str(item.get("id") or ""), |
| 67 | 74 | url=item.get("url") or "", |
| 68 | 75 | employer=self.EMPLOYER or (item.get("company") or {}).get("name", ""), |
| 69 | 76 | title=item.get("name") or "", |
| 70 | 77 | description=clean_html(item.get("description") or ""), |
| 78 | + address=address, | |
| 71 | 79 | city=loc.get("city") or "", |
| 72 | 80 | location_label=f"{loc.get('city') or ''}, " |
| 73 | 81 | f"{(loc.get('state') or {}).get('name') or ''}".strip(", "), |
| 82 | + work_mode="teletravail" if loc.get("is_remote") else None, | |
| 74 | 83 | date_posted=item.get("published_date"), |
| 75 | 84 | salary_label=salary_label, |
| 85 | + company_logo=(item.get("company") or {}).get("logo_url") or "", | |
| 76 | 86 | ats=self.ats, |
| 77 | 87 | ) |
| 78 | 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 | 58 | location_label=(item.get("location") or {}).get("name") or "", |
| 59 | 59 | date_posted=item.get("first_published") or item.get("updated_at"), |
| 60 | 60 | date_deadline=deadline, |
| 61 | + language=(item.get("language") or "")[:2], # « fr » / « en » | |
| 61 | 62 | ats=self.ats, |
| 62 | 63 | ) |
| 63 | 64 | deps = [d.get("name") for d in item.get("departments") or [] if d.get("name")] |
| 64 | 65 | if deps: |
| 65 | 66 | job.details["team"] = deps[0] |
| 67 | + if item.get("requisition_id"): | |
| 68 | + job.details["requisition"] = str(item["requisition_id"]) | |
| 66 | 69 | out.append(job) |
| 67 | 70 | return out |
modified
jobka/connectors/guichet_emplois.py
+27 −4
@@ -71,6 +71,21 @@ class GuichetEmploisConnector(BaseConnector): | ||
| 71 | 71 | m = re.search(r'property="validThrough"[^>]*content="([^"]+)"', html) |
| 72 | 72 | if m: |
| 73 | 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 | 89 | return d |
| 75 | 90 | |
| 76 | 91 | def fetch(self) -> list[JobPosting]: |
@@ -123,7 +138,8 @@ class GuichetEmploisConnector(BaseConnector): | ||
| 123 | 138 | ) |
| 124 | 139 | if not job.title or not job.employer: |
| 125 | 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 | 143 | if details_used < MAX_DETAILS: |
| 128 | 144 | fresh = [False] |
| 129 | 145 | |
@@ -134,10 +150,17 @@ class GuichetEmploisConnector(BaseConnector): | ||
| 134 | 150 | try: |
| 135 | 151 | d = self.detail(jid, key, _fn) |
| 136 | 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 | 154 | if fresh[0]: |
| 139 | 155 | details_used += 1 |
| 140 | − job.description = d.get("description", "") | |
| 141 | − job.date_deadline = d.get("date_deadline") | |
| 156 | + else: | |
| 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 | 165 | out.append(job) |
| 143 | 166 | return out |
modified
jobka/connectors/icims.py
+7 −10
@@ -73,16 +73,11 @@ class ICIMSConnector(BaseConnector): | ||
| 73 | 73 | details_used = 0 |
| 74 | 74 | for jid, slug in seen.items(): |
| 75 | 75 | if details_used >= MAX_DETAILS: |
| 76 | − # budget épuisé : ne servir que le cache (sans jamais y écrire | |
| 77 | − # un vide) — l'offre sera reprise à la prochaine synchronisation | |
| 78 | − from .. import db | |
| 79 | − if self._detail_con is None: | |
| 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: | |
| 76 | + # budget épuisé : ne servir que le cache (même périmé) — | |
| 77 | + # l'offre sera reprise à la prochaine synchronisation | |
| 78 | + fields = self.stale_detail(jid) | |
| 79 | + if not fields: | |
| 84 | 80 | continue |
| 85 | − fields = cached | |
| 86 | 81 | else: |
| 87 | 82 | fresh = [False] |
| 88 | 83 | |
@@ -90,7 +85,9 @@ class ICIMSConnector(BaseConnector): | ||
| 90 | 85 | fresh[0] = True |
| 91 | 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 | 91 | if fresh[0]: |
| 95 | 92 | details_used += 1 |
| 96 | 93 | if not fields: |
modified
jobka/connectors/jobillico.py
+6 −6
@@ -68,6 +68,9 @@ class JobillicoConnector(BaseConnector): | ||
| 68 | 68 | continue |
| 69 | 69 | seen.add(eid) |
| 70 | 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 | 74 | if details_used < MAX_DETAILS: |
| 72 | 75 | fresh = [False] |
| 73 | 76 | |
@@ -75,15 +78,12 @@ class JobillicoConnector(BaseConnector): | ||
| 75 | 78 | fresh[0] = True |
| 76 | 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 | 82 | if fresh[0]: |
| 80 | 83 | details_used += 1 |
| 81 | 84 | else: |
| 82 | − from .. import db | |
| 83 | − if self._detail_con is None: | |
| 84 | − self._detail_con = db.connect() | |
| 85 | − fields = db.get_cached_detail(self._detail_con, self.source_id, | |
| 86 | − eid, lastmod or eid) or {} | |
| 85 | + # budget épuisé : cache même périmé (mieux qu'une offre ratée) | |
| 86 | + fields = self.stale_detail(eid) | |
| 87 | 87 | if not fields or not fields.get("title"): |
| 88 | 88 | continue |
| 89 | 89 | region = (fields.get("region_code") or "").upper() |
modified
jobka/connectors/lever.py
+12 −0
@@ -58,6 +58,10 @@ class LeverConnector(BaseConnector): | ||
| 58 | 58 | sal = item.get("salaryRange") or {} |
| 59 | 59 | desc = (item.get("descriptionBodyPlain") |
| 60 | 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 | 65 | # les puces (exigences, avantages) sont dans lists[] |
| 62 | 66 | for lst in item.get("lists") or []: |
| 63 | 67 | titre = lst.get("text") or "" |
@@ -65,6 +69,10 @@ class LeverConnector(BaseConnector): | ||
| 65 | 69 | if contenu: |
| 66 | 70 | from ..normalize import clean_html |
| 67 | 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 | 76 | job = JobPosting( |
| 69 | 77 | source=self.source_id, external_id=str(item.get("id") or ""), |
| 70 | 78 | url=item.get("hostedUrl") or "", |
@@ -76,10 +84,14 @@ class LeverConnector(BaseConnector): | ||
| 76 | 84 | date_posted=item.get("createdAt"), |
| 77 | 85 | salary_min=sal.get("min"), salary_max=sal.get("max"), |
| 78 | 86 | salary_unit=_INTERVAL.get((sal.get("interval") or "").lower()), |
| 87 | + apply_url=item.get("applyUrl") or "", | |
| 79 | 88 | ats=self.ats, |
| 80 | 89 | ) |
| 81 | 90 | job.details["employment_label"] = cats.get("commitment") or "" |
| 82 | 91 | if cats.get("team"): |
| 83 | 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 | 96 | out.append(job) |
| 85 | 97 | return out |
modified
jobka/connectors/njoyn.py
+1 −0
@@ -164,6 +164,7 @@ class NjoynConnector(BaseConnector): | ||
| 164 | 164 | if f.get("niveau de scolarité"): |
| 165 | 165 | job.requirements["scolarite"] = f["niveau de scolarité"] |
| 166 | 166 | if it["apply_url"]: |
| 167 | + job.apply_url = it["apply_url"] | |
| 167 | 168 | job.details["apply_url"] = it["apply_url"] |
| 168 | 169 | key = hashlib.sha1( |
| 169 | 170 | f"{it['title']}|{it['date']}".encode()).hexdigest()[:12] |
modified
jobka/connectors/recruitee.py
+39 −3
@@ -63,8 +63,28 @@ class RecruiteeConnector(BaseConnector): | ||
| 63 | 63 | continue |
| 64 | 64 | if not self._keep(item): |
| 65 | 65 | continue |
| 66 | − desc = clean_html(item.get("description") or "") | |
| 67 | − reqs = clean_html(item.get("requirements") or "") | |
| 66 | + # traductions : quand une version FRANÇAISE existe, c'est elle | |
| 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 | 88 | if reqs: |
| 69 | 89 | desc = f"{desc}\n\nExigences\n{reqs}" if desc else reqs |
| 70 | 90 | sal = item.get("salary") or {} |
@@ -82,7 +102,7 @@ class RecruiteeConnector(BaseConnector): | ||
| 82 | 102 | source=self.source_id, external_id=str(item.get("id") or ""), |
| 83 | 103 | url=item.get("careers_url") or "", |
| 84 | 104 | employer=self.EMPLOYER or item.get("company_name") or "", |
| 85 | − title=item.get("title") or "", | |
| 105 | + title=title, | |
| 86 | 106 | description=desc, |
| 87 | 107 | city=item.get("city") or "", |
| 88 | 108 | postal_code=item.get("postal_code") or "", |
@@ -95,9 +115,25 @@ class RecruiteeConnector(BaseConnector): | ||
| 95 | 115 | salary_unit=_PERIOD.get((sal.get("period") or "").lower()), |
| 96 | 116 | date_posted=item.get("published_at") or item.get("created_at"), |
| 97 | 117 | date_deadline=item.get("close_at"), |
| 118 | + language=language, | |
| 119 | + apply_url=item.get("careers_apply_url") or "", | |
| 98 | 120 | ats=self.ats, |
| 99 | 121 | ) |
| 100 | 122 | if item.get("department"): |
| 101 | 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 | 138 | out.append(job) |
| 103 | 139 | return out |
modified
jobka/connectors/smartrecruiters.py
+20 −1
@@ -105,6 +105,22 @@ class SmartRecruitersConnector(BaseConnector): | ||
| 105 | 105 | ) |
| 106 | 106 | job.details["employment_label"] = \ |
| 107 | 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 | 124 | key = str(item.get("releasedDate") or "")[:19] |
| 109 | 125 | if details_used < MAX_DETAILS: |
| 110 | 126 | fresh = [False] |
@@ -116,7 +132,10 @@ class SmartRecruitersConnector(BaseConnector): | ||
| 116 | 132 | d = self.detail(eid, key, _fn) |
| 117 | 133 | if fresh[0]: |
| 118 | 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 | 139 | out.append(job) |
| 121 | 140 | offset += PAGE_SIZE |
| 122 | 141 | if offset >= int(data.get("totalFound") or 0): |
modified
jobka/connectors/successfactors.py
+15 −2
@@ -120,7 +120,16 @@ class SuccessFactorsConnector(BaseConnector): | ||
| 120 | 120 | slug = urllib.parse.unquote(slug_raw) |
| 121 | 121 | if self.quebec_only and not _QC_SLUG_RE.search(slug + "/"): |
| 122 | 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 | 133 | postal = "" |
| 125 | 134 | m_cp = re.search(r"([GHJ]\d[A-Z])[- ]?(\d[A-Z]\d)", slug, re.I) |
| 126 | 135 | if m_cp: |
@@ -128,7 +137,7 @@ class SuccessFactorsConnector(BaseConnector): | ||
| 128 | 137 | job = JobPosting( |
| 129 | 138 | source=self.source_id, external_id=eid, url=loc, |
| 130 | 139 | employer=self.EMPLOYER, |
| 131 | − title=" ".join(slug.split("-")[1:-3]) or slug, | |
| 140 | + title=" ".join(parts[n_city:-3]) or slug, | |
| 132 | 141 | city=city, postal_code=postal, |
| 133 | 142 | date_posted=lastmod or None, |
| 134 | 143 | ats=self.ats, |
@@ -143,6 +152,10 @@ class SuccessFactorsConnector(BaseConnector): | ||
| 143 | 152 | d = self.detail(eid, lastmod or eid, _fn) |
| 144 | 153 | if fresh[0]: |
| 145 | 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 | 159 | if d.get("description"): |
| 147 | 160 | job.description = d["description"] |
| 148 | 161 | if d.get("datePosted"): |
modified
jobka/connectors/taleo.py
+20 −1
@@ -31,6 +31,7 @@ import json | ||
| 31 | 31 | import os |
| 32 | 32 | import re |
| 33 | 33 | |
| 34 | +from ..normalize import parse_date | |
| 34 | 35 | from ..schema import JobPosting, clean_html, is_quebec_location |
| 35 | 36 | from .base import BaseConnector |
| 36 | 37 | |
@@ -54,6 +55,12 @@ class TaleoConnector(BaseConnector): | ||
| 54 | 55 | LANG = "fr" |
| 55 | 56 | quebec_only = True |
| 56 | 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 | 65 | @property |
| 59 | 66 | def _base(self) -> str: |
@@ -151,6 +158,18 @@ class TaleoConnector(BaseConnector): | ||
| 151 | 158 | seen.add(contest) |
| 152 | 159 | if not self._keep(locations): |
| 153 | 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 | 173 | job = JobPosting( |
| 155 | 174 | source=self.source_id, external_id=contest, |
| 156 | 175 | url=(f"{self._base}/{self.SECTION}/jobdetail.ftl" |
@@ -159,7 +178,7 @@ class TaleoConnector(BaseConnector): | ||
| 159 | 178 | title=title, |
| 160 | 179 | location_label=locations[0].replace("-", ", ") |
| 161 | 180 | if locations else "", |
| 162 | − date_posted=date_raw or None, | |
| 181 | + date_posted=date_posted, date_deadline=date_deadline, | |
| 163 | 182 | ats=self.ats, |
| 164 | 183 | ) |
| 165 | 184 | key = hashlib.sha1( |
modified
jobka/connectors/workable.py
+5 −0
@@ -66,11 +66,16 @@ class WorkableConnector(BaseConnector): | ||
| 66 | 66 | location_label=locs[0] if locs else "", |
| 67 | 67 | work_mode="teletravail" if item.get("telecommuting") else None, |
| 68 | 68 | date_posted=item.get("published_on") or item.get("created_at"), |
| 69 | + apply_url=item.get("application_url") or "", | |
| 69 | 70 | ats=self.ats, |
| 70 | 71 | ) |
| 71 | 72 | job.details["employment_label"] = item.get("employment_type") or "" |
| 72 | 73 | if item.get("department"): |
| 73 | 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 | 79 | if item.get("experience"): |
| 75 | 80 | job.requirements["experience"] = item["experience"] |
| 76 | 81 | if item.get("education"): |
modified
jobka/connectors/workday.py
+70 −5
@@ -84,9 +84,39 @@ class WorkdayConnector(BaseConnector): | ||
| 84 | 84 | return found |
| 85 | 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 | 118 | url = f"{self._base}/wday/cxs/{self.TENANT}/{self.SITE}/jobs" |
| 89 | − applied: dict = {} | |
| 119 | + applied: dict = dict(extra_facets or {}) | |
| 90 | 120 | offset = 0 |
| 91 | 121 | total = 0 |
| 92 | 122 | first = True |
@@ -96,12 +126,14 @@ class WorkdayConnector(BaseConnector): | ||
| 96 | 126 | "offset": offset, "searchText": ""}).json() |
| 97 | 127 | if first: |
| 98 | 128 | first = False |
| 129 | + self._first_facets = data.get("facets") or [] | |
| 99 | 130 | if self.quebec_only: |
| 100 | 131 | qc = self._find_qc_facet(data.get("facets")) |
| 101 | 132 | if qc: |
| 102 | 133 | # filtre serveur trouvé : rejouer la pagination avec, et |
| 103 | 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 | 137 | self._server_filtered = True |
| 106 | 138 | continue |
| 107 | 139 | items = data.get("jobPostings") or [] |
@@ -118,6 +150,24 @@ class WorkdayConnector(BaseConnector): | ||
| 118 | 150 | if len(items) < PAGE_SIZE or (total and offset >= total): |
| 119 | 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 | 171 | def _fetch_detail(self, external_path: str) -> dict: |
| 122 | 172 | data = self.get(f"{self._base}/wday/cxs/{self.TENANT}/{self.SITE}" |
| 123 | 173 | f"{external_path}").json() |
@@ -128,12 +178,17 @@ class WorkdayConnector(BaseConnector): | ||
| 128 | 178 | "time_type": info.get("timeType") or "", |
| 129 | 179 | "posted_on": info.get("postedOn") or "", |
| 130 | 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 | 185 | def fetch(self) -> list[JobPosting]: |
| 134 | 186 | out: list[JobPosting] = [] |
| 187 | + by_path: dict[str, JobPosting] = {} | |
| 135 | 188 | details_used = 0 |
| 136 | 189 | self._server_filtered = False |
| 190 | + self._first_facets: list = [] | |
| 191 | + self._qc_facet = None | |
| 137 | 192 | for item in self._list_pages(): |
| 138 | 193 | if not self._server_filtered and not self._keep(item): |
| 139 | 194 | continue |
@@ -151,9 +206,10 @@ class WorkdayConnector(BaseConnector): | ||
| 151 | 206 | date_posted=item.get("postedOn") or None, |
| 152 | 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 | 211 | key = hashlib.sha1( |
| 156 | − f"{path}|{item.get('postedOn','')}".encode()).hexdigest()[:12] | |
| 212 | + f"v2|{path}|{item.get('postedOn','')}".encode()).hexdigest()[:12] | |
| 157 | 213 | if details_used < MAX_DETAILS: |
| 158 | 214 | fresh = [False] |
| 159 | 215 | |
@@ -164,11 +220,20 @@ class WorkdayConnector(BaseConnector): | ||
| 164 | 220 | d = self.detail(str(eid), key, _fn) |
| 165 | 221 | if fresh[0]: |
| 166 | 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 | 227 | job.description = clean_html(d.get("description_html", "")) |
| 168 | 228 | job.details["employment_label"] = d.get("time_type", "") |
| 229 | + job.apply_url = d.get("apply_url", "") or "" | |
| 169 | 230 | # « 2 Locations » : le vrai lieu principal est sur la page détail |
| 170 | 231 | if d.get("location") and (not job.location_label or re.match( |
| 171 | 232 | r"^\d+\s+locations?$", job.location_label, re.I)): |
| 172 | 233 | job.location_label = d["location"] |
| 173 | 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 | 239 | return out |
modified
tests/fixtures/mistplay/expected.json
+3 −3
@@ -13,7 +13,7 @@ | ||
| 13 | 13 | "salary_unit": null, |
| 14 | 14 | "employment_type": null, |
| 15 | 15 | "ats": "lever", |
| 16 | − "desc_len": 6414 | |
| 16 | + "desc_len": 9262 | |
| 17 | 17 | }, |
| 18 | 18 | { |
| 19 | 19 | "uid": "mistplay:6cf16909-e4a6-439a-8773-21a2827566ba", |
@@ -27,7 +27,7 @@ | ||
| 27 | 27 | "salary_unit": "year", |
| 28 | 28 | "employment_type": null, |
| 29 | 29 | "ats": "lever", |
| 30 | − "desc_len": 6735 | |
| 30 | + "desc_len": 9583 | |
| 31 | 31 | }, |
| 32 | 32 | { |
| 33 | 33 | "uid": "mistplay:8d459210-edd2-45dd-9e30-b2e804508018", |
@@ -41,7 +41,7 @@ | ||
| 41 | 41 | "salary_unit": null, |
| 42 | 42 | "employment_type": null, |
| 43 | 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 | 13 | "salary_unit": null, |
| 14 | 14 | "employment_type": null, |
| 15 | 15 | "ats": "greenhouse", |
| 16 | − "desc_len": 7044 | |
| 16 | + "desc_len": 7031 | |
| 17 | 17 | }, |
| 18 | 18 | { |
| 19 | 19 | "uid": "poka:6103724004", |
@@ -27,7 +27,7 @@ | ||
| 27 | 27 | "salary_unit": null, |
| 28 | 28 | "employment_type": null, |
| 29 | 29 | "ats": "greenhouse", |
| 30 | − "desc_len": 6992 | |
| 30 | + "desc_len": 6979 | |
| 31 | 31 | }, |
| 32 | 32 | { |
| 33 | 33 | "uid": "poka:6114006004", |
@@ -41,7 +41,7 @@ | ||
| 41 | 41 | "salary_unit": null, |
| 42 | 42 | "employment_type": null, |
| 43 | 43 | "ats": "greenhouse", |
| 44 | − "desc_len": 4419 | |
| 44 | + "desc_len": 4406 | |
| 45 | 45 | }, |
| 46 | 46 | { |
| 47 | 47 | "uid": "poka:6115034004", |
@@ -55,7 +55,7 @@ | ||
| 55 | 55 | "salary_unit": null, |
| 56 | 56 | "employment_type": null, |
| 57 | 57 | "ats": "greenhouse", |
| 58 | − "desc_len": 5664 | |
| 58 | + "desc_len": 5651 | |
| 59 | 59 | }, |
| 60 | 60 | { |
| 61 | 61 | "uid": "poka:6134510004", |
@@ -69,7 +69,7 @@ | ||
| 69 | 69 | "salary_unit": null, |
| 70 | 70 | "employment_type": null, |
| 71 | 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 | |