SPB Git

spb/job-ka

Public

scripts: éclaireur Serper + support Dayforce dans probe_ats (campagne connecteurs du 2026-08-22)

Outils laissés non commités par la session précédente : serper_scout.py
(requêtes site:<ATS> × termes québécois → slugs candidats JSONL) et
probe_ats.py étendu à 10 ATS (ajout dayforce).

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

2 changed files +301 −3

modified scripts/probe_ats.py +66 −3
@@ -4,11 +4,11 @@
4 # Auteur : Simon-Pierre Boucher 4 # Auteur : Simon-Pierre Boucher
5 # Contact : contact@spboucher.ai 5 # Contact : contact@spboucher.ai
6 # Fichier : scripts/probe_ats.py 6 # Fichier : scripts/probe_ats.py
7−# Rôle : Sondage massif de slugs candidats sur les 9 ATS supportés — 7+# Rôle : Sondage massif de slugs candidats sur les 10 ATS supportés —
8 # un slug est CONFIRMÉ si l'API répond ≥1 offre ET ≥1 offre au 8 # un slug est CONFIRMÉ si l'API répond ≥1 offre ET ≥1 offre au
9 # Québec (réutilise is_quebec_location du projet). Émet des entrées 9 # Québec (réutilise is_quebec_location du projet). Émet des entrées
10 # prêtes pour data/feeds-*.json (scripts/gen_connectors.py). 10 # prêtes pour data/feeds-*.json (scripts/gen_connectors.py).
11−# Créé : 2026-08-18 Modifié : 2026-08-18 11+# Créé : 2026-08-18 Modifié : 2026-08-22
12 # ============================================================================= 12 # =============================================================================
13 """Sonde des slugs candidats sur les 9 plateformes ATS de Job·Ka. 13 """Sonde des slugs candidats sur les 9 plateformes ATS de Job·Ka.
14 14
@@ -67,7 +67,8 @@ WD_COMMON_SITES = [
67 ] 67 ]
68 68
69 DEFAULT_ORDER = ["lever", "greenhouse", "ashby", "smartrecruiters", 69 DEFAULT_ORDER = ["lever", "greenhouse", "ashby", "smartrecruiters",
70− "workable", "recruitee", "breezy", "bamboohr", "workday"] 70+ "workable", "recruitee", "breezy", "bamboohr", "dayforce",
71+ "workday"]
71 72
72 # --- throttling par domaine d'API ------------------------------------------- 73 # --- throttling par domaine d'API -------------------------------------------
73 _locks: dict[str, threading.Lock] = {} 74 _locks: dict[str, threading.Lock] = {}
@@ -354,11 +355,70 @@ def probe_workday(slug: str, cand: dict) -> dict | None:
354 return None 355 return None
355 356
356 357
358+def probe_dayforce(slug: str, cand: dict) -> dict | None:
359+ """Dayforce HCM — même API que jobka/connectors/dayforce.py : csrf anonyme
360+ puis recherche paginée. Le namespace est sensible à la casse tel qu'écrit
361+ dans l'URL ; babillard par défaut CANDIDATEPORTAL."""
362+ base = "https://jobs.dayforcehcm.com"
363+ boards = [b for b in (cand.get("board"), "CANDIDATEPORTAL") if b]
364+ sess = requests.Session()
365+ sess.headers["User-Agent"] = UA
366+ with _glock:
367+ lock = _locks.setdefault("jobs.dayforcehcm.com", threading.Lock())
368+ for board in dict.fromkeys(boards):
369+ with lock:
370+ wait = THROTTLE - (time.time() - _last.get("dayforce", 0.0))
371+ if wait > 0:
372+ time.sleep(wait)
373+ try:
374+ csrf = (sess.get(f"{base}/api/auth/csrf",
375+ timeout=TIMEOUT).json() or {}).get(
376+ "csrfToken") or ""
377+ r = sess.post(
378+ f"{base}/api/geo/{slug}/jobposting/search",
379+ timeout=TIMEOUT,
380+ headers={"X-CSRF-TOKEN": csrf,
381+ "Accept": "application/json"},
382+ json={"clientNamespace": slug, "jobBoardCode": board,
383+ "cultureCode": "fr-CA", "paginationStart": 0})
384+ except requests.RequestException:
385+ return None
386+ finally:
387+ _last["dayforce"] = time.time()
388+ _nreq[0] += 1
389+ if r.status_code != 200:
390+ continue
391+ try:
392+ data = r.json()
393+ except ValueError:
394+ continue
395+ postings = data.get("jobPostings") or []
396+ total = int(data.get("maxCount") or len(postings))
397+ if not postings:
398+ continue
399+ labels = []
400+ for p in postings:
401+ locs = []
402+ for l in (p.get("postingLocations") or []):
403+ if (l.get("stateCode") or "").upper() == "QC" and \
404+ (l.get("isoCountryCode") or "CA").upper() == "CA":
405+ locs.append(l.get("formattedAddress")
406+ or f"{l.get('cityName') or ''}, QC")
407+ else:
408+ locs.append(l.get("formattedAddress") or "")
409+ labels.append(locs)
410+ qc, ex = _qc_stats(labels)
411+ return {"found": total, "qc": qc, "qc_ex": ex, "api_name": "",
412+ "ns": slug, "board": board}
413+ return None
414+
415+
357 PROBES = { 416 PROBES = {
358 "lever": probe_lever, "greenhouse": probe_greenhouse, "ashby": probe_ashby, 417 "lever": probe_lever, "greenhouse": probe_greenhouse, "ashby": probe_ashby,
359 "smartrecruiters": probe_smartrecruiters, "workable": probe_workable, 418 "smartrecruiters": probe_smartrecruiters, "workable": probe_workable,
360 "recruitee": probe_recruitee, "breezy": probe_breezy, 419 "recruitee": probe_recruitee, "breezy": probe_breezy,
361 "bamboohr": probe_bamboohr, "workday": probe_workday, 420 "bamboohr": probe_bamboohr, "workday": probe_workday,
421+ "dayforce": probe_dayforce,
362 } 422 }
363 423
364 424
@@ -410,6 +470,9 @@ def probe_group(group: str, cands: list[dict], stats: dict,
410 if ats == "workday": 470 if ats == "workday":
411 feed.update(tenant=res["tenant"], host=res["host"], 471 feed.update(tenant=res["tenant"], host=res["host"],
412 site=res["site"]) 472 site=res["site"])
473+ elif ats == "dayforce":
474+ feed.update(ns=res["ns"], board=res["board"],
475+ source_id=res["ns"].lower())
413 else: 476 else:
414 feed["org"] = res.get("org_override", slug) 477 feed["org"] = res.get("org_override", slug)
415 return feed, {} 478 return feed, {}
added scripts/serper_scout.py +235 −0
@@ -0,0 +1,235 @@
1+#!/usr/bin/env python3
2+# =============================================================================
3+# Job·Ka — Groupe KA
4+# Auteur : Simon-Pierre Boucher
5+# Contact : contact@spboucher.ai
6+# Fichier : scripts/serper_scout.py
7+# Rôle : Éclaireur Serper — requêtes Google `site:<domaine ATS>` × termes
8+# québécois, extraction des slugs candidats par motifs d'URL,
9+# dédoublonnage contre sources.json/connecteurs. Sortie JSONL prête
10+# pour scripts/probe_ats.py.
11+# Créé : 2026-08-22 Modifié : 2026-08-22
12+# =============================================================================
13+"""Usage :
14+ python3 scripts/serper_scout.py --out candidats.jsonl \
15+ [--pages 2] [--ats lever,greenhouse,...] [--workers 6]
16+
17+Nécessite SERPER_API_KEY dans l'environnement. 1 requête Serper = 1 crédit ;
18+~(nb ATS × nb termes × pages) requêtes au total, throttlées.
19+"""
20+from __future__ import annotations
21+
22+import argparse
23+import json
24+import os
25+import re
26+import sys
27+import threading
28+import time
29+from concurrent.futures import ThreadPoolExecutor, as_completed
30+from pathlib import Path
31+
32+import requests
33+
34+ROOT = Path(__file__).resolve().parent.parent
35+sys.path.insert(0, str(ROOT))
36+
37+SERPER_URL = "https://google.serper.dev/search"
38+KEY = os.environ.get("SERPER_API_KEY", "")
39+
40+# Termes de recherche : villes/régions québécoises + génériques emploi QC.
41+TERMS = [
42+ '"Montréal"', '"Québec"', '"Laval, QC"', '"Gatineau"', '"Sherbrooke"',
43+ '"Longueuil"', '"Trois-Rivières"', '"Saguenay"', '"Lévis"', '"Brossard"',
44+ '"Drummondville"', '"Granby"', '"Terrebonne"', '"Boucherville"',
45+ '"Saint-Hyacinthe"', '"Rimouski"', '"Victoriaville"', '"Rouyn-Noranda"',
46+ '"Sept-Îles"', '"Vaudreuil"', '"Joliette"', '"Saint-Jérôme"',
47+ '"Baie-Comeau"', '"Val-d\'Or"', '"Alma"', '"Shawinigan"',
48+ '"Salaberry-de-Valleyfield"', '"Sainte-Julie"', '"Beloeil"', '"Mirabel"',
49+ '"Blainville"', '"Repentigny"', '"Chicoutimi"', '"Kirkland"',
50+ '"Pointe-Claire"', '"Dorval"', '"Anjou"', '"Lachine"', '"Longue-Pointe"',
51+ '"Québec, QC"', 'emploi Québec', 'carrières Québec',
52+]
53+
54+# domaine ATS -> (nom de site pour `site:`, regex d'extraction, ats, groupe(s))
55+PATTERNS: dict[str, dict] = {
56+ "lever": {
57+ "site": "jobs.lever.co",
58+ "rx": re.compile(r"jobs\.lever\.co/([A-Za-z0-9._-]+)"),
59+ },
60+ "greenhouse": {
61+ "site": "boards.greenhouse.io OR site:job-boards.greenhouse.io",
62+ "rx": re.compile(r"(?:job-)?boards(?:\.eu)?\.greenhouse\.io/"
63+ r"(?:embed/job_board\?for=)?([A-Za-z0-9._-]+)"),
64+ },
65+ "smartrecruiters": {
66+ "site": "jobs.smartrecruiters.com",
67+ "rx": re.compile(r"jobs\.smartrecruiters\.com/(?:oneclick-ui/company/)?"
68+ r"([A-Za-z0-9._-]+)"),
69+ },
70+ "workable": {
71+ "site": "apply.workable.com",
72+ "rx": re.compile(r"apply\.workable\.com/(?:api/v\d/accounts/)?"
73+ r"([A-Za-z0-9._-]+)"),
74+ },
75+ "ashby": {
76+ "site": "jobs.ashbyhq.com",
77+ "rx": re.compile(r"jobs\.ashbyhq\.com/([A-Za-z0-9._%-]+)"),
78+ },
79+ "recruitee": {
80+ "site": "recruitee.com",
81+ "rx": re.compile(r"https?://([a-z0-9-]+)\.recruitee\.com"),
82+ },
83+ "breezy": {
84+ "site": "breezy.hr",
85+ "rx": re.compile(r"https?://([a-z0-9-]+)\.breezy\.hr"),
86+ },
87+ "bamboohr": {
88+ "site": "bamboohr.com",
89+ "rx": re.compile(r"https?://([a-z0-9-]+)\.bamboohr\.com/(?:careers|jobs)"),
90+ },
91+ "workday": {
92+ "site": "myworkdayjobs.com",
93+ "rx": re.compile(r"https?://([a-z0-9-]+)\.(wd\d+)\.myworkdayjobs\.com/"
94+ r"(?:([a-zA-Z]{2}-[a-zA-Z]{2})/)?([A-Za-z0-9_-]+)"),
95+ },
96+ "dayforce": {
97+ "site": "jobs.dayforcehcm.com",
98+ "rx": re.compile(r"jobs\.dayforcehcm\.com/(?:[a-z]{2}-[A-Za-z]{2}/)?"
99+ r"([A-Za-z0-9]+)/([A-Za-z0-9_-]+)"),
100+ },
101+}
102+
103+BAD_SLUGS = {
104+ "login", "signin", "api", "js", "css", "static", "assets", "app", "www",
105+ "help", "support", "blog", "about", "privacy", "terms", "search", "jobs",
106+ "job", "embed", "widget", "oneclick-ui", "company", "careers", "fr", "en",
107+ "mydayforce", "candidateportal",
108+}
109+
110+_lock = threading.Lock()
111+_last = [0.0]
112+_nreq = [0]
113+
114+
115+def serper(q: str, page: int) -> list[dict]:
116+ with _lock:
117+ wait = 0.15 - (time.time() - _last[0])
118+ if wait > 0:
119+ time.sleep(wait)
120+ _last[0] = time.time()
121+ _nreq[0] += 1
122+ r = requests.post(
123+ SERPER_URL, timeout=20,
124+ headers={"X-API-KEY": KEY, "Content-Type": "application/json"},
125+ json={"q": q, "gl": "ca", "hl": "fr", "num": 20, "page": page})
126+ r.raise_for_status()
127+ return r.json().get("organic") or []
128+
129+
130+def known_slugs() -> set[str]:
131+ known: set[str] = set()
132+ src = ROOT / "data" / "sources.json"
133+ if src.exists():
134+ for s in json.loads(src.read_text())["sources"]:
135+ known.add(s["id"].lower())
136+ pat = re.compile(
137+ r"^\s*(?:ORG|BOARD|COMPANY|TENANT|NS)\s*=\s*['\"]([^'\"]+)", re.M)
138+ for f in (ROOT / "jobka" / "connectors").glob("*.py"):
139+ for m in pat.finditer(f.read_text(encoding="utf-8")):
140+ known.add(m.group(1).lower())
141+ return known
142+
143+
144+def main() -> None:
145+ ap = argparse.ArgumentParser()
146+ ap.add_argument("--out", required=True)
147+ ap.add_argument("--pages", type=int, default=2)
148+ ap.add_argument("--ats", default=",".join(PATTERNS))
149+ ap.add_argument("--workers", type=int, default=6)
150+ ap.add_argument("--terms-file", default="",
151+ help="fichier texte : un terme de recherche par ligne "
152+ "(remplace la liste de villes par défaut)")
153+ args = ap.parse_args()
154+ if not KEY:
155+ sys.exit("SERPER_API_KEY manquant")
156+ global TERMS
157+ if args.terms_file:
158+ TERMS = [l.strip() for l in Path(args.terms_file)
159+ .read_text(encoding="utf-8").splitlines() if l.strip()]
160+
161+ wanted = [a.strip() for a in args.ats.split(",") if a.strip() in PATTERNS]
162+ known = known_slugs()
163+ found: dict[tuple, dict] = {}
164+ flock = threading.Lock()
165+
166+ def one(ats: str, term: str, page: int) -> None:
167+ cfg = PATTERNS[ats]
168+ q = f"site:{cfg['site']} {term}"
169+ try:
170+ organic = serper(q, page)
171+ except Exception as exc:
172+ print(f" ! {q} p{page}: {exc}", flush=True)
173+ return
174+ for item in organic:
175+ url = item.get("link") or ""
176+ m = cfg["rx"].search(url)
177+ if not m:
178+ continue
179+ title = (item.get("title") or "").split(" - ")[0].split(" | ")[0]
180+ if ats == "workday":
181+ tenant, host, _cult, site = m.groups()
182+ if tenant.lower() in known or tenant.lower() in BAD_SLUGS:
183+ continue
184+ key = ("workday", tenant.lower())
185+ entry = {"slug": tenant.lower(), "ats": "workday",
186+ "tenant": tenant.lower(), "host": host, "site": site,
187+ "employer": title.strip(), "src_url": url}
188+ elif ats == "dayforce":
189+ ns, board = m.group(1), m.group(2)
190+ if ns.lower() in known or ns.lower() in BAD_SLUGS:
191+ continue
192+ key = ("dayforce", ns.lower())
193+ entry = {"slug": ns, "ats": "dayforce", "board": board
194+ if board.upper() != "CANDIDATEPORTAL"
195+ and not board.startswith("jobs") else "",
196+ "employer": title.strip(), "src_url": url}
197+ else:
198+ slug = m.group(1)
199+ if slug.lower() in known or slug.lower() in BAD_SLUGS:
200+ continue
201+ key = (ats, slug.lower())
202+ entry = {"slug": slug, "ats": ats,
203+ "employer": title.strip(), "src_url": url}
204+ with flock:
205+ found.setdefault(key, entry)
206+
207+ jobs = [(a, t, p) for a in wanted for t in TERMS
208+ for p in range(1, args.pages + 1)]
209+ print(f"[scout] {len(jobs)} requêtes Serper ({len(wanted)} ATS × "
210+ f"{len(TERMS)} termes × {args.pages} pages)", flush=True)
211+ t0 = time.time()
212+ with ThreadPoolExecutor(max_workers=args.workers) as ex:
213+ futs = [ex.submit(one, a, t, p) for a, t, p in jobs]
214+ for i, fut in enumerate(as_completed(futs), 1):
215+ fut.result()
216+ if i % 100 == 0:
217+ print(f"[scout] {i}/{len(jobs)} requêtes, "
218+ f"{len(found)} candidats, {time.time()-t0:.0f}s",
219+ flush=True)
220+
221+ out = sorted(found.values(), key=lambda e: (e["ats"], e["slug"].lower()))
222+ Path(args.out).write_text(
223+ "\n".join(json.dumps(e, ensure_ascii=False) for e in out) + "\n",
224+ encoding="utf-8")
225+ per = {}
226+ for e in out:
227+ per[e["ats"]] = per.get(e["ats"], 0) + 1
228+ print(f"[scout] terminé en {time.time()-t0:.0f}s — {_nreq[0]} requêtes, "
229+ f"{len(out)} candidats -> {args.out}")
230+ for a, n in sorted(per.items()):
231+ print(f" {a:16s} {n}")
232+
233+
234+if __name__ == "__main__":
235+ main()
236
Menu

Theme