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 | 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 | ||||