Audit Lou·Ka → Plan d'adaptation Job·Ka
Livrable de la règle nº 2 du
CLAUDE.md. Dépôt étudié :~/Desktop/lou-ka(212 connecteurs, ~18 500 annonces actives, SQLite + FastAPI + React/Vite, production PM2 + ngrok). Aucune implémentation n'a été faite : ce document attend la validation de Simon-Pierre avant tout code.
Partie A — Ce que fait Lou·Ka (architecture éprouvée)
A.1 Connecteurs : interface, conventions, découverte
- Classe de base
BaseConnector(louka/connectors/base.py), contrat minimal :- attributs de classe :
source_id: str,request_delay: float = 0.6,timeout: int = 30,use_detail_cache: bool = True; - une seule méthode abstraite par convention (pas d'
abc) :fetch(self) -> list[Listing]; - helpers fournis :
get()/post()(sessionrequeststhrottlée +raise_for_status),get_rendered()(Firecrawl),scrapfly()/get_scrapfly()(rendu JS + anti-bot),detail()(cache BD des pages détail).
- attributs de classe :
- Conventions : un fichier = un connecteur = un
source_id; fichiersnake_case.py→ classeCamelCaseConnector→source_id= nom du fichier. Bannière commentée en tête de fichier (site, plateforme, particularités, robots.txt). - Découverte automatique (
connectors/__init__.py) :pkgutil.iter_modulesimportlibremplissent le dictCONNECTORSavec toute sous-classe deBaseConnectorayant unsource_id. Un module qui plante à l'import est ignoré avec un avertissement — jamais de liste à maintenir à la main.
- Plateformes partagées : une classe de plateforme expose des constantes
(
BASE,LIST_PATH,CITY_DEFAULT…) et des crochets surchargeables (_parse_card,_fetch_detail…) ; un client de la plateforme = une sous-classe de ~15 lignes (ex.DynamicConnector→AgenceSherbrookeConnector). - Registre descriptif parallèle
data/sources.json(id, name, url, listing_url, sectors, connector, status, region) — alimente la page publique « Sources », pas la découverte.
A.2 Schéma normalisé et pipeline de normalisation
@dataclass Listing(louka/schema.py) — stdlib, pas de Pydantic. Champs requis :source,external_id,url. Dérivés :uid = "{source}:{external_id}"(clé primaire),content_hash()(sha256 du dict trié → moteur de diff),finalize()(normalisation commune, idempotente, n'écrase jamais une valeur explicite du connecteur).louka/normalize.pycentralise :parse_price(exige$adjacent, bornes de plausibilité, retourne le min d'une fourchette),parse_availability_date(FR + EN → ISO, jamais de date inventée),parse_area_sqft,normalize_unit_type,extract_details(~20 règles à motifs positifs/négatifs, dict sparse : clé absente = inconnu, jamais defalsedeviné),clean_address,strip_accents.- Nettoyage HTML à trois niveaux :
BeautifulSoup.get_text()dans le connecteur, helpers partagés_detailutil.py(JSON-LD, aplatissement, coordonnées Google Maps), ettextmine.py(analyse regex FR déterministe de la description → digest structuré ; aucun LLM). - Garde-fou géographique dans
finalize(): coordonnées hors Québec rejetées.
A.3 Erreurs, retries, timeouts, throttling
- Pas de retry HTTP générique (ni tenacity ni urllib3.Retry) : la
robustesse vient de
try/exceptpar source + par annonce, et de la reprise au cycle suivant. Philosophie : une panne coûte une heure, pas des données. - Throttling par source :
request_delay(0.6 s défaut, jusqu'à 20 s quand le robots.txt exige unCrawl-delay), appliqué dansget()/post(). - Budgets de requêtes plutôt que rate limit externe :
max_pages,max_details, plafonds surchargés par variables d'env (LOUKA_<PORTAIL>_<LIMITE>), exceptions sentinelles locales. - User-Agent identifiable unique, déclaré une fois dans
base.py(LouKaBot/1.0 (+https://www.lou-ka.com/bot; contact@spboucher.ai)). - Timeouts : 30 s défaut, surchargables par classe ; Firecrawl 90-150 s, Scrapfly 180 s.
- Scrapfly / Firecrawl = seule stratégie anti-bot et rendu JS ; aucun proxy
maison ; tout est
requestssynchrone.
A.4 Scheduler / orchestration
- Boucle maison, pas d'APScheduler ni cron :
run.py watch 60→ingest.watch(3600)=while True: run(); sleep(3600). - Cycle strictement séquentiel : ingestion → géocodage en lot → POI →
quartier → déduplication (la dédup vient après le géocodage car les
coordonnées alimentent le blocage). Étapes 2-5 chacune dans un
try/exceptnon bloquant. - Aucun parallélisme entre connecteurs (choix délibéré de politesse) ;
POST /api/syncdéclenche un cycle sousthreading.Lock. - CLI unique
run.py:sync | watch | serve | geocode | record …, dispatch manuel sursys.argv, chargement.envmaison, imports paresseux.
A.5 Déduplication inter-sources
louka/dedup.py — clé exacte + union-find, aucun fuzzy :
- Blocage par clé d'adresse normalisée (
civique|mots-de-rue|ville, sans accents ni types de voie) — la proximité GPS a été abandonnée (fusionnait des immeubles voisins). - Confirmation d'une paire : même clé obligatoire + aucun signal contradictoire (unité, type, prix ±4 %) + au moins un second signal concordant.
- Union-find avec contrainte : jamais deux annonces de la même source dans une composante ; jamais de dédup intra-source.
- Canonique choisi par autorité de la source (site direct < portail <
petites annonces) puis richesse du contenu. Les doublons sont masqués
(
dup_of), jamais supprimés ; recalcul intégral idempotent à chaque cycle.
A.6 Expiration des annonces
- Marquage vu/non-vu par cycle :
last_seen,miss_count; non vue 2 cycles consécutifs (MISS_GRACE = 2) →active = 0. Jamais deDELETE. - Garde-fou anti-dérive (
_drift_alert) : si une source retourne ≤ 25 % de sa médiane des 5 dernières syncs OK, ou si le taux de prix nuls explose → alerte consignée et retraits suspendus (un connecteur cassé ne vide pas la base). - SEO : annonce retirée → HTTP 410 Gone sur sa page.
A.7 Stockage et exposition
- SQLite unique (
data/louka.db,sqlite3stdlib, pas d'ORM). Schéma idempotent exécuté à chaqueconnect(); migrations maison parPRAGMA table_info+ALTER TABLE ADD COLUMN(dict_MIGRATIONS). - Tables clés :
listings(uid PK, colonnes du dataclass + cycle de vie),sync_log(monitoring),price_log(historique),geocode_cache,detail_cache,poi_cache, comptes/favoris. - Index : source, city, active,
(lat, lng)pour les bbox de la carte. - FastAPI (
louka/web.py) :/api/listings(filtres + pagination),/api/listings.geojson(bbox),/api/listings/{uid}(fiche enrichie),/api/facets,/api/sources,/api/stats[.detailed], PDF,POST /api/sync. CORS ouvert + GZip. Frontend React 18 + Vite + TS servi en statique par FastAPI (catch-all avec vraies 404), SSR léger + sitemaps dansseo.py. - Géocodage : Nominatim (1,1 s/req) + repli Adresses Québec (lot de 200), cache SQLite avec mémorisation des échecs (retente après 30 j), validation par bounding box, granularité par immeuble. Jamais de coordonnées inventées.
A.8 Logging, monitoring, tests
- Pas de module
logging:print("[lou-ka] …")capté par PM2. La vraie télémétrie est en base :sync_log(source, ts, found, added, updated, removed, ok, message, stats JSON)→ exposée par/api/stats(20 dernières syncs) et/api/sources(last_syncpar source). - Fixtures HTTP enregistrées/rejouées (
louka/fixtures.py) :run.py record <source>capture les requêtes réelles (adapter monté sur la session, secrets caviardés) ;pytestrejoue hors ligne et compare à un instantané déterministe + invariants (uid uniques, URLs absolues, bornes de prix/superficie, pas de valeurs inventées). 193 sources couvertes. - Une fiche Markdown par connecteur dans
reports/connectors/<source>.md(méthode, couverture des champs en %, fragilités, échantillon). - Production : 3 processus PM2 —
lou-ka-web(serve 8095),lou-ka-sync(watch 60),lou-ka-ngrok(ngrok http --url=www.lou-ka.com 8095). Philosophie : « on ne pousse que le code — le serveur maintient ses données lui-même » (data/*.db,dist/,.envjamais versionnés).
Partie B — Plan d'adaptation pour Job·Ka
Principe directeur : reprendre l'architecture de Lou·Ka telle quelle —
paquet jobka/, mêmes mécanismes (registre auto-découvrant, diff par
content_hash, miss_count + anti-dérive, fixtures rejouables, SQLite,
FastAPI, run.py, PM2 + ngrok) — et n'adapter que ce qui est propre au
domaine de l'emploi.
B.1 Ce qui se transpose tel quel (≈ 80 %)
| Brique Lou·Ka | Job·Ka |
|---|---|
BaseConnector + registre auto-découvrant |
Identique (jobka/connectors/) |
finalize() + normalize.py |
Identique dans l'esprit ; parseurs adaptés (salaires, dates limites) |
Diff content_hash + sync_log + anti-dérive |
Identique — critique pour l'emploi (offres qui disparaissent vite) |
Expiration miss_count/MISS_GRACE=2 + active=0 |
Identique + prise en compte de date_limite dépassée |
| Fixtures record/replay + tests d'invariants | Identique (run.py record, pytest hors ligne) |
| SQLite + migrations maison + FastAPI + frontend Vite | Identique |
| Géocodage Nominatim + Adresses Québec + cache | Réutilisé tel quel (le lieu de travail se géocode comme une adresse) |
CLI run.py (sync/watch/serve/record) |
Identique |
| Déploiement 3 processus PM2 + ngrok | Identique, domaine www.job-ka.com, nœud m3u96a |
Fiches reports/connectors/<source>.md |
Identique |
B.2 Schéma JobPosting (adaptation du dataclass Listing)
Mêmes conventions (champs requis source, external_id, url ; uid,
content_hash(), finalize()), champs métier du CLAUDE.md §4 mappés au
style Lou·Ka (identifiants en anglais) :
@dataclass
class JobPosting:
source: str # id du connecteur (= employeur ou ATS) [id_source]
external_id: str
url: str # lien direct vers l'offre — zéro boîte noire
employer: str = "" # [employeur]
title: str = ""
description: str = "" # HTML nettoyé
address: str = "" # lieu de travail si dispo
city: str = ""
region: str = ""
postal_code: str = ""
work_mode: str | None = None # presentiel | hybride | teletravail
employment_type: str | None = None # temps_plein | partiel | contractuel | stage | saisonnier
salary_min: float | None = None # nombres, jamais des strings
salary_max: float | None = None
salary_unit: str | None = None # "hour" | "year" (+ conversions dérivées)
benefits: list[str] = field(default_factory=list)
requirements: dict = field(default_factory=dict) # scolarité, expérience, langues
date_posted: str | None = None # ISO 8601
date_deadline: str | None = None # ISO 8601
category: str = "" # taxonomie interne (TI, santé, construction…)
ats: str = "" # workday | lever | greenhouse | custom…
details: dict = field(default_factory=dict) # sparse, comme Lou·Ka
lat: float | None = None
lng: float | None = NoneNormalisation spécifique emploi (équivalents de parse_price/parse_area_sqft) :
parse_salary(raw)→(min, max, unit); bornes de plausibilité (~15–200 $/h, ~20 000–500 000 $/an) ; conversion $/h ↔ $/an (base 40 h × 52 sem.) stockée en champs dérivés — la transparence salariale est le différenciateur clé, jamais de salaire inventé ;parse_work_mode,parse_employment_type: tables de mots-clés FR/EN (comme_UNIT_WORDS) ;parse_date: réutiliseparse_availability_date(FR + EN → ISO) ;categorize(title, description): taxonomie par mots-clés déterministes (approchetextmine.py, pas de LLM au départ).
B.3 Connecteurs : l'avantage ATS
Différence favorable vs l'immobilier : une grande part des employeurs québécois
passe par un ATS mutualisé avec API JSON publique et stable. Une classe de
plateforme par ATS (pattern DynamicConnector/LaCourConnector) donne des
connecteurs-clients de ~10 lignes :
| Classe de plateforme | Méthode | Coût par employeur |
|---|---|---|
WorkdayConnector |
API JSON …/wday/cxs/<tenant>/<site>/jobs (POST paginé) |
tenant + site |
LeverConnector |
api.lever.co/v0/postings/<org>?mode=json |
slug org |
GreenhouseConnector |
boards-api.greenhouse.io/v1/boards/<org>/jobs?content=true |
slug org |
SmartRecruitersConnector |
api.smartrecruiters.com/v1/companies/<org>/postings |
slug org |
BambooHRConnector |
<org>.bamboohr.com/careers/list (JSON) |
sous-domaine |
JazzHRConnector, RecruiteeConnector, UKGConnector… |
à documenter au fil des sources | — |
| Connecteurs custom (HTML) | BeautifulSoup, ou Scrapfly si rendu JS / anti-bot |
1 module dédié |
- Scrapfly : méthode
scrapfly()reprise debase.pytelle quelle ; clé déjà en place dans.env(SCRAPFLY_API_KEY). Usage réservé aux pages carrières à rendu JS ou protégées (rôle qu'avaitfb_marketplacedans Lou·Ka) — les ATS JSON n'en ont pas besoin. - User-Agent :
JobKaBot/1.0 (+https://www.job-ka.com; contact@spboucher.ai)(CLAUDE.md §5), déclaré une seule fois dansbase.py. - Throttling par domaine (
request_delay), budgetsmax_pages/max_details, plafonds par envJOBKA_<SOURCE>_<LIMITE>: repris à l'identique.
B.4 Déduplication adaptée à l'emploi
Le blocage par adresse civique ne se transpose pas (une offre n'a pas toujours d'adresse précise). Même squelette (blocage + confirmation + union-find + autorité), clés adaptées :
- Blocage :
titre normalisé (sans accents, stopwords RH retirés) | employeur normalisé | ville; - Confirmation : refus si signaux contradictoires (type d'emploi différent, salaires connus s'écartant de > 4 %) + exigence d'un second signal concordant (même date de publication ±7 j, même salaire, même code postal) ;
- Autorité : page carrière de l'employeur (0) < ATS mutualisé (10) —
pertinent le jour où des agrégats s'ajoutent ; masquage
dup_of, jamais de suppression ; contrainte « jamais deux offres de la même source dans une composante » conservée.
B.5 Expiration spécifique emploi
miss_count+MISS_GRACE = 2+ anti-dérive : repris tels quels ;- règle additionnelle :
date_deadlinedépassée →active = 0même si l'offre est encore en ligne (offres zombies fréquentes sur les pages carrières) ; - offre retirée → HTTP 410 sur sa page publique (pattern
seo.py).
B.6 Structure de dépôt cible
job-ka/
├── run.py # sync | watch | serve | geocode | record
├── requirements.txt # fastapi, uvicorn, requests, beautifulsoup4
├── jobka/
│ ├── schema.py # JobPosting (dataclass) + finalize()
│ ├── normalize.py # salaires, dates, mode/type d'emploi, catégories
│ ├── db.py # SQLite, sync_log, anti-dérive, migrations
│ ├── ingest.py # run() / watch()
│ ├── dedup.py # blocage titre|employeur|ville + union-find
│ ├── geocode.py # Nominatim + Adresses Québec + cache (repris)
│ ├── web.py # FastAPI : /api/jobs, /api/facets, /api/sources, /api/stats
│ ├── seo.py # SSR léger + sitemaps + 410
│ ├── fixtures.py # record/replay hors ligne
│ └── connectors/
│ ├── base.py # BaseConnector (JobKaBot UA, scrapfly(), detail())
│ ├── _atsutil.py # helpers partagés (équiv. _detailutil)
│ ├── workday.py, lever.py, greenhouse.py, … # classes de plateforme
│ └── <employeur>.py # 1 fichier = 1 source
├── data/sources.json # registre descriptif des employeurs
├── frontend/ # React + Vite + TS, servi par FastAPI
├── tests/ # test_connectors (fixtures), test_normalize
├── reports/connectors/ # 1 fiche .md par connecteur
└── docs/B.7 Déploiement (CLAUDE.md §6)
3 processus PM2 sur m3u96a, calqués sur Lou·Ka :
job-ka-web .venv/bin/python run.py serve <port> # API + SSR + frontend
job-ka-sync .venv/bin/python run.py watch 60 # resync horaire, jour et nuit
job-ka-ngrok ngrok http --url=www.job-ka.com <port>Vérifications post-déploiement : https://www.job-ka.com répond,
/api/sources montre des last_sync récents, sync_log sans alerte.
Jamais d'admin/BD exposé par le tunnel. Enregistrement dans
~/Desktop/cluster-skill/cluster-deployments.json. À noter : m3u96a est
normalement réservé au staging/calcul — dérogation explicite du CLAUDE.md §6.
B.8 Écarts assumés vs Lou·Ka (améliorations légères)
- Endpoint
/healthajouté (Lou·Ka n'en a pas ; le CLAUDE.md de Job·Ka exige un healthcheck au déploiement). dedup.run()aussi appelé en fin derun.py sync(bogue mineur relevé chez Lou·Ka : la dédup ne tourne que danswatch).- Réinitialiser
geocode_failedquand l'adresse d'une offre change (second bogue mineur relevé chez Lou·Ka). - En-tête d'auteur Job·Ka (règle nº 1) sur chaque fichier + hook pre-commit de vérification (section 17 du CLAUDE.md).
B.9 Ordre d'implémentation proposé
- Socle :
schema.py,normalize.py(+ tests),db.py,base.py, registre auto-découvrant,run.py,fixtures.py. - 3 premiers connecteurs pilotes : 1 Workday + 1 Lever ou Greenhouse (employeurs québécois à confirmer) + 1 custom HTML — pour valider le socle sur les trois familles.
ingest.py+ expiration + anti-dérive +dedup.py.web.py+ frontend minimal (liste, fiche, filtres, sources, stats).- Vague de connecteurs (choix des employeurs à valider avec Simon-Pierre).
seo.py, géocodage, déploiement m3u96a + ngrok.
Questions ouvertes pour validation
- Liste des premiers employeurs/ATS cibles — as-tu déjà une liste (ex. Desjardins, Hydro-Québec, CGI, gouvernement du Québec via Workday…) ou je propose une première vague de ~20 employeurs québécois par ATS ?
- Port du service sur m3u96a (Lou·Ka occupe 8095 ailleurs ; proposer 8096 ?).
- Le géocodage des lieux de travail est-il prioritaire dès la v1, ou la ville textuelle suffit-elle au lancement (carte en v2) ?
Statut : en attente de validation avant toute implémentation (règle nº 2).