SPB Git forge

spb/job-ka

Public
227commits 1branches 0releases
37.7 MBsize
maindefault branch
1 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%

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 1 mo ago (Aug 19, 2026) parent c618452

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