# 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()` (session `requests` throttlée + `raise_for_status`), `get_rendered()` (Firecrawl), `scrapfly()`/`get_scrapfly()` (rendu JS + anti-bot), `detail()` (cache BD des pages détail). - **Conventions** : un fichier = un connecteur = un `source_id` ; fichier `snake_case.py` → classe `CamelCaseConnector` → `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_modules` + `importlib` remplissent le dict `CONNECTORS` avec toute sous-classe de `BaseConnector` ayant un `source_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.py`** centralise : `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 de `false` deviné**), `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), et `textmine.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/except` par 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 un `Crawl-delay`), appliqué dans `get()/post()`. - **Budgets de requêtes** plutôt que rate limit externe : `max_pages`, `max_details`, plafonds surchargés par variables d'env (`LOUKA__`), 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 `requests` synchrone. ### 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/except` non bloquant. - Aucun parallélisme entre connecteurs (choix délibéré de politesse) ; `POST /api/sync` déclenche un cycle sous `threading.Lock`. - CLI unique `run.py` : `sync | watch | serve | geocode | record …`, dispatch manuel sur `sys.argv`, chargement `.env` maison, imports paresseux. ### A.5 Déduplication inter-sources `louka/dedup.py` — **clé exacte + union-find, aucun fuzzy** : 1. 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). 2. Confirmation d'une paire : même clé obligatoire + aucun signal contradictoire (unité, type, prix ±4 %) + **au moins un second signal concordant**. 3. Union-find avec contrainte : jamais deux annonces de la même source dans une composante ; jamais de dédup intra-source. 4. 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 de `DELETE`.** - **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`, `sqlite3` stdlib, pas d'ORM). Schéma idempotent exécuté à chaque `connect()` ; **migrations maison** par `PRAGMA 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 dans `seo.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_sync` par source). - **Fixtures HTTP enregistrées/rejouées** (`louka/fixtures.py`) : `run.py record ` capture les requêtes réelles (adapter monté sur la session, secrets caviardés) ; `pytest` rejoue **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/.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/`, `.env` jamais 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/.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) : ```python @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 = None ``` Normalisation 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éutilise `parse_availability_date` (FR + EN → ISO) ; - `categorize(title, description)` : taxonomie par mots-clés déterministes (approche `textmine.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///jobs` (POST paginé) | tenant + site | | `LeverConnector` | `api.lever.co/v0/postings/?mode=json` | slug org | | `GreenhouseConnector` | `boards-api.greenhouse.io/v1/boards//jobs?content=true` | slug org | | `SmartRecruitersConnector` | `api.smartrecruiters.com/v1/companies//postings` | slug org | | `BambooHRConnector` | `.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 de `base.py` telle 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'avait `fb_marketplace` dans 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 dans `base.py`. - Throttling par domaine (`request_delay`), budgets `max_pages`/`max_details`, plafonds par env `JOBKA__` : 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_deadline` dépassée → `active = 0` mê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 │ └── .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 # 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 ``` 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) 1. **Endpoint `/health`** ajouté (Lou·Ka n'en a pas ; le CLAUDE.md de Job·Ka exige un healthcheck au déploiement). 2. `dedup.run()` aussi appelé en fin de `run.py sync` (bogue mineur relevé chez Lou·Ka : la dédup ne tourne que dans `watch`). 3. Réinitialiser `geocode_failed` quand l'adresse d'une offre change (second bogue mineur relevé chez Lou·Ka). 4. 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é 1. **Socle** : `schema.py`, `normalize.py` (+ tests), `db.py`, `base.py`, registre auto-découvrant, `run.py`, `fixtures.py`. 2. **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. 3. `ingest.py` + expiration + anti-dérive + `dedup.py`. 4. `web.py` + frontend minimal (liste, fiche, filtres, sources, stats). 5. Vague de connecteurs (choix des employeurs à valider avec Simon-Pierre). 6. `seo.py`, géocodage, déploiement m3u96a + ngrok. --- ## Questions ouvertes pour validation 1. **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 ? 2. **Port du service** sur m3u96a (Lou·Ka occupe 8095 ailleurs ; proposer 8096 ?). 3. 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).**