SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
3 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
19.0 KB · 355 lines markdown
Rendered Raw Blame History
1<!--2=============================================================================3Job·Ka — Groupe KA4Auteur  : Simon-Pierre Boucher5Contact : contact@spboucher.ai6Fichier : docs/audit-louka.md7Rôle    : Audit de l'architecture Lou·Ka et plan d'adaptation pour Job·Ka8Créé    : 2026-08-17   Modifié : 2026-08-179=============================================================================10-->1112# Audit Lou·Ka → Plan d'adaptation Job·Ka1314> Livrable de la règle nº 2 du `CLAUDE.md`. Dépôt étudié : `~/Desktop/lou-ka`15> (212 connecteurs, ~18 500 annonces actives, SQLite + FastAPI + React/Vite,16> production PM2 + ngrok). **Aucune implémentation n'a été faite : ce document17> attend la validation de Simon-Pierre avant tout code.**1819---2021## Partie A — Ce que fait Lou·Ka (architecture éprouvée)2223### A.1 Connecteurs : interface, conventions, découverte2425- **Classe de base** `BaseConnector` (`louka/connectors/base.py`), contrat minimal :26  - attributs de classe : `source_id: str`, `request_delay: float = 0.6`,27    `timeout: int = 30`, `use_detail_cache: bool = True` ;28  - une **seule méthode abstraite par convention** (pas d'`abc`) :29    `fetch(self) -> list[Listing]` ;30  - helpers fournis : `get()`/`post()` (session `requests` throttlée +31    `raise_for_status`), `get_rendered()` (Firecrawl), `scrapfly()`/`get_scrapfly()`32    (rendu JS + anti-bot), `detail()` (cache BD des pages détail).33- **Conventions** : un fichier = un connecteur = un `source_id` ; fichier34  `snake_case.py` → classe `CamelCaseConnector` → `source_id` = nom du fichier.35  Bannière commentée en tête de fichier (site, plateforme, particularités,36  robots.txt).37- **Découverte automatique** (`connectors/__init__.py`) : `pkgutil.iter_modules`38  + `importlib` remplissent le dict `CONNECTORS` avec toute sous-classe de39  `BaseConnector` ayant un `source_id`. Un module qui plante à l'import est40  **ignoré avec un avertissement** — jamais de liste à maintenir à la main.41- **Plateformes partagées** : une classe de plateforme expose des constantes42  (`BASE`, `LIST_PATH`, `CITY_DEFAULT`…) et des *crochets* surchargeables43  (`_parse_card`, `_fetch_detail`…) ; un client de la plateforme = une44  sous-classe de ~15 lignes (ex. `DynamicConnector` → `AgenceSherbrookeConnector`).45- **Registre descriptif parallèle** `data/sources.json`46  (`id, name, url, listing_url, sectors, connector, status, region`) — alimente47  la page publique « Sources », pas la découverte.4849### A.2 Schéma normalisé et pipeline de normalisation5051- **`@dataclass Listing`** (`louka/schema.py`) — stdlib, pas de Pydantic.52  Champs requis : `source`, `external_id`, `url`. Dérivés :53  `uid = "{source}:{external_id}"` (clé primaire), `content_hash()` (sha256 du54  dict trié → moteur de diff), `finalize()` (normalisation commune, idempotente,55  **n'écrase jamais une valeur explicite du connecteur**).56- **`louka/normalize.py`** centralise : `parse_price` (exige `$` adjacent,57  bornes de plausibilité, retourne le min d'une fourchette),58  `parse_availability_date` (FR + EN → ISO, **jamais de date inventée**),59  `parse_area_sqft`, `normalize_unit_type`, `extract_details` (~20 règles à60  motifs positifs/négatifs, **dict sparse : clé absente = inconnu, jamais de61  `false` deviné**), `clean_address`, `strip_accents`.62- Nettoyage HTML à trois niveaux : `BeautifulSoup.get_text()` dans le63  connecteur, helpers partagés `_detailutil.py` (JSON-LD, aplatissement,64  coordonnées Google Maps), et `textmine.py` (analyse regex FR déterministe de65  la description → digest structuré ; aucun LLM).66- Garde-fou géographique dans `finalize()` : coordonnées hors Québec rejetées.6768### A.3 Erreurs, retries, timeouts, throttling6970- **Pas de retry HTTP générique** (ni tenacity ni urllib3.Retry) : la71  robustesse vient de `try/except` par source + par annonce, et de la reprise72  au cycle suivant. Philosophie : une panne coûte une heure, pas des données.73- **Throttling par source** : `request_delay` (0.6 s défaut, jusqu'à 20 s quand74  le robots.txt exige un `Crawl-delay`), appliqué dans `get()/post()`.75- **Budgets de requêtes** plutôt que rate limit externe : `max_pages`,76  `max_details`, plafonds surchargés par variables d'env77  (`LOUKA_<PORTAIL>_<LIMITE>`), exceptions sentinelles locales.78- **User-Agent identifiable** unique, déclaré une fois dans `base.py`79  (`LouKaBot/1.0 (+https://www.lou-ka.com/bot; contact@spboucher.ai)`).80- **Timeouts** : 30 s défaut, surchargables par classe ; Firecrawl 90-150 s,81  Scrapfly 180 s.82- Scrapfly / Firecrawl = seule stratégie anti-bot et rendu JS ; aucun proxy83  maison ; tout est `requests` synchrone.8485### A.4 Scheduler / orchestration8687- **Boucle maison**, pas d'APScheduler ni cron : `run.py watch 60` →88  `ingest.watch(3600)` = `while True: run(); sleep(3600)`.89- Cycle strictement séquentiel : **ingestion → géocodage en lot → POI →90  quartier → déduplication** (la dédup vient après le géocodage car les91  coordonnées alimentent le blocage). Étapes 2-5 chacune dans un `try/except`92  non bloquant.93- Aucun parallélisme entre connecteurs (choix délibéré de politesse) ;94  `POST /api/sync` déclenche un cycle sous `threading.Lock`.95- CLI unique `run.py` : `sync | watch | serve | geocode | record …`,96  dispatch manuel sur `sys.argv`, chargement `.env` maison, imports paresseux.9798### A.5 Déduplication inter-sources99100`louka/dedup.py` — **clé exacte + union-find, aucun fuzzy** :1011. Blocage par clé d'adresse normalisée (`civique|mots-de-rue|ville`, sans102   accents ni types de voie) — la proximité GPS a été **abandonnée** (fusionnait103   des immeubles voisins).1042. Confirmation d'une paire : même clé obligatoire + aucun signal contradictoire105   (unité, type, prix ±4 %) + **au moins un second signal concordant**.1063. Union-find avec contrainte : jamais deux annonces de la même source dans une107   composante ; jamais de dédup intra-source.1084. Canonique choisi par **autorité de la source** (site direct < portail <109   petites annonces) puis richesse du contenu. Les doublons sont **masqués**110   (`dup_of`), jamais supprimés ; recalcul intégral idempotent à chaque cycle.111112### A.6 Expiration des annonces113114- Marquage **vu/non-vu par cycle** : `last_seen`, `miss_count` ; non vue 2 cycles115  consécutifs (`MISS_GRACE = 2`) → `active = 0`. **Jamais de `DELETE`.**116- **Garde-fou anti-dérive** (`_drift_alert`) : si une source retourne ≤ 25 % de117  sa médiane des 5 dernières syncs OK, ou si le taux de prix nuls explose →118  alerte consignée et **retraits suspendus** (un connecteur cassé ne vide pas119  la base).120- SEO : annonce retirée → HTTP 410 Gone sur sa page.121122### A.7 Stockage et exposition123124- **SQLite unique** (`data/louka.db`, `sqlite3` stdlib, pas d'ORM). Schéma125  idempotent exécuté à chaque `connect()` ; **migrations maison** par126  `PRAGMA table_info` + `ALTER TABLE ADD COLUMN` (dict `_MIGRATIONS`).127- Tables clés : `listings` (uid PK, colonnes du dataclass + cycle de vie),128  `sync_log` (monitoring), `price_log` (historique), `geocode_cache`,129  `detail_cache`, `poi_cache`, comptes/favoris.130- Index : source, city, active, `(lat, lng)` pour les bbox de la carte.131- **FastAPI** (`louka/web.py`) : `/api/listings` (filtres + pagination),132  `/api/listings.geojson` (bbox), `/api/listings/{uid}` (fiche enrichie),133  `/api/facets`, `/api/sources`, `/api/stats[.detailed]`, PDF, `POST /api/sync`.134  CORS ouvert + GZip. Frontend React 18 + Vite + TS servi en statique par135  FastAPI (catch-all avec vraies 404), SSR léger + sitemaps dans `seo.py`.136- Géocodage : **Nominatim (1,1 s/req)** + repli **Adresses Québec (lot de 200)**,137  cache SQLite avec mémorisation des échecs (retente après 30 j), validation138  par bounding box, granularité par immeuble. Jamais de coordonnées inventées.139140### A.8 Logging, monitoring, tests141142- Pas de module `logging` : `print("[lou-ka] …")` capté par PM2. La vraie143  télémétrie est en base : `sync_log(source, ts, found, added, updated,144  removed, ok, message, stats JSON)` → exposée par `/api/stats`145  (20 dernières syncs) et `/api/sources` (`last_sync` par source).146- **Fixtures HTTP enregistrées/rejouées** (`louka/fixtures.py`) :147  `run.py record <source>` capture les requêtes réelles (adapter monté sur la148  session, secrets caviardés) ; `pytest` rejoue **hors ligne** et compare à un149  instantané déterministe + invariants (uid uniques, URLs absolues, bornes de150  prix/superficie, pas de valeurs inventées). 193 sources couvertes.151- Une **fiche Markdown par connecteur** dans `reports/connectors/<source>.md`152  (méthode, couverture des champs en %, fragilités, échantillon).153- Production : 3 processus PM2 — `lou-ka-web` (serve 8095), `lou-ka-sync`154  (watch 60), `lou-ka-ngrok` (`ngrok http --url=www.lou-ka.com 8095`).155  Philosophie : « on ne pousse que le code — le serveur maintient ses données156  lui-même » (`data/*.db`, `dist/`, `.env` jamais versionnés).157158---159160## Partie B — Plan d'adaptation pour Job·Ka161162Principe directeur : **reprendre l'architecture de Lou·Ka telle quelle** —163paquet `jobka/`, mêmes mécanismes (registre auto-découvrant, diff par164`content_hash`, `miss_count` + anti-dérive, fixtures rejouables, SQLite,165FastAPI, `run.py`, PM2 + ngrok) — et n'adapter que ce qui est propre au166domaine de l'emploi.167168### B.1 Ce qui se transpose tel quel (≈ 80 %)169170| Brique Lou·Ka | Job·Ka |171|---|---|172| `BaseConnector` + registre auto-découvrant | Identique (`jobka/connectors/`) |173| `finalize()` + `normalize.py` | Identique dans l'esprit ; parseurs adaptés (salaires, dates limites) |174| Diff `content_hash` + `sync_log` + anti-dérive | Identique — critique pour l'emploi (offres qui disparaissent vite) |175| Expiration `miss_count`/`MISS_GRACE=2` + `active=0` | Identique + prise en compte de `date_limite` dépassée |176| Fixtures record/replay + tests d'invariants | Identique (`run.py record`, pytest hors ligne) |177| SQLite + migrations maison + FastAPI + frontend Vite | Identique |178| Géocodage Nominatim + Adresses Québec + cache | **Réutilisé tel quel** (le lieu de travail se géocode comme une adresse) |179| CLI `run.py` (sync/watch/serve/record) | Identique |180| Déploiement 3 processus PM2 + ngrok | Identique, domaine `www.job-ka.com`, nœud `m3u96a` |181| Fiches `reports/connectors/<source>.md` | Identique |182183### B.2 Schéma `JobPosting` (adaptation du dataclass `Listing`)184185Mêmes conventions (champs requis `source`, `external_id`, `url` ; `uid`,186`content_hash()`, `finalize()`), champs métier du `CLAUDE.md` §4 mappés au187style Lou·Ka (identifiants en anglais) :188189```python190@dataclass191class JobPosting:192    source: str            # id du connecteur (= employeur ou ATS)   [id_source]193    external_id: str194    url: str               # lien direct vers l'offre — zéro boîte noire195    employer: str = ""     # [employeur]196    title: str = ""197    description: str = ""  # HTML nettoyé198    address: str = ""      # lieu de travail si dispo199    city: str = ""200    region: str = ""201    postal_code: str = ""202    work_mode: str | None = None      # presentiel | hybride | teletravail203    employment_type: str | None = None # temps_plein | partiel | contractuel | stage | saisonnier204    salary_min: float | None = None   # nombres, jamais des strings205    salary_max: float | None = None206    salary_unit: str | None = None    # "hour" | "year" (+ conversions dérivées)207    benefits: list[str] = field(default_factory=list)208    requirements: dict = field(default_factory=dict)  # scolarité, expérience, langues209    date_posted: str | None = None    # ISO 8601210    date_deadline: str | None = None  # ISO 8601211    category: str = ""                # taxonomie interne (TI, santé, construction…)212    ats: str = ""                     # workday | lever | greenhouse | custom…213    details: dict = field(default_factory=dict)   # sparse, comme Lou·Ka214    lat: float | None = None215    lng: float | None = None216```217218Normalisation spécifique emploi (équivalents de `parse_price`/`parse_area_sqft`) :219- `parse_salary(raw)` → `(min, max, unit)` ; bornes de plausibilité220  (~15–200 $/h, ~20 000–500 000 $/an) ; conversion $/h ↔ $/an221  (base 40 h × 52 sem.) stockée en champs dérivés — la **transparence salariale**222  est le différenciateur clé, jamais de salaire inventé ;223- `parse_work_mode`, `parse_employment_type` : tables de mots-clés FR/EN224  (comme `_UNIT_WORDS`) ;225- `parse_date` : réutilise `parse_availability_date` (FR + EN → ISO) ;226- `categorize(title, description)` : taxonomie par mots-clés déterministes227  (approche `textmine.py`, pas de LLM au départ).228229### B.3 Connecteurs : l'avantage ATS230231Différence favorable vs l'immobilier : une grande part des employeurs québécois232passe par un **ATS mutualisé** avec API JSON publique et stable. Une classe de233plateforme par ATS (pattern `DynamicConnector`/`LaCourConnector`) donne des234connecteurs-clients de ~10 lignes :235236| Classe de plateforme | Méthode | Coût par employeur |237|---|---|---|238| `WorkdayConnector` | API JSON `…/wday/cxs/<tenant>/<site>/jobs` (POST paginé) | tenant + site |239| `LeverConnector` | `api.lever.co/v0/postings/<org>?mode=json` | slug org |240| `GreenhouseConnector` | `boards-api.greenhouse.io/v1/boards/<org>/jobs?content=true` | slug org |241| `SmartRecruitersConnector` | `api.smartrecruiters.com/v1/companies/<org>/postings` | slug org |242| `BambooHRConnector` | `<org>.bamboohr.com/careers/list` (JSON) | sous-domaine |243| `JazzHRConnector`, `RecruiteeConnector`, `UKGConnector`… | à documenter au fil des sources | — |244| Connecteurs custom (HTML) | `BeautifulSoup`, ou **Scrapfly** si rendu JS / anti-bot | 1 module dédié |245246- **Scrapfly** : méthode `scrapfly()` reprise de `base.py` telle quelle ;247  clé déjà en place dans `.env` (`SCRAPFLY_API_KEY`). Usage réservé aux pages248  carrières à rendu JS ou protégées (rôle qu'avait `fb_marketplace` dans249  Lou·Ka) — les ATS JSON n'en ont pas besoin.250- User-Agent : `JobKaBot/1.0 (+https://www.job-ka.com; contact@spboucher.ai)`251  (CLAUDE.md §5), déclaré une seule fois dans `base.py`.252- Throttling par domaine (`request_delay`), budgets `max_pages`/`max_details`,253  plafonds par env `JOBKA_<SOURCE>_<LIMITE>` : repris à l'identique.254255### B.4 Déduplication adaptée à l'emploi256257Le blocage par adresse civique ne se transpose pas (une offre n'a pas toujours258d'adresse précise). Même squelette (blocage + confirmation + union-find +259autorité), clés adaptées :260- **Blocage** : `titre normalisé (sans accents, stopwords RH retirés) | employeur normalisé | ville` ;261- **Confirmation** : refus si signaux contradictoires (type d'emploi différent,262  salaires connus s'écartant de > 4 %) + exigence d'un second signal concordant263  (même date de publication ±7 j, même salaire, même code postal) ;264- **Autorité** : page carrière de l'employeur (0) < ATS mutualisé (10) —265  pertinent le jour où des agrégats s'ajoutent ; masquage `dup_of`, jamais de266  suppression ; contrainte « jamais deux offres de la même source dans une267  composante » conservée.268269### B.5 Expiration spécifique emploi270271- `miss_count` + `MISS_GRACE = 2` + anti-dérive : repris tels quels ;272- règle additionnelle : `date_deadline` dépassée → `active = 0` même si l'offre273  est encore en ligne (offres zombies fréquentes sur les pages carrières) ;274- offre retirée → HTTP 410 sur sa page publique (pattern `seo.py`).275276### B.6 Structure de dépôt cible277278```279job-ka/280├── run.py                  # sync | watch | serve | geocode | record281├── requirements.txt        # fastapi, uvicorn, requests, beautifulsoup4282├── jobka/283│   ├── schema.py           # JobPosting (dataclass) + finalize()284│   ├── normalize.py        # salaires, dates, mode/type d'emploi, catégories285│   ├── db.py               # SQLite, sync_log, anti-dérive, migrations286│   ├── ingest.py           # run() / watch()287│   ├── dedup.py            # blocage titre|employeur|ville + union-find288│   ├── geocode.py          # Nominatim + Adresses Québec + cache (repris)289│   ├── web.py              # FastAPI : /api/jobs, /api/facets, /api/sources, /api/stats290│   ├── seo.py              # SSR léger + sitemaps + 410291│   ├── fixtures.py         # record/replay hors ligne292│   └── connectors/293│       ├── base.py         # BaseConnector (JobKaBot UA, scrapfly(), detail())294│       ├── _atsutil.py     # helpers partagés (équiv. _detailutil)295│       ├── workday.py, lever.py, greenhouse.py, …   # classes de plateforme296│       └── <employeur>.py  # 1 fichier = 1 source297├── data/sources.json       # registre descriptif des employeurs298├── frontend/               # React + Vite + TS, servi par FastAPI299├── tests/                  # test_connectors (fixtures), test_normalize300├── reports/connectors/     # 1 fiche .md par connecteur301└── docs/302```303304### B.7 Déploiement (CLAUDE.md §6)3053063 processus PM2 sur **m3u96a**, calqués sur Lou·Ka :307308```309job-ka-web    .venv/bin/python run.py serve <port>   # API + SSR + frontend310job-ka-sync   .venv/bin/python run.py watch 60       # resync horaire, jour et nuit311job-ka-ngrok  ngrok http --url=www.job-ka.com <port>312```313314Vérifications post-déploiement : `https://www.job-ka.com` répond,315`/api/sources` montre des `last_sync` récents, `sync_log` sans alerte.316Jamais d'admin/BD exposé par le tunnel. Enregistrement dans317`~/Desktop/cluster-skill/cluster-deployments.json`. À noter : m3u96a est318normalement réservé au staging/calcul — dérogation explicite du CLAUDE.md §6.319320### B.8 Écarts assumés vs Lou·Ka (améliorations légères)3213221. **Endpoint `/health`** ajouté (Lou·Ka n'en a pas ; le CLAUDE.md de Job·Ka323   exige un healthcheck au déploiement).3242. `dedup.run()` aussi appelé en fin de `run.py sync` (bogue mineur relevé chez325   Lou·Ka : la dédup ne tourne que dans `watch`).3263. Réinitialiser `geocode_failed` quand l'adresse d'une offre change (second327   bogue mineur relevé chez Lou·Ka).3284. En-tête d'auteur Job·Ka (règle nº 1) sur chaque fichier + hook pre-commit de329   vérification (section 17 du CLAUDE.md).330331### B.9 Ordre d'implémentation proposé3323331. **Socle** : `schema.py`, `normalize.py` (+ tests), `db.py`, `base.py`,334   registre auto-découvrant, `run.py`, `fixtures.py`.3352. **3 premiers connecteurs pilotes** : 1 Workday + 1 Lever ou Greenhouse336   (employeurs québécois à confirmer) + 1 custom HTML — pour valider le socle337   sur les trois familles.3383. `ingest.py` + expiration + anti-dérive + `dedup.py`.3394. `web.py` + frontend minimal (liste, fiche, filtres, sources, stats).3405. Vague de connecteurs (choix des employeurs à valider avec Simon-Pierre).3416. `seo.py`, géocodage, déploiement m3u96a + ngrok.342343---344345## Questions ouvertes pour validation3463471. **Liste des premiers employeurs/ATS cibles** — as-tu déjà une liste348   (ex. Desjardins, Hydro-Québec, CGI, gouvernement du Québec via Workday…) ou349   je propose une première vague de ~20 employeurs québécois par ATS ?3502. **Port du service** sur m3u96a (Lou·Ka occupe 8095 ailleurs ; proposer 8096 ?).3513. Le géocodage des lieux de travail est-il prioritaire dès la v1, ou la ville352   textuelle suffit-elle au lancement (carte en v2) ?353354**Statut : en attente de validation avant toute implémentation (règle nº 2).**355