Fondation Job·Ka : socle backend, 5 connecteurs pilotes, frontend, tests
- Schéma JobPosting + normalisation (salaires $/h↔$/an, dates ISO, villes canoniques, catégories) — 25 tests unitaires - Pipeline hérité de Lou·Ka : diff par content_hash, délai de grâce, anti-dérive, expiration sur date limite, dédup union-find, géocodage Nominatim + Adresses Québec avec cache - Plateformes ATS : Workday (facette Québec auto-découverte), Lever, Greenhouse, SmartRecruiters — connecteurs pilotes CAE, Desjardins, Poka, Mistplay, Ubisoft (442 offres QC) - API FastAPI (/api/jobs, geojson, facets, sources, stats, /health) + frontend React/Vite (liste, fiche, carte MapLibre, stats) - Fixtures HTTP enregistrées/rejouées hors ligne + hook pre-commit d'en-tête d'auteur (section 17) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
54 changed files +7,657 −0
added
.env.example
+11 −0
@@ -0,0 +1,11 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : .env.example | |
| 6 | +# Rôle : Gabarit des variables d'environnement (copier vers .env) | |
| 7 | +# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 8 | +# ============================================================================= | |
| 9 | + | |
| 10 | +# Clé API Scrapfly (scraping des pages carrières protégées / rendu JS) | |
| 11 | +SCRAPFLY_API_KEY=scp-live-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx | |
added
.gitignore
+30 −0
@@ -0,0 +1,30 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : .gitignore | |
| 6 | +# Rôle : Exclusions git (secrets, caches, artefacts de build) | |
| 7 | +# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 8 | +# ============================================================================= | |
| 9 | + | |
| 10 | +# Secrets — jamais commités | |
| 11 | +.env | |
| 12 | + | |
| 13 | +# Python | |
| 14 | +__pycache__/ | |
| 15 | +*.py[cod] | |
| 16 | +.venv/ | |
| 17 | +.pytest_cache/ | |
| 18 | + | |
| 19 | +# Node / frontend | |
| 20 | +node_modules/ | |
| 21 | +dist/ | |
| 22 | +*.tsbuildinfo | |
| 23 | + | |
| 24 | +# macOS | |
| 25 | +.DS_Store | |
| 26 | + | |
| 27 | +# Données locales et rapports générés | |
| 28 | +data/*.db | |
| 29 | +data/*.sqlite* | |
| 30 | +data/cache/ | |
added
CLAUDE.md
+159 −0
@@ -0,0 +1,159 @@ | ||
| 1 | +# CLAUDE.md — Job·Ka | |
| 2 | + | |
| 3 | +> Agrégateur d'offres d'emploi québécoises du **Groupe KA** (groupe-ka.com). | |
| 4 | +> Fichier d'instructions pour Claude Code. Lis-le en entier avant toute action. | |
| 5 | + | |
| 6 | +--- | |
| 7 | + | |
| 8 | +## Règle nº 1 — En-tête d'auteur OBLIGATOIRE | |
| 9 | + | |
| 10 | +**Chaque fichier créé ou modifié** (code, script, config, doc, migration, test — sans exception) doit commencer par l'en-tête d'auteur suivant, adapté à la syntaxe de commentaire du langage : | |
| 11 | + | |
| 12 | +```python | |
| 13 | +# ============================================================================= | |
| 14 | +# Job·Ka — Groupe KA | |
| 15 | +# Auteur : Simon-Pierre Boucher | |
| 16 | +# Contact : contact@spboucher.ai | |
| 17 | +# Fichier : <chemin/nom_du_fichier> | |
| 18 | +# Rôle : <description en une ligne> | |
| 19 | +# Créé : <YYYY-MM-DD> Modifié : <YYYY-MM-DD> | |
| 20 | +# ============================================================================= | |
| 21 | +``` | |
| 22 | + | |
| 23 | +```javascript | |
| 24 | +/** | |
| 25 | + * ============================================================================= | |
| 26 | + * Job·Ka — Groupe KA | |
| 27 | + * Auteur : Simon-Pierre Boucher | |
| 28 | + * Contact : contact@spboucher.ai | |
| 29 | + * Fichier : <chemin/nom_du_fichier> | |
| 30 | + * Rôle : <description en une ligne> | |
| 31 | + * Créé : <YYYY-MM-DD> Modifié : <YYYY-MM-DD> | |
| 32 | + * ============================================================================= | |
| 33 | + */ | |
| 34 | +``` | |
| 35 | + | |
| 36 | +- Aucun fichier sans cet en-tête ne doit être commité. Point final. | |
| 37 | +- Si tu modifies un fichier existant sans en-tête : ajoute-le. | |
| 38 | +- Si tu modifies un fichier avec en-tête : mets à jour la date `Modifié`. | |
| 39 | +- Voir la **section 17** pour la politique complète d'attribution. | |
| 40 | + | |
| 41 | +--- | |
| 42 | + | |
| 43 | +## 2. Contexte du projet | |
| 44 | + | |
| 45 | +**Job·Ka** agrège les offres d'emploi **directement depuis les pages carrières des employeurs québécois** (pas depuis Indeed/Jobillico). Même philosophie que les autres plateformes du Groupe KA (Lou·Ka, Immo·Ka, Vrai-Prix, ValoPlex, Auto·Ka, Fabri·Ka, Food·Ka) : | |
| 46 | + | |
| 47 | +1. **Zéro boîte noire** — chaque offre est traçable à sa source, avec URL originale. | |
| 48 | +2. **Données réelles à la source** — connecteurs automatisés, aucune saisie manuelle. | |
| 49 | +3. **Indépendance** — aucun employeur ne paie pour être mis en avant. | |
| 50 | + | |
| 51 | +Architecture cible : **un connecteur = un employeur** (ou une plateforme ATS partagée : Workday, Lever, Greenhouse, SmartRecruiters, BambooHR, etc.), un scheduler qui resynchronise jour et nuit, une normalisation vers un schéma unifié. | |
| 52 | + | |
| 53 | +--- | |
| 54 | + | |
| 55 | +## 3. Règle nº 2 — Étudier le dépôt Lou·Ka AVANT tout connecteur | |
| 56 | + | |
| 57 | +**Avant d'écrire ou de modifier le moindre connecteur**, tu dois explorer le dépôt Lou·Ka et produire un résumé de ce que tu as compris. Job·Ka doit réutiliser les patterns éprouvés de Lou·Ka, pas les réinventer. | |
| 58 | + | |
| 59 | +À étudier obligatoirement dans Lou·Ka : | |
| 60 | + | |
| 61 | +- [ ] Structure d'un connecteur : interface commune, classe de base, conventions de nommage | |
| 62 | +- [ ] Schéma de données normalisé et pipeline de normalisation | |
| 63 | +- [ ] Gestion des erreurs, retries, timeouts, rate limiting par source | |
| 64 | +- [ ] Scheduler / orchestration des resynchronisations | |
| 65 | +- [ ] Déduplication des annonces entre sources | |
| 66 | +- [ ] Détection et retrait des annonces expirées | |
| 67 | +- [ ] Stockage (BD, index, cache) et exposition API/frontend | |
| 68 | +- [ ] Logging et monitoring des connecteurs (taux de succès, fraîcheur) | |
| 69 | + | |
| 70 | +**Livrable avant de coder :** un rapport `docs/audit-louka.md` (avec en-tête d'auteur, règle nº 1) résumant l'architecture de Lou·Ka et le plan d'adaptation pour Job·Ka. Attends la validation de Simon-Pierre avant d'implémenter. | |
| 71 | + | |
| 72 | +--- | |
| 73 | + | |
| 74 | +## 4. Schéma de données cible (offre d'emploi) | |
| 75 | + | |
| 76 | +Adapte les noms au style hérité de Lou·Ka, mais chaque connecteur doit tenter d'extraire : | |
| 77 | + | |
| 78 | +- `id_source` — identifiant unique chez l'employeur/ATS | |
| 79 | +- `employeur` — nom de l'entreprise | |
| 80 | +- `url_offre` — lien direct vers l'offre originale (obligatoire, zéro boîte noire) | |
| 81 | +- `titre` — titre du poste | |
| 82 | +- `description` — texte complet (HTML nettoyé) | |
| 83 | +- `lieu` — ville, région, code postal si dispo | |
| 84 | +- `mode_travail` — présentiel / hybride / télétravail | |
| 85 | +- `type_emploi` — temps plein / partiel / contractuel / stage / saisonnier | |
| 86 | +- `salaire_min`, `salaire_max`, `salaire_unite` — nombres, pas des strings (transparence salariale = différenciateur clé) | |
| 87 | +- `avantages` — liste si disponible | |
| 88 | +- `exigences` — scolarité, années d'expérience, langues | |
| 89 | +- `date_publication`, `date_limite` — dates ISO normalisées | |
| 90 | +- `categorie` — taxonomie interne (TI, santé, construction, etc.) | |
| 91 | +- `ats` — plateforme source (workday, lever, custom, etc.) | |
| 92 | +- `scrape_timestamp` — horodatage de la collecte | |
| 93 | + | |
| 94 | +Règles de normalisation : dates en ISO 8601, salaires convertis en $/h et $/an, géocodage adresse → lat/lng (réutiliser le pipeline Nominatim + cache de Lou·Ka, 1 req/s max). | |
| 95 | + | |
| 96 | +--- | |
| 97 | + | |
| 98 | +## 5. Conventions de développement | |
| 99 | + | |
| 100 | +- Langue du code : anglais pour les identifiants, français pour la doc et les commentaires métier. | |
| 101 | +- Un connecteur = un module isolé, testable seul (`make test-connector NAME=x`). | |
| 102 | +- Jamais de scraping agressif : respecter robots.txt quand raisonnable, throttling par domaine, User-Agent identifiable `JobKaBot/1.0 (+https://www.job-ka.com; contact@spboucher.ai)`. | |
| 103 | +- Aucun secret en dur dans le code : `.env` uniquement, jamais commité. | |
| 104 | +- Chaque PR/commit : message clair en français, en-tête d'auteur vérifié (règle nº 1). | |
| 105 | +- Avant de coder une fonctionnalité : présenter le plan et attendre validation. | |
| 106 | + | |
| 107 | +--- | |
| 108 | + | |
| 109 | +## 6. Déploiement — node m3u96a + ngrok | |
| 110 | + | |
| 111 | +**Environnement de déploiement :** | |
| 112 | + | |
| 113 | +- Nœud cible : **`m3u96a`** | |
| 114 | +- Exposition publique : **ngrok** avec le domaine réservé **`www.job-ka.com`** | |
| 115 | + | |
| 116 | +Procédure attendue : | |
| 117 | + | |
| 118 | +```bash | |
| 119 | +# 1. Sur le node m3u96a — build et démarrage du service | |
| 120 | +# (adapter au stack hérité de Lou·Ka : pm2, systemd, docker compose…) | |
| 121 | + | |
| 122 | +# 2. Tunnel ngrok vers le port de l'app (ex. 3000) | |
| 123 | +ngrok http --domain=www.job-ka.com 3000 | |
| 124 | +``` | |
| 125 | + | |
| 126 | +Règles : | |
| 127 | + | |
| 128 | +- Le tunnel ngrok doit être supervisé (pm2/systemd) pour redémarrer seul. | |
| 129 | +- Vérifier après chaque déploiement : `https://www.job-ka.com` répond, healthcheck OK, connecteurs planifiés actifs. | |
| 130 | +- Documenter toute variation dans `docs/deploiement.md` (avec en-tête, règle nº 1). | |
| 131 | +- Ne jamais exposer d'interface d'admin ou de BD via le tunnel public. | |
| 132 | + | |
| 133 | +--- | |
| 134 | + | |
| 135 | +## 7–16. Sections réservées | |
| 136 | + | |
| 137 | +Réservées pour les futures politiques (monitoring, SEO, alertes courriel, API publique, etc.). Ne pas supprimer la numérotation : la **section 17** doit rester la section 17. | |
| 138 | + | |
| 139 | +--- | |
| 140 | + | |
| 141 | +## 17. Politique d'attribution d'auteur | |
| 142 | + | |
| 143 | +1. **Auteur unique du projet : Simon-Pierre Boucher** (`contact@spboucher.ai`). Toute contribution générée par Claude est réputée réalisée pour le compte de l'auteur. | |
| 144 | +2. L'en-tête d'auteur (règle nº 1) est **non négociable** : il s'applique aux fichiers de code, scripts, configs, docs, tests, migrations, workflows CI, Dockerfiles et Makefiles. | |
| 145 | +3. Les métadonnées de projet doivent refléter l'auteur : | |
| 146 | + - `package.json` → `"author": "Simon-Pierre Boucher <contact@spboucher.ai>"` | |
| 147 | + - `pyproject.toml` → `authors = [{name = "Simon-Pierre Boucher", email = "contact@spboucher.ai"}]` | |
| 148 | + - `LICENSE` et `README.md` → copyright « © Groupe KA — Simon-Pierre Boucher » | |
| 149 | +4. Configuration git du dépôt : | |
| 150 | + ```bash | |
| 151 | + git config user.name "Simon-Pierre Boucher" | |
| 152 | + git config user.email "contact@spboucher.ai" | |
| 153 | + ``` | |
| 154 | +5. Toute page publique de Job·Ka affiche dans le pied de page : « Une plateforme du Groupe KA » avec le contact `contact@spboucher.ai`. | |
| 155 | +6. **Vérification automatique** : ajouter un hook pre-commit (ou étape CI) qui rejette tout fichier sans l'en-tête d'auteur. Ce hook porte lui-même l'en-tête. | |
| 156 | + | |
| 157 | +--- | |
| 158 | + | |
| 159 | +*Dernière mise à jour : 2026-08-17 — Simon-Pierre Boucher (contact@spboucher.ai)* | |
added
README.md
+281 −0
@@ -0,0 +1,281 @@ | ||
| 1 | +<!-- | |
| 2 | +============================================================================= | |
| 3 | +Job·Ka — Groupe KA | |
| 4 | +Auteur : Simon-Pierre Boucher | |
| 5 | +Contact : contact@spboucher.ai | |
| 6 | +Fichier : README.md | |
| 7 | +Rôle : Documentation principale du projet | |
| 8 | +Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | +============================================================================= | |
| 10 | +--> | |
| 11 | + | |
| 12 | +<div align="center"> | |
| 13 | + | |
| 14 | +# Job·Ka | |
| 15 | + | |
| 16 | +### Tous les emplois des employeurs québécois. Un seul endroit. | |
| 17 | + | |
| 18 | +**[www.job-ka.com](https://www.job-ka.com)** | |
| 19 | + | |
| 20 | +*Job·Ka collecte les offres directement sur les pages carrières des employeurs | |
| 21 | +du Québec — pas sur les job boards. Chaque offre est traçable à sa source, | |
| 22 | +les salaires sont normalisés en $/h et $/an, et la candidature se fait chez | |
| 23 | +l'employeur. Zéro boîte noire, zéro intermédiaire.* | |
| 24 | + | |
| 25 | +</div> | |
| 26 | + | |
| 27 | +--- | |
| 28 | + | |
| 29 | +## Sommaire | |
| 30 | + | |
| 31 | +- [Pourquoi Job·Ka ?](#pourquoi-job-ka-) | |
| 32 | +- [L'architecture en 30 secondes](#larchitecture-en-30-secondes) | |
| 33 | +- [Le pipeline en détail](#le-pipeline-en-détail) | |
| 34 | +- [Structure du dépôt](#structure-du-dépôt) | |
| 35 | +- [Démarrage rapide](#démarrage-rapide) | |
| 36 | +- [CLI `run.py`](#cli-runpy) | |
| 37 | +- [Variables d'environnement](#variables-denvironnement) | |
| 38 | +- [API](#api) | |
| 39 | +- [Ajouter un employeur (≈ 10 lignes)](#ajouter-un-employeur--10-lignes) | |
| 40 | +- [Tests](#tests) | |
| 41 | +- [Production](#production) | |
| 42 | +- [Principes](#principes) | |
| 43 | +- [Écosystème Groupe KA](#écosystème-groupe-ka) | |
| 44 | + | |
| 45 | +--- | |
| 46 | + | |
| 47 | +## Pourquoi Job·Ka ? | |
| 48 | + | |
| 49 | +Les grands sites d'emploi mélangent annonces payantes, doublons repostés et | |
| 50 | +offres mortes. L'offre originale — celle de la page carrière de l'employeur — | |
| 51 | +est la seule source fiable : à jour, complète, sans frais de mise en avant. | |
| 52 | + | |
| 53 | +> Job·Ka se branche directement sur les systèmes de recrutement (ATS) des | |
| 54 | +> employeurs : Workday, Lever, Greenhouse, SmartRecruiters, ou leur site | |
| 55 | +> maison. Un connecteur = un employeur. Le tout resynchronisé jour et nuit. | |
| 56 | + | |
| 57 | +Ce n'est pas un job board qui vend de la visibilité, c'est un index fidèle de | |
| 58 | +ce que les employeurs québécois affichent réellement — **avec les salaires en | |
| 59 | +clair quand ils existent** (normalisés en $/h et $/an), parce que la | |
| 60 | +transparence salariale est le différenciateur clé. | |
| 61 | + | |
| 62 | +## L'architecture en 30 secondes | |
| 63 | + | |
| 64 | +```mermaid | |
| 65 | +flowchart LR | |
| 66 | + subgraph Sources | |
| 67 | + W["Workday"] --> C | |
| 68 | + L["Lever"] --> C | |
| 69 | + G["Greenhouse"] --> C | |
| 70 | + S["SmartRecruiters"] --> C | |
| 71 | + X["Sites maison<br/>(HTML / Scrapfly)"] --> C | |
| 72 | + end | |
| 73 | + C["Connecteurs<br/>(auto-découverts)"] --> N["finalize()<br/>normalisation"] | |
| 74 | + N --> D[("SQLite<br/>data/jobka.db")] | |
| 75 | + W2["⏱ Watcher horaire<br/>(PM2)"] -.-> C | |
| 76 | + D --> A["FastAPI<br/>/api/*"] | |
| 77 | + A --> F["React + Vite<br/>frontend/dist"] | |
| 78 | + D --> GEO["Géocodage<br/>Nominatim + Adresses Québec"] | |
| 79 | + GEO --> D | |
| 80 | +``` | |
| 81 | + | |
| 82 | +| Couche | Rôle | Fichiers | | |
| 83 | +|---|---|---| | |
| 84 | +| Connecteurs | 1 employeur = 1 module ; plateformes ATS partagées | `jobka/connectors/` | | |
| 85 | +| Schéma | `JobPosting` + `finalize()` (normalisation commune) | `jobka/schema.py`, `jobka/normalize.py` | | |
| 86 | +| Persistance | diff par `content_hash`, cycle de vie, anti-dérive | `jobka/db.py` | | |
| 87 | +| Pipeline | ingestion, boucle `watch`, géocodage, dédup | `jobka/ingest.py`, `geocode.py`, `dedup.py` | | |
| 88 | +| API + Web | FastAPI + frontend Vite servi en statique | `jobka/web.py`, `frontend/` | | |
| 89 | +| Tests | fixtures HTTP enregistrées, rejeu hors ligne | `jobka/fixtures.py`, `tests/` | | |
| 90 | + | |
| 91 | +## Le pipeline en détail | |
| 92 | + | |
| 93 | +### Connecteurs auto-découverts | |
| 94 | +Toute sous-classe de `BaseConnector` avec un `source_id` est enregistrée par | |
| 95 | +`pkgutil` — aucun registre à maintenir. Les plateformes ATS (Workday, Lever, | |
| 96 | +Greenhouse, SmartRecruiters) sont des classes de base : brancher un nouvel | |
| 97 | +employeur = ~10 lignes. Politesse : throttling par source (`request_delay`), | |
| 98 | +User-Agent identifiable `JobKaBot/1.0`, budgets de requêtes, cache BD des | |
| 99 | +pages détail. | |
| 100 | + | |
| 101 | +### Diff engine avec garde-fou anti-dérive | |
| 102 | +Chaque sync compare le `content_hash` de chaque offre : ajout, mise à jour, ou | |
| 103 | +disparition. Une offre absente 2 syncs consécutifs passe `active=0` (jamais de | |
| 104 | +DELETE). Si une source retourne ≤ 25 % de sa médiane historique, alerte et | |
| 105 | +**retraits suspendus** — un connecteur cassé ne vide pas la base. Les offres | |
| 106 | +dont la `date_limite` est dépassée sont retirées même si encore affichées. | |
| 107 | + | |
| 108 | +### Salaires normalisés — jamais inventés | |
| 109 | +`parse_salary()` exige un signe monétaire ou une unité explicite, applique des | |
| 110 | +bornes de plausibilité par unité, et convertit en $/h et $/an (40 h × 52 sem.). | |
| 111 | +Une offre sans salaire reste sans salaire. L'historique des changements est | |
| 112 | +conservé (`salary_log`). | |
| 113 | + | |
| 114 | +### Géocodage sans invention | |
| 115 | +Nominatim (1 req/1,1 s) + repli officiel Adresses Québec (MERN, lots de 200), | |
| 116 | +cache persistant par lieu normalisé, validation par bounding box provinciale. | |
| 117 | +Sans adresse : centroïde de ville. Introuvable : `geocode_failed=1`, jamais de | |
| 118 | +coordonnées bidon. | |
| 119 | + | |
| 120 | +### Déduplication inter-sources | |
| 121 | +Blocage par clé exacte `titre normalisé | employeur | ville` + union-find avec | |
| 122 | +exigence d'un second signal concordant (type, salaire ±4 %, date ±7 j). Le | |
| 123 | +canonique est la source la plus autoritaire (page carrière avant agrégat) ; | |
| 124 | +les doublons sont masqués (`dup_of`), jamais supprimés. | |
| 125 | + | |
| 126 | +## Structure du dépôt | |
| 127 | + | |
| 128 | +``` | |
| 129 | +job-ka/ | |
| 130 | +├── run.py # point d'entrée : sync | watch | serve | geocode | record | |
| 131 | +├── requirements.txt # fastapi, uvicorn, requests, beautifulsoup4 | |
| 132 | +├── jobka/ | |
| 133 | +│ ├── schema.py # dataclass JobPosting + finalize() | |
| 134 | +│ ├── normalize.py # salaires, dates, mode/type, lieux, catégories | |
| 135 | +│ ├── db.py # SQLite : diff, cycle de vie, anti-dérive | |
| 136 | +│ ├── ingest.py # run() / watch() — orchestration | |
| 137 | +│ ├── dedup.py # déduplication inter-sources (union-find) | |
| 138 | +│ ├── geocode.py # Nominatim + Adresses Québec + cache | |
| 139 | +│ ├── web.py # API FastAPI + /health + frontend statique | |
| 140 | +│ ├── fixtures.py # enregistrement/rejeu HTTP pour les tests | |
| 141 | +│ └── connectors/ | |
| 142 | +│ ├── base.py # BaseConnector (throttling, Scrapfly, cache détail) | |
| 143 | +│ ├── workday.py # plateforme Workday (facette Québec auto-découverte) | |
| 144 | +│ ├── lever.py # plateforme Lever | |
| 145 | +│ ├── greenhouse.py # plateforme Greenhouse | |
| 146 | +│ ├── smartrecruiters.py # plateforme SmartRecruiters | |
| 147 | +│ └── <employeur>.py # 1 fichier = 1 employeur (sous-classe) | |
| 148 | +├── data/sources.json # registre descriptif des employeurs | |
| 149 | +├── frontend/ # React 18 + Vite + TS (carte MapLibre, sans jeton) | |
| 150 | +├── tests/ # pytest : normalisation + rejeu des connecteurs | |
| 151 | +├── scripts/check_headers.py # hook pre-commit : en-tête d'auteur obligatoire | |
| 152 | +└── docs/ # audit Lou·Ka, déploiement | |
| 153 | +``` | |
| 154 | + | |
| 155 | +## Démarrage rapide | |
| 156 | + | |
| 157 | +```bash | |
| 158 | +git clone <repo> job-ka && cd job-ka | |
| 159 | + | |
| 160 | +# Backend | |
| 161 | +python3 -m venv .venv | |
| 162 | +.venv/bin/pip install -r requirements.txt | |
| 163 | + | |
| 164 | +# Frontend | |
| 165 | +cd frontend && npm install && npm run build && cd .. | |
| 166 | + | |
| 167 | +# Secrets (Scrapfly pour les sites anti-bot — facultatif au départ) | |
| 168 | +cp .env.example .env # puis remplir SCRAPFLY_API_KEY | |
| 169 | + | |
| 170 | +# Amorçage : première synchronisation + géocodage | |
| 171 | +.venv/bin/python run.py sync | |
| 172 | +.venv/bin/python run.py geocode | |
| 173 | + | |
| 174 | +# Service (API + frontend) — http://localhost:8096 | |
| 175 | +.venv/bin/python run.py serve 8096 | |
| 176 | +``` | |
| 177 | + | |
| 178 | +En dev frontend : `npm run dev` dans `frontend/` (proxy `/api` → :8096). | |
| 179 | + | |
| 180 | +## CLI `run.py` | |
| 181 | + | |
| 182 | +| Commande | Rôle | | |
| 183 | +|---|---| | |
| 184 | +| `run.py sync [source ...]` | synchronise les offres (toutes les sources ou celles données) | | |
| 185 | +| `run.py watch [minutes]` | boucle : sync + géocodage + dédup (défaut 60 min) | | |
| 186 | +| `run.py serve [port]` | API + frontend (défaut 8096) | | |
| 187 | +| `run.py geocode [n]` | géocode les offres sans coordonnées | | |
| 188 | +| `run.py record <source ...>` | enregistre les fixtures de test d'une source | | |
| 189 | + | |
| 190 | +## Variables d'environnement | |
| 191 | + | |
| 192 | +| Variable | Rôle | | |
| 193 | +|---|---| | |
| 194 | +| `SCRAPFLY_API_KEY` | rendu JS / anti-bot pour les pages carrières maison | | |
| 195 | +| `JOBKA_WORKDAY_DETAIL_LIMIT` | plafond de pages détail Workday par sync (défaut 300) | | |
| 196 | +| `JOBKA_SR_DETAIL_LIMIT` | plafond de pages détail SmartRecruiters (défaut 300) | | |
| 197 | + | |
| 198 | +## API | |
| 199 | + | |
| 200 | +| Route | Rôle | | |
| 201 | +|---|---| | |
| 202 | +| `GET /health` | healthcheck (BD + fraîcheur des syncs) | | |
| 203 | +| `GET /api/jobs` | recherche : `q, city, category, work_mode, employment_type, salary_min, with_salary, sort, limit, offset` | | |
| 204 | +| `GET /api/jobs.geojson` | offres géolocalisées (carte), `bbox` + mêmes filtres | | |
| 205 | +| `GET /api/jobs/{uid}` | fiche complète + historique salarial | | |
| 206 | +| `GET /api/facets` | valeurs distinctes pour les filtres | | |
| 207 | +| `GET /api/sources` | registre des employeurs + fraîcheur par source | | |
| 208 | +| `GET /api/stats` | totaux, catégories, villes, dernières syncs | | |
| 209 | +| `POST /api/sync` | déclenche une synchronisation (webhook) | | |
| 210 | + | |
| 211 | +## Ajouter un employeur (≈ 10 lignes) | |
| 212 | + | |
| 213 | +```python | |
| 214 | +# jobka/connectors/mon_employeur.py (+ en-tête d'auteur, règle nº 1) | |
| 215 | +from .workday import WorkdayConnector | |
| 216 | + | |
| 217 | +class MonEmployeurConnector(WorkdayConnector): | |
| 218 | + source_id = "mon_employeur" | |
| 219 | + EMPLOYER = "Mon Employeur inc." | |
| 220 | + TENANT = "monemployeur" # monemployeur.wd3.myworkdayjobs.com | |
| 221 | + HOST = "wd3" | |
| 222 | + SITE = "Careers" | |
| 223 | +``` | |
| 224 | + | |
| 225 | +Puis : ajouter l'entrée dans `data/sources.json`, `run.py sync mon_employeur` | |
| 226 | +pour valider, `run.py record mon_employeur` pour figer les fixtures de test. | |
| 227 | + | |
| 228 | +## Tests | |
| 229 | + | |
| 230 | +```bash | |
| 231 | +.venv/bin/python -m pytest # tout | |
| 232 | +.venv/bin/python -m pytest tests/test_normalize.py # normalisation seule | |
| 233 | +``` | |
| 234 | + | |
| 235 | +- `test_normalize.py` — salaires, dates, modes, types, lieux, catégories ; | |
| 236 | +- `test_connectors.py` — rejeu hors ligne des fixtures : instantané exact + | |
| 237 | + invariants (uid uniques, URLs absolues, salaires plausibles, valeurs jamais | |
| 238 | + inventées) + `finalize()` sûr. | |
| 239 | + | |
| 240 | +## Production | |
| 241 | + | |
| 242 | +``` | |
| 243 | +job-ka-web .venv/bin/python run.py serve 8096 # API + frontend | |
| 244 | +job-ka-sync .venv/bin/python run.py watch 60 # resync horaire, jour et nuit | |
| 245 | +job-ka-ngrok ngrok http --url=www.job-ka.com 8096 # tunnel public | |
| 246 | +``` | |
| 247 | + | |
| 248 | +**Philosophie d'exploitation : on ne pousse que le code — le serveur maintient | |
| 249 | +ses données lui-même.** `data/*.db`, `frontend/dist/` et `.env` ne sont jamais | |
| 250 | +versionnés. Détails : `docs/deploiement.md`. | |
| 251 | + | |
| 252 | +## Principes | |
| 253 | + | |
| 254 | +1. **Politesse** — throttling par source, robots.txt respecté, User-Agent | |
| 255 | + identifiable, budgets de requêtes, caches. | |
| 256 | +2. **Fidélité** — les champs reflètent la source ; une donnée absente reste | |
| 257 | + absente, jamais de valeur inventée (salaire, date, coordonnées). | |
| 258 | +3. **Traçabilité** — chaque offre garde son URL originale ; la candidature se | |
| 259 | + fait chez l'employeur. Zéro boîte noire. | |
| 260 | +4. **Robustesse** — une source cassée ne bloque pas les autres et ne vide pas | |
| 261 | + la base (délai de grâce + anti-dérive). | |
| 262 | +5. **Transparence salariale** — salaires numériques normalisés en $/h et | |
| 263 | + $/an ; filtre « salaire affiché » de premier ordre. | |
| 264 | + | |
| 265 | +## Écosystème Groupe KA | |
| 266 | + | |
| 267 | +Job·Ka partage la philosophie et l'architecture des plateformes sœurs : | |
| 268 | +[Lou·Ka](https://www.lou-ka.com) (logements), Immo·Ka, Vrai-Prix, ValoPlex, | |
| 269 | +Auto·Ka, Fabri·Ka, Food·Ka. | |
| 270 | + | |
| 271 | +--- | |
| 272 | + | |
| 273 | +<div align="center"> | |
| 274 | + | |
| 275 | +## Auteur | |
| 276 | + | |
| 277 | +**Simon-Pierre Boucher** — [contact@spboucher.ai](mailto:contact@spboucher.ai) | |
| 278 | + | |
| 279 | +© 2026 Groupe KA — Simon-Pierre Boucher. Tous droits réservés. | |
| 280 | + | |
| 281 | +</div> | |
added
data/sources.json
+60 −0
@@ -0,0 +1,60 @@ | ||
| 1 | +{ | |
| 2 | + "_meta": { | |
| 3 | + "project": "Job·Ka — Groupe KA", | |
| 4 | + "author": "Simon-Pierre Boucher <contact@spboucher.ai>", | |
| 5 | + "role": "Registre descriptif des sources (alimente la page Sources)", | |
| 6 | + "updated": "2026-08-17" | |
| 7 | + }, | |
| 8 | + "sources": [ | |
| 9 | + { | |
| 10 | + "id": "cae", | |
| 11 | + "name": "CAE", | |
| 12 | + "url": "https://www.cae.com", | |
| 13 | + "careers_url": "https://cae.wd3.myworkdayjobs.com/career", | |
| 14 | + "sectors": ["Aéronautique", "Simulation", "TI"], | |
| 15 | + "connector": "workday", | |
| 16 | + "status": "actif", | |
| 17 | + "region": "Montréal" | |
| 18 | + }, | |
| 19 | + { | |
| 20 | + "id": "desjardins", | |
| 21 | + "name": "Mouvement Desjardins", | |
| 22 | + "url": "https://www.desjardins.com", | |
| 23 | + "careers_url": "https://desjardins.wd10.myworkdayjobs.com/Desjardins", | |
| 24 | + "sectors": ["Finance", "Assurance", "TI"], | |
| 25 | + "connector": "workday", | |
| 26 | + "status": "actif", | |
| 27 | + "region": "Lévis / province" | |
| 28 | + }, | |
| 29 | + { | |
| 30 | + "id": "poka", | |
| 31 | + "name": "Poka", | |
| 32 | + "url": "https://www.poka.io", | |
| 33 | + "careers_url": "https://job-boards.greenhouse.io/poka", | |
| 34 | + "sectors": ["TI", "SaaS manufacturier"], | |
| 35 | + "connector": "greenhouse", | |
| 36 | + "status": "actif", | |
| 37 | + "region": "Québec" | |
| 38 | + }, | |
| 39 | + { | |
| 40 | + "id": "mistplay", | |
| 41 | + "name": "Mistplay", | |
| 42 | + "url": "https://www.mistplay.com", | |
| 43 | + "careers_url": "https://jobs.lever.co/mistplay", | |
| 44 | + "sectors": ["TI", "Jeu mobile"], | |
| 45 | + "connector": "lever", | |
| 46 | + "status": "actif", | |
| 47 | + "region": "Montréal" | |
| 48 | + }, | |
| 49 | + { | |
| 50 | + "id": "ubisoft", | |
| 51 | + "name": "Ubisoft", | |
| 52 | + "url": "https://www.ubisoft.com", | |
| 53 | + "careers_url": "https://jobs.smartrecruiters.com/company/Ubisoft2", | |
| 54 | + "sectors": ["Jeux vidéo", "TI", "Création"], | |
| 55 | + "connector": "smartrecruiters", | |
| 56 | + "status": "actif", | |
| 57 | + "region": "Montréal / Québec / Sherbrooke" | |
| 58 | + } | |
| 59 | + ] | |
| 60 | +} | |
added
docs/audit-louka.md
+354 −0
@@ -0,0 +1,354 @@ | ||
| 1 | +<!-- | |
| 2 | +============================================================================= | |
| 3 | +Job·Ka — Groupe KA | |
| 4 | +Auteur : Simon-Pierre Boucher | |
| 5 | +Contact : contact@spboucher.ai | |
| 6 | +Fichier : docs/audit-louka.md | |
| 7 | +Rôle : Audit de l'architecture Lou·Ka et plan d'adaptation pour Job·Ka | |
| 8 | +Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | +============================================================================= | |
| 10 | +--> | |
| 11 | + | |
| 12 | +# Audit Lou·Ka → Plan d'adaptation Job·Ka | |
| 13 | + | |
| 14 | +> 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 document | |
| 17 | +> attend la validation de Simon-Pierre avant tout code.** | |
| 18 | + | |
| 19 | +--- | |
| 20 | + | |
| 21 | +## Partie A — Ce que fait Lou·Ka (architecture éprouvée) | |
| 22 | + | |
| 23 | +### A.1 Connecteurs : interface, conventions, découverte | |
| 24 | + | |
| 25 | +- **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` ; fichier | |
| 34 | + `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 de | |
| 39 | + `BaseConnector` ayant un `source_id`. Un module qui plante à l'import est | |
| 40 | + **ignoré avec un avertissement** — jamais de liste à maintenir à la main. | |
| 41 | +- **Plateformes partagées** : une classe de plateforme expose des constantes | |
| 42 | + (`BASE`, `LIST_PATH`, `CITY_DEFAULT`…) et des *crochets* surchargeables | |
| 43 | + (`_parse_card`, `_fetch_detail`…) ; un client de la plateforme = une | |
| 44 | + 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`) — alimente | |
| 47 | + la page publique « Sources », pas la découverte. | |
| 48 | + | |
| 49 | +### A.2 Schéma normalisé et pipeline de normalisation | |
| 50 | + | |
| 51 | +- **`@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 du | |
| 54 | + 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 de | |
| 61 | + `false` deviné**), `clean_address`, `strip_accents`. | |
| 62 | +- Nettoyage HTML à trois niveaux : `BeautifulSoup.get_text()` dans le | |
| 63 | + connecteur, helpers partagés `_detailutil.py` (JSON-LD, aplatissement, | |
| 64 | + coordonnées Google Maps), et `textmine.py` (analyse regex FR déterministe de | |
| 65 | + la description → digest structuré ; aucun LLM). | |
| 66 | +- Garde-fou géographique dans `finalize()` : coordonnées hors Québec rejetées. | |
| 67 | + | |
| 68 | +### A.3 Erreurs, retries, timeouts, throttling | |
| 69 | + | |
| 70 | +- **Pas de retry HTTP générique** (ni tenacity ni urllib3.Retry) : la | |
| 71 | + robustesse vient de `try/except` par source + par annonce, et de la reprise | |
| 72 | + 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 quand | |
| 74 | + 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'env | |
| 77 | + (`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 proxy | |
| 83 | + maison ; tout est `requests` synchrone. | |
| 84 | + | |
| 85 | +### A.4 Scheduler / orchestration | |
| 86 | + | |
| 87 | +- **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 les | |
| 91 | + 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. | |
| 97 | + | |
| 98 | +### A.5 Déduplication inter-sources | |
| 99 | + | |
| 100 | +`louka/dedup.py` — **clé exacte + union-find, aucun fuzzy** : | |
| 101 | +1. Blocage par clé d'adresse normalisée (`civique|mots-de-rue|ville`, sans | |
| 102 | + accents ni types de voie) — la proximité GPS a été **abandonnée** (fusionnait | |
| 103 | + des immeubles voisins). | |
| 104 | +2. Confirmation d'une paire : même clé obligatoire + aucun signal contradictoire | |
| 105 | + (unité, type, prix ±4 %) + **au moins un second signal concordant**. | |
| 106 | +3. Union-find avec contrainte : jamais deux annonces de la même source dans une | |
| 107 | + composante ; jamais de dédup intra-source. | |
| 108 | +4. 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. | |
| 111 | + | |
| 112 | +### A.6 Expiration des annonces | |
| 113 | + | |
| 114 | +- Marquage **vu/non-vu par cycle** : `last_seen`, `miss_count` ; non vue 2 cycles | |
| 115 | + consécutifs (`MISS_GRACE = 2`) → `active = 0`. **Jamais de `DELETE`.** | |
| 116 | +- **Garde-fou anti-dérive** (`_drift_alert`) : si une source retourne ≤ 25 % de | |
| 117 | + 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 pas | |
| 119 | + la base). | |
| 120 | +- SEO : annonce retirée → HTTP 410 Gone sur sa page. | |
| 121 | + | |
| 122 | +### A.7 Stockage et exposition | |
| 123 | + | |
| 124 | +- **SQLite unique** (`data/louka.db`, `sqlite3` stdlib, pas d'ORM). Schéma | |
| 125 | + idempotent exécuté à chaque `connect()` ; **migrations maison** par | |
| 126 | + `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 par | |
| 135 | + 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), validation | |
| 138 | + par bounding box, granularité par immeuble. Jamais de coordonnées inventées. | |
| 139 | + | |
| 140 | +### A.8 Logging, monitoring, tests | |
| 141 | + | |
| 142 | +- Pas de module `logging` : `print("[lou-ka] …")` capté par PM2. La vraie | |
| 143 | + 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 la | |
| 148 | + session, secrets caviardés) ; `pytest` rejoue **hors ligne** et compare à un | |
| 149 | + instantané déterministe + invariants (uid uniques, URLs absolues, bornes de | |
| 150 | + 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ées | |
| 156 | + lui-même » (`data/*.db`, `dist/`, `.env` jamais versionnés). | |
| 157 | + | |
| 158 | +--- | |
| 159 | + | |
| 160 | +## Partie B — Plan d'adaptation pour Job·Ka | |
| 161 | + | |
| 162 | +Principe directeur : **reprendre l'architecture de Lou·Ka telle quelle** — | |
| 163 | +paquet `jobka/`, mêmes mécanismes (registre auto-découvrant, diff par | |
| 164 | +`content_hash`, `miss_count` + anti-dérive, fixtures rejouables, SQLite, | |
| 165 | +FastAPI, `run.py`, PM2 + ngrok) — et n'adapter que ce qui est propre au | |
| 166 | +domaine de l'emploi. | |
| 167 | + | |
| 168 | +### B.1 Ce qui se transpose tel quel (≈ 80 %) | |
| 169 | + | |
| 170 | +| 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 | | |
| 182 | + | |
| 183 | +### B.2 Schéma `JobPosting` (adaptation du dataclass `Listing`) | |
| 184 | + | |
| 185 | +Mêmes conventions (champs requis `source`, `external_id`, `url` ; `uid`, | |
| 186 | +`content_hash()`, `finalize()`), champs métier du `CLAUDE.md` §4 mappés au | |
| 187 | +style Lou·Ka (identifiants en anglais) : | |
| 188 | + | |
| 189 | +```python | |
| 190 | +@dataclass | |
| 191 | +class JobPosting: | |
| 192 | + source: str # id du connecteur (= employeur ou ATS) [id_source] | |
| 193 | + external_id: str | |
| 194 | + url: str # lien direct vers l'offre — zéro boîte noire | |
| 195 | + employer: str = "" # [employeur] | |
| 196 | + title: str = "" | |
| 197 | + description: str = "" # HTML nettoyé | |
| 198 | + address: str = "" # lieu de travail si dispo | |
| 199 | + city: str = "" | |
| 200 | + region: str = "" | |
| 201 | + postal_code: str = "" | |
| 202 | + work_mode: str | None = None # presentiel | hybride | teletravail | |
| 203 | + employment_type: str | None = None # temps_plein | partiel | contractuel | stage | saisonnier | |
| 204 | + salary_min: float | None = None # nombres, jamais des strings | |
| 205 | + salary_max: float | None = None | |
| 206 | + 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, langues | |
| 209 | + date_posted: str | None = None # ISO 8601 | |
| 210 | + date_deadline: str | None = None # ISO 8601 | |
| 211 | + category: str = "" # taxonomie interne (TI, santé, construction…) | |
| 212 | + ats: str = "" # workday | lever | greenhouse | custom… | |
| 213 | + details: dict = field(default_factory=dict) # sparse, comme Lou·Ka | |
| 214 | + lat: float | None = None | |
| 215 | + lng: float | None = None | |
| 216 | +``` | |
| 217 | + | |
| 218 | +Normalisation 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 ↔ $/an | |
| 221 | + (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/EN | |
| 224 | + (comme `_UNIT_WORDS`) ; | |
| 225 | +- `parse_date` : réutilise `parse_availability_date` (FR + EN → ISO) ; | |
| 226 | +- `categorize(title, description)` : taxonomie par mots-clés déterministes | |
| 227 | + (approche `textmine.py`, pas de LLM au départ). | |
| 228 | + | |
| 229 | +### B.3 Connecteurs : l'avantage ATS | |
| 230 | + | |
| 231 | +Différence favorable vs l'immobilier : une grande part des employeurs québécois | |
| 232 | +passe par un **ATS mutualisé** avec API JSON publique et stable. Une classe de | |
| 233 | +plateforme par ATS (pattern `DynamicConnector`/`LaCourConnector`) donne des | |
| 234 | +connecteurs-clients de ~10 lignes : | |
| 235 | + | |
| 236 | +| 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é | | |
| 245 | + | |
| 246 | +- **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 pages | |
| 248 | + carrières à rendu JS ou protégées (rôle qu'avait `fb_marketplace` dans | |
| 249 | + 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. | |
| 254 | + | |
| 255 | +### B.4 Déduplication adaptée à l'emploi | |
| 256 | + | |
| 257 | +Le blocage par adresse civique ne se transpose pas (une offre n'a pas toujours | |
| 258 | +d'adresse précise). Même squelette (blocage + confirmation + union-find + | |
| 259 | +autorité), 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 concordant | |
| 263 | + (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 de | |
| 266 | + suppression ; contrainte « jamais deux offres de la même source dans une | |
| 267 | + composante » conservée. | |
| 268 | + | |
| 269 | +### B.5 Expiration spécifique emploi | |
| 270 | + | |
| 271 | +- `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'offre | |
| 273 | + 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`). | |
| 275 | + | |
| 276 | +### B.6 Structure de dépôt cible | |
| 277 | + | |
| 278 | +``` | |
| 279 | +job-ka/ | |
| 280 | +├── run.py # sync | watch | serve | geocode | record | |
| 281 | +├── requirements.txt # fastapi, uvicorn, requests, beautifulsoup4 | |
| 282 | +├── jobka/ | |
| 283 | +│ ├── schema.py # JobPosting (dataclass) + finalize() | |
| 284 | +│ ├── normalize.py # salaires, dates, mode/type d'emploi, catégories | |
| 285 | +│ ├── db.py # SQLite, sync_log, anti-dérive, migrations | |
| 286 | +│ ├── ingest.py # run() / watch() | |
| 287 | +│ ├── dedup.py # blocage titre|employeur|ville + union-find | |
| 288 | +│ ├── geocode.py # Nominatim + Adresses Québec + cache (repris) | |
| 289 | +│ ├── web.py # FastAPI : /api/jobs, /api/facets, /api/sources, /api/stats | |
| 290 | +│ ├── seo.py # SSR léger + sitemaps + 410 | |
| 291 | +│ ├── fixtures.py # record/replay hors ligne | |
| 292 | +│ └── 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 plateforme | |
| 296 | +│ └── <employeur>.py # 1 fichier = 1 source | |
| 297 | +├── data/sources.json # registre descriptif des employeurs | |
| 298 | +├── frontend/ # React + Vite + TS, servi par FastAPI | |
| 299 | +├── tests/ # test_connectors (fixtures), test_normalize | |
| 300 | +├── reports/connectors/ # 1 fiche .md par connecteur | |
| 301 | +└── docs/ | |
| 302 | +``` | |
| 303 | + | |
| 304 | +### B.7 Déploiement (CLAUDE.md §6) | |
| 305 | + | |
| 306 | +3 processus PM2 sur **m3u96a**, calqués sur Lou·Ka : | |
| 307 | + | |
| 308 | +``` | |
| 309 | +job-ka-web .venv/bin/python run.py serve <port> # API + SSR + frontend | |
| 310 | +job-ka-sync .venv/bin/python run.py watch 60 # resync horaire, jour et nuit | |
| 311 | +job-ka-ngrok ngrok http --url=www.job-ka.com <port> | |
| 312 | +``` | |
| 313 | + | |
| 314 | +Vérifications post-déploiement : `https://www.job-ka.com` répond, | |
| 315 | +`/api/sources` montre des `last_sync` récents, `sync_log` sans alerte. | |
| 316 | +Jamais d'admin/BD exposé par le tunnel. Enregistrement dans | |
| 317 | +`~/Desktop/cluster-skill/cluster-deployments.json`. À noter : m3u96a est | |
| 318 | +normalement réservé au staging/calcul — dérogation explicite du CLAUDE.md §6. | |
| 319 | + | |
| 320 | +### B.8 Écarts assumés vs Lou·Ka (améliorations légères) | |
| 321 | + | |
| 322 | +1. **Endpoint `/health`** ajouté (Lou·Ka n'en a pas ; le CLAUDE.md de Job·Ka | |
| 323 | + exige un healthcheck au déploiement). | |
| 324 | +2. `dedup.run()` aussi appelé en fin de `run.py sync` (bogue mineur relevé chez | |
| 325 | + Lou·Ka : la dédup ne tourne que dans `watch`). | |
| 326 | +3. Réinitialiser `geocode_failed` quand l'adresse d'une offre change (second | |
| 327 | + bogue mineur relevé chez Lou·Ka). | |
| 328 | +4. En-tête d'auteur Job·Ka (règle nº 1) sur chaque fichier + hook pre-commit de | |
| 329 | + vérification (section 17 du CLAUDE.md). | |
| 330 | + | |
| 331 | +### B.9 Ordre d'implémentation proposé | |
| 332 | + | |
| 333 | +1. **Socle** : `schema.py`, `normalize.py` (+ tests), `db.py`, `base.py`, | |
| 334 | + registre auto-découvrant, `run.py`, `fixtures.py`. | |
| 335 | +2. **3 premiers connecteurs pilotes** : 1 Workday + 1 Lever ou Greenhouse | |
| 336 | + (employeurs québécois à confirmer) + 1 custom HTML — pour valider le socle | |
| 337 | + sur les trois familles. | |
| 338 | +3. `ingest.py` + expiration + anti-dérive + `dedup.py`. | |
| 339 | +4. `web.py` + frontend minimal (liste, fiche, filtres, sources, stats). | |
| 340 | +5. Vague de connecteurs (choix des employeurs à valider avec Simon-Pierre). | |
| 341 | +6. `seo.py`, géocodage, déploiement m3u96a + ngrok. | |
| 342 | + | |
| 343 | +--- | |
| 344 | + | |
| 345 | +## Questions ouvertes pour validation | |
| 346 | + | |
| 347 | +1. **Liste des premiers employeurs/ATS cibles** — as-tu déjà une liste | |
| 348 | + (ex. Desjardins, Hydro-Québec, CGI, gouvernement du Québec via Workday…) ou | |
| 349 | + je propose une première vague de ~20 employeurs québécois par ATS ? | |
| 350 | +2. **Port du service** sur m3u96a (Lou·Ka occupe 8095 ailleurs ; proposer 8096 ?). | |
| 351 | +3. Le géocodage des lieux de travail est-il prioritaire dès la v1, ou la ville | |
| 352 | + textuelle suffit-elle au lancement (carte en v2) ? | |
| 353 | + | |
| 354 | +**Statut : en attente de validation avant toute implémentation (règle nº 2).** | |
added
docs/deploiement.md
+84 −0
@@ -0,0 +1,84 @@ | ||
| 1 | +<!-- | |
| 2 | +============================================================================= | |
| 3 | +Job·Ka — Groupe KA | |
| 4 | +Auteur : Simon-Pierre Boucher | |
| 5 | +Contact : contact@spboucher.ai | |
| 6 | +Fichier : docs/deploiement.md | |
| 7 | +Rôle : Procédure de déploiement sur m3u96a + ngrok www.job-ka.com | |
| 8 | +Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | +============================================================================= | |
| 10 | +--> | |
| 11 | + | |
| 12 | +# Déploiement Job·Ka — nœud m3u96a | |
| 13 | + | |
| 14 | +## Cible | |
| 15 | + | |
| 16 | +- **Nœud** : `M3U96a.maclustr.io` (Mac Studio, 32 cœurs, 96 Go) — imposé par le | |
| 17 | + CLAUDE.md du projet (§6). Dérogation assumée à la règle « M3U96a = staging » : | |
| 18 | + demande explicite du projet, comme `vrai-prix` et `sorti-ka` déjà sur ce nœud. | |
| 19 | +- **Répertoire** : `~/apps/job-ka` (convention des projets Ka remote-first) | |
| 20 | +- **Port interne** : `8096` (8095 = lou-ka ; vérifier les conflits avec | |
| 21 | + `pick-node.py`/`lsof` avant de démarrer) | |
| 22 | +- **Exposition** : ngrok, domaine réservé `www.job-ka.com` | |
| 23 | +- **Superviseur** : PM2 (3 processus) | |
| 24 | + | |
| 25 | +## Processus PM2 | |
| 26 | + | |
| 27 | +``` | |
| 28 | +job-ka-web .venv/bin/python run.py serve 8096 # API + frontend | |
| 29 | +job-ka-sync .venv/bin/python run.py watch 60 # resync horaire | |
| 30 | +job-ka-ngrok ngrok http --url=www.job-ka.com 8096 # tunnel supervisé | |
| 31 | +``` | |
| 32 | + | |
| 33 | +## Procédure | |
| 34 | + | |
| 35 | +```bash | |
| 36 | +# 1. Pousser le code (jamais les données : data/*.db, dist/, .env restent locaux au nœud) | |
| 37 | +ssh M3U96a 'mkdir -p ~/apps' | |
| 38 | +rsync -a --delete \ | |
| 39 | + --exclude .venv --exclude node_modules --exclude dist \ | |
| 40 | + --exclude 'data/*.db' --exclude .env --exclude .git \ | |
| 41 | + ~/Desktop/job-ka/ M3U96a:~/apps/job-ka/ | |
| 42 | + | |
| 43 | +# 2. Sur le nœud : runtime + build + secrets | |
| 44 | +ssh M3U96a 'cd ~/apps/job-ka \ | |
| 45 | + && python3 -m venv .venv && .venv/bin/pip install -r requirements.txt \ | |
| 46 | + && cd frontend && npm install && npm run build' | |
| 47 | +# .env : créer sur le nœud (SCRAPFLY_API_KEY) — jamais commité ni rsyncé | |
| 48 | + | |
| 49 | +# 3. Amorçage des données | |
| 50 | +ssh M3U96a 'cd ~/apps/job-ka && .venv/bin/python run.py sync && .venv/bin/python run.py geocode' | |
| 51 | + | |
| 52 | +# 4. PM2 | |
| 53 | +ssh M3U96a 'cd ~/apps/job-ka \ | |
| 54 | + && pm2 start ".venv/bin/python run.py serve 8096" --name job-ka-web \ | |
| 55 | + && pm2 start ".venv/bin/python run.py watch 60" --name job-ka-sync \ | |
| 56 | + && pm2 start "ngrok http --url=www.job-ka.com 8096" --name job-ka-ngrok \ | |
| 57 | + && pm2 save' | |
| 58 | +``` | |
| 59 | + | |
| 60 | +## Vérifications post-déploiement | |
| 61 | + | |
| 62 | +1. `curl https://www.job-ka.com/health` → `{"status":"ok", ...}` avec | |
| 63 | + `last_sync_age_s` < 7200 ; | |
| 64 | +2. `https://www.job-ka.com` répond (frontend) ; | |
| 65 | +3. `https://www.job-ka.com/api/sources` → `last_sync` récents sur chaque source ; | |
| 66 | +4. `pm2 ls` : les 3 processus `online` ; | |
| 67 | +5. Mettre à jour `~/Desktop/cluster-skill/cluster-deployments.json` (registre | |
| 68 | + cross-session : app, node, port, domain, process_manager, dir, start, deployed). | |
| 69 | + | |
| 70 | +## Règles | |
| 71 | + | |
| 72 | +- Jamais d'interface d'admin ni de BD exposée par le tunnel (seuls `/`, | |
| 73 | + `/api/*`, `/health` existent ; `POST /api/sync` est le seul point mutateur — | |
| 74 | + il ne fait que déclencher une resynchronisation). | |
| 75 | +- Les mises à jour de code passent par rsync (ou git sur le nœud, protocole | |
| 76 | + remote-first des projets Ka) puis `pm2 restart job-ka-web job-ka-sync`. | |
| 77 | + | |
| 78 | +## Variations par rapport à Lou·Ka | |
| 79 | + | |
| 80 | +- Carte : **MapLibre GL + fond Carto libre** au lieu de Mapbox/ka-maps — aucun | |
| 81 | + jeton requis, aucun paquet lié à builder à côté du dépôt. | |
| 82 | +- `dedup.run()` s'exécute aussi après `run.py sync` (pas seulement dans | |
| 83 | + `watch`) ; `geocode_failed` est remis à 0 quand l'adresse d'une offre change. | |
| 84 | +- Endpoint `/health` dédié (Lou·Ka n'en a pas). | |
added
frontend/index.html
+27 −0
@@ -0,0 +1,27 @@ | ||
| 1 | +<!doctype html> | |
| 2 | +<!-- | |
| 3 | +============================================================================= | |
| 4 | +Job·Ka — Groupe KA | |
| 5 | +Auteur : Simon-Pierre Boucher | |
| 6 | +Contact : contact@spboucher.ai | |
| 7 | +Fichier : frontend/index.html | |
| 8 | +Rôle : Coquille HTML de l'application (SPA React) | |
| 9 | +Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 10 | +============================================================================= | |
| 11 | +--> | |
| 12 | +<html lang="fr"> | |
| 13 | + <head> | |
| 14 | + <meta charset="UTF-8" /> | |
| 15 | + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| 16 | + <meta name="description" content="Job·Ka — toutes les offres d'emploi des employeurs québécois, directement à la source. Salaires transparents, zéro boîte noire." /> | |
| 17 | + <title>Job·Ka — L'emploi au Québec, à la source</title> | |
| 18 | + <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='14' fill='%23141814'/%3E%3Ctext x='32' y='42' font-family='Inter,sans-serif' font-size='28' font-weight='800' fill='%23d9f26b' text-anchor='middle'%3EJ·K%3C/text%3E%3C/svg%3E" /> | |
| 19 | + <link rel="preconnect" href="https://fonts.googleapis.com" /> | |
| 20 | + <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> | |
| 21 | + <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" /> | |
| 22 | + </head> | |
| 23 | + <body> | |
| 24 | + <div id="root"></div> | |
| 25 | + <script type="module" src="/src/main.tsx"></script> | |
| 26 | + </body> | |
| 27 | +</html> | |
added
frontend/package-lock.json
+2212 −0
Large diff (2,212 lines) is not loaded by default to keep the page fast.Load diff
added
frontend/package.json
+26 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
added
frontend/src/App.tsx
+60 −0
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
added
frontend/src/api.ts
+171 −0
Large diff (171 lines) is not loaded by default to keep the page fast.Load diff
added
frontend/src/components/JobCard.tsx
+30 −0
Large diff (30 lines) is not loaded by default to keep the page fast.Load diff
added
frontend/src/main.tsx
+23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added
frontend/src/pages/Home.tsx
+136 −0
Large diff (136 lines) is not loaded by default to keep the page fast.Load diff
added
frontend/src/pages/Job.tsx
+71 −0
Large diff (71 lines) is not loaded by default to keep the page fast.Load diff
added
frontend/src/pages/MapPage.tsx
+98 −0
Large diff (98 lines) is not loaded by default to keep the page fast.Load diff
added
frontend/src/pages/Sources.tsx
+52 −0
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
added
frontend/src/pages/Stats.tsx
+79 −0
Large diff (79 lines) is not loaded by default to keep the page fast.Load diff
added
frontend/src/styles.css
+172 −0
Large diff (172 lines) is not loaded by default to keep the page fast.Load diff
added
frontend/tsconfig.json
+21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
added
frontend/vite.config.ts
+23 −0
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
added
jobka/__init__.py
+8 −0
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added
jobka/connectors/__init__.py
+32 −0
Large diff (32 lines) is not loaded by default to keep the page fast.Load diff
added
jobka/connectors/base.py
+150 −0
Large diff (150 lines) is not loaded by default to keep the page fast.Load diff
added
jobka/connectors/cae.py
+18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added
jobka/connectors/desjardins.py
+18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added
jobka/connectors/greenhouse.py
+67 −0
Large diff (67 lines) is not loaded by default to keep the page fast.Load diff
added
jobka/connectors/lever.py
+85 −0
Large diff (85 lines) is not loaded by default to keep the page fast.Load diff
added
jobka/connectors/mistplay.py
+15 −0
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
added
jobka/connectors/poka.py
+16 −0
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added
jobka/connectors/smartrecruiters.py
+124 −0
Large diff (124 lines) is not loaded by default to keep the page fast.Load diff
added
jobka/connectors/ubisoft.py
+16 −0
Large diff (16 lines) is not loaded by default to keep the page fast.Load diff
added
jobka/connectors/workday.py
+174 −0
Large diff (174 lines) is not loaded by default to keep the page fast.Load diff
added
jobka/db.py
+369 −0
Large diff (369 lines) is not loaded by default to keep the page fast.Load diff
added
jobka/dedup.py
+202 −0
Large diff (202 lines) is not loaded by default to keep the page fast.Load diff
added
jobka/fixtures.py
+194 −0
Large diff (194 lines) is not loaded by default to keep the page fast.Load diff
added
jobka/geocode.py
+344 −0
Large diff (344 lines) is not loaded by default to keep the page fast.Load diff
added
jobka/ingest.py
+96 −0
Large diff (96 lines) is not loaded by default to keep the page fast.Load diff
added
jobka/normalize.py
+480 −0
Large diff (480 lines) is not loaded by default to keep the page fast.Load diff
added
jobka/schema.py
+167 −0
Large diff (167 lines) is not loaded by default to keep the page fast.Load diff
added
jobka/web.py
+338 −0
Large diff (338 lines) is not loaded by default to keep the page fast.Load diff
added
requirements.txt
+12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
added
run.py
+65 −0
Large diff (65 lines) is not loaded by default to keep the page fast.Load diff
added
scripts/check_headers.py
+76 −0
Large diff (76 lines) is not loaded by default to keep the page fast.Load diff
added
tests/conftest.py
+12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/mistplay/b53675f2801b6632ebba.json
+1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/mistplay/expected.json
+47 −0
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/mistplay/index.json
+9 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/poka/0b773b59b34e454db34d.json
+1 −0
Large diff (2 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/poka/expected.json
+75 −0
Large diff (76 lines) is not loaded by default to keep the page fast.Load diff
added
tests/fixtures/poka/index.json
+9 −0
Large diff (10 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_connectors.py
+79 −0
Large diff (79 lines) is not loaded by default to keep the page fast.Load diff
added
tests/test_normalize.py
+178 −0
Large diff (179 lines) is not loaded by default to keep the page fast.Load diff