SPB Git forge

spb/job-ka

Public
229commits 1branches 0releases
38.1 MBsize
maindefault branch
1 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%
19.0 KB

# 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_<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 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 <source> 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/<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/, .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/<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) :

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/<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 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_<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_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

text
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 :

text
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)

  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).