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
@@ -0,0 +1,2212 @@ | ||
| 1 | +{ | |
| 2 | + "name": "jobka-frontend", | |
| 3 | + "version": "1.0.0", | |
| 4 | + "lockfileVersion": 3, | |
| 5 | + "requires": true, | |
| 6 | + "packages": { | |
| 7 | + "": { | |
| 8 | + "name": "jobka-frontend", | |
| 9 | + "version": "1.0.0", | |
| 10 | + "dependencies": { | |
| 11 | + "maplibre-gl": "^4.7.1", | |
| 12 | + "react": "^18.3.1", | |
| 13 | + "react-dom": "^18.3.1", | |
| 14 | + "react-router-dom": "^6.26.2" | |
| 15 | + }, | |
| 16 | + "devDependencies": { | |
| 17 | + "@types/react": "^18.3.5", | |
| 18 | + "@types/react-dom": "^18.3.0", | |
| 19 | + "@vitejs/plugin-react": "^4.3.1", | |
| 20 | + "typescript": "^5.5.4", | |
| 21 | + "vite": "^5.4.3" | |
| 22 | + } | |
| 23 | + }, | |
| 24 | + "node_modules/@babel/code-frame": { | |
| 25 | + "version": "7.29.7", | |
| 26 | + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", | |
| 27 | + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", | |
| 28 | + "dev": true, | |
| 29 | + "license": "MIT", | |
| 30 | + "dependencies": { | |
| 31 | + "@babel/helper-validator-identifier": "^7.29.7", | |
| 32 | + "js-tokens": "^4.0.0", | |
| 33 | + "picocolors": "^1.1.1" | |
| 34 | + }, | |
| 35 | + "engines": { | |
| 36 | + "node": ">=6.9.0" | |
| 37 | + } | |
| 38 | + }, | |
| 39 | + "node_modules/@babel/compat-data": { | |
| 40 | + "version": "7.29.7", | |
| 41 | + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", | |
| 42 | + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", | |
| 43 | + "dev": true, | |
| 44 | + "license": "MIT", | |
| 45 | + "engines": { | |
| 46 | + "node": ">=6.9.0" | |
| 47 | + } | |
| 48 | + }, | |
| 49 | + "node_modules/@babel/core": { | |
| 50 | + "version": "7.29.7", | |
| 51 | + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", | |
| 52 | + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", | |
| 53 | + "dev": true, | |
| 54 | + "license": "MIT", | |
| 55 | + "dependencies": { | |
| 56 | + "@babel/code-frame": "^7.29.7", | |
| 57 | + "@babel/generator": "^7.29.7", | |
| 58 | + "@babel/helper-compilation-targets": "^7.29.7", | |
| 59 | + "@babel/helper-module-transforms": "^7.29.7", | |
| 60 | + "@babel/helpers": "^7.29.7", | |
| 61 | + "@babel/parser": "^7.29.7", | |
| 62 | + "@babel/template": "^7.29.7", | |
| 63 | + "@babel/traverse": "^7.29.7", | |
| 64 | + "@babel/types": "^7.29.7", | |
| 65 | + "@jridgewell/remapping": "^2.3.5", | |
| 66 | + "convert-source-map": "^2.0.0", | |
| 67 | + "debug": "^4.1.0", | |
| 68 | + "gensync": "^1.0.0-beta.2", | |
| 69 | + "json5": "^2.2.3", | |
| 70 | + "semver": "^6.3.1" | |
| 71 | + }, | |
| 72 | + "engines": { | |
| 73 | + "node": ">=6.9.0" | |
| 74 | + }, | |
| 75 | + "funding": { | |
| 76 | + "type": "opencollective", | |
| 77 | + "url": "https://opencollective.com/babel" | |
| 78 | + } | |
| 79 | + }, | |
| 80 | + "node_modules/@babel/generator": { | |
| 81 | + "version": "7.29.8", | |
| 82 | + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", | |
| 83 | + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", | |
| 84 | + "dev": true, | |
| 85 | + "license": "MIT", | |
| 86 | + "dependencies": { | |
| 87 | + "@babel/parser": "^7.29.8", | |
| 88 | + "@babel/types": "^7.29.8", | |
| 89 | + "@jridgewell/gen-mapping": "^0.3.12", | |
| 90 | + "@jridgewell/trace-mapping": "^0.3.28", | |
| 91 | + "jsesc": "^3.0.2" | |
| 92 | + }, | |
| 93 | + "engines": { | |
| 94 | + "node": ">=6.9.0" | |
| 95 | + } | |
| 96 | + }, | |
| 97 | + "node_modules/@babel/helper-compilation-targets": { | |
| 98 | + "version": "7.29.7", | |
| 99 | + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", | |
| 100 | + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", | |
| 101 | + "dev": true, | |
| 102 | + "license": "MIT", | |
| 103 | + "dependencies": { | |
| 104 | + "@babel/compat-data": "^7.29.7", | |
| 105 | + "@babel/helper-validator-option": "^7.29.7", | |
| 106 | + "browserslist": "^4.24.0", | |
| 107 | + "lru-cache": "^5.1.1", | |
| 108 | + "semver": "^6.3.1" | |
| 109 | + }, | |
| 110 | + "engines": { | |
| 111 | + "node": ">=6.9.0" | |
| 112 | + } | |
| 113 | + }, | |
| 114 | + "node_modules/@babel/helper-globals": { | |
| 115 | + "version": "7.29.7", | |
| 116 | + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", | |
| 117 | + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", | |
| 118 | + "dev": true, | |
| 119 | + "license": "MIT", | |
| 120 | + "engines": { | |
| 121 | + "node": ">=6.9.0" | |
| 122 | + } | |
| 123 | + }, | |
| 124 | + "node_modules/@babel/helper-module-imports": { | |
| 125 | + "version": "7.29.7", | |
| 126 | + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", | |
| 127 | + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", | |
| 128 | + "dev": true, | |
| 129 | + "license": "MIT", | |
| 130 | + "dependencies": { | |
| 131 | + "@babel/traverse": "^7.29.7", | |
| 132 | + "@babel/types": "^7.29.7" | |
| 133 | + }, | |
| 134 | + "engines": { | |
| 135 | + "node": ">=6.9.0" | |
| 136 | + } | |
| 137 | + }, | |
| 138 | + "node_modules/@babel/helper-module-transforms": { | |
| 139 | + "version": "7.29.7", | |
| 140 | + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", | |
| 141 | + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", | |
| 142 | + "dev": true, | |
| 143 | + "license": "MIT", | |
| 144 | + "dependencies": { | |
| 145 | + "@babel/helper-module-imports": "^7.29.7", | |
| 146 | + "@babel/helper-validator-identifier": "^7.29.7", | |
| 147 | + "@babel/traverse": "^7.29.7" | |
| 148 | + }, | |
| 149 | + "engines": { | |
| 150 | + "node": ">=6.9.0" | |
| 151 | + }, | |
| 152 | + "peerDependencies": { | |
| 153 | + "@babel/core": "^7.0.0" | |
| 154 | + } | |
| 155 | + }, | |
| 156 | + "node_modules/@babel/helper-plugin-utils": { | |
| 157 | + "version": "7.29.7", | |
| 158 | + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", | |
| 159 | + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", | |
| 160 | + "dev": true, | |
| 161 | + "license": "MIT", | |
| 162 | + "engines": { | |
| 163 | + "node": ">=6.9.0" | |
| 164 | + } | |
| 165 | + }, | |
| 166 | + "node_modules/@babel/helper-string-parser": { | |
| 167 | + "version": "7.29.7", | |
| 168 | + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", | |
| 169 | + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", | |
| 170 | + "dev": true, | |
| 171 | + "license": "MIT", | |
| 172 | + "engines": { | |
| 173 | + "node": ">=6.9.0" | |
| 174 | + } | |
| 175 | + }, | |
| 176 | + "node_modules/@babel/helper-validator-identifier": { | |
| 177 | + "version": "7.29.7", | |
| 178 | + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", | |
| 179 | + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", | |
| 180 | + "dev": true, | |
| 181 | + "license": "MIT", | |
| 182 | + "engines": { | |
| 183 | + "node": ">=6.9.0" | |
| 184 | + } | |
| 185 | + }, | |
| 186 | + "node_modules/@babel/helper-validator-option": { | |
| 187 | + "version": "7.29.7", | |
| 188 | + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", | |
| 189 | + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", | |
| 190 | + "dev": true, | |
| 191 | + "license": "MIT", | |
| 192 | + "engines": { | |
| 193 | + "node": ">=6.9.0" | |
| 194 | + } | |
| 195 | + }, | |
| 196 | + "node_modules/@babel/helpers": { | |
| 197 | + "version": "7.29.7", | |
| 198 | + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", | |
| 199 | + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", | |
| 200 | + "dev": true, | |
| 201 | + "license": "MIT", | |
| 202 | + "dependencies": { | |
| 203 | + "@babel/template": "^7.29.7", | |
| 204 | + "@babel/types": "^7.29.7" | |
| 205 | + }, | |
| 206 | + "engines": { | |
| 207 | + "node": ">=6.9.0" | |
| 208 | + } | |
| 209 | + }, | |
| 210 | + "node_modules/@babel/parser": { | |
| 211 | + "version": "7.29.8", | |
| 212 | + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", | |
| 213 | + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", | |
| 214 | + "dev": true, | |
| 215 | + "license": "MIT", | |
| 216 | + "dependencies": { | |
| 217 | + "@babel/types": "^7.29.8" | |
| 218 | + }, | |
| 219 | + "bin": { | |
| 220 | + "parser": "bin/babel-parser.js" | |
| 221 | + }, | |
| 222 | + "engines": { | |
| 223 | + "node": ">=6.0.0" | |
| 224 | + } | |
| 225 | + }, | |
| 226 | + "node_modules/@babel/plugin-transform-react-jsx-self": { | |
| 227 | + "version": "7.29.7", | |
| 228 | + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", | |
| 229 | + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", | |
| 230 | + "dev": true, | |
| 231 | + "license": "MIT", | |
| 232 | + "dependencies": { | |
| 233 | + "@babel/helper-plugin-utils": "^7.29.7" | |
| 234 | + }, | |
| 235 | + "engines": { | |
| 236 | + "node": ">=6.9.0" | |
| 237 | + }, | |
| 238 | + "peerDependencies": { | |
| 239 | + "@babel/core": "^7.0.0-0" | |
| 240 | + } | |
| 241 | + }, | |
| 242 | + "node_modules/@babel/plugin-transform-react-jsx-source": { | |
| 243 | + "version": "7.29.7", | |
| 244 | + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", | |
| 245 | + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", | |
| 246 | + "dev": true, | |
| 247 | + "license": "MIT", | |
| 248 | + "dependencies": { | |
| 249 | + "@babel/helper-plugin-utils": "^7.29.7" | |
| 250 | + }, | |
| 251 | + "engines": { | |
| 252 | + "node": ">=6.9.0" | |
| 253 | + }, | |
| 254 | + "peerDependencies": { | |
| 255 | + "@babel/core": "^7.0.0-0" | |
| 256 | + } | |
| 257 | + }, | |
| 258 | + "node_modules/@babel/template": { | |
| 259 | + "version": "7.29.7", | |
| 260 | + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", | |
| 261 | + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", | |
| 262 | + "dev": true, | |
| 263 | + "license": "MIT", | |
| 264 | + "dependencies": { | |
| 265 | + "@babel/code-frame": "^7.29.7", | |
| 266 | + "@babel/parser": "^7.29.7", | |
| 267 | + "@babel/types": "^7.29.7" | |
| 268 | + }, | |
| 269 | + "engines": { | |
| 270 | + "node": ">=6.9.0" | |
| 271 | + } | |
| 272 | + }, | |
| 273 | + "node_modules/@babel/traverse": { | |
| 274 | + "version": "7.29.8", | |
| 275 | + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", | |
| 276 | + "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", | |
| 277 | + "dev": true, | |
| 278 | + "license": "MIT", | |
| 279 | + "dependencies": { | |
| 280 | + "@babel/code-frame": "^7.29.7", | |
| 281 | + "@babel/generator": "^7.29.8", | |
| 282 | + "@babel/helper-globals": "^7.29.7", | |
| 283 | + "@babel/parser": "^7.29.8", | |
| 284 | + "@babel/template": "^7.29.7", | |
| 285 | + "@babel/types": "^7.29.8", | |
| 286 | + "debug": "^4.3.1" | |
| 287 | + }, | |
| 288 | + "engines": { | |
| 289 | + "node": ">=6.9.0" | |
| 290 | + } | |
| 291 | + }, | |
| 292 | + "node_modules/@babel/types": { | |
| 293 | + "version": "7.29.8", | |
| 294 | + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", | |
| 295 | + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", | |
| 296 | + "dev": true, | |
| 297 | + "license": "MIT", | |
| 298 | + "dependencies": { | |
| 299 | + "@babel/helper-string-parser": "^7.29.7", | |
| 300 | + "@babel/helper-validator-identifier": "^7.29.7" | |
| 301 | + }, | |
| 302 | + "engines": { | |
| 303 | + "node": ">=6.9.0" | |
| 304 | + } | |
| 305 | + }, | |
| 306 | + "node_modules/@esbuild/aix-ppc64": { | |
| 307 | + "version": "0.21.5", | |
| 308 | + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", | |
| 309 | + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", | |
| 310 | + "cpu": [ | |
| 311 | + "ppc64" | |
| 312 | + ], | |
| 313 | + "dev": true, | |
| 314 | + "license": "MIT", | |
| 315 | + "optional": true, | |
| 316 | + "os": [ | |
| 317 | + "aix" | |
| 318 | + ], | |
| 319 | + "engines": { | |
| 320 | + "node": ">=12" | |
| 321 | + } | |
| 322 | + }, | |
| 323 | + "node_modules/@esbuild/android-arm": { | |
| 324 | + "version": "0.21.5", | |
| 325 | + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", | |
| 326 | + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", | |
| 327 | + "cpu": [ | |
| 328 | + "arm" | |
| 329 | + ], | |
| 330 | + "dev": true, | |
| 331 | + "license": "MIT", | |
| 332 | + "optional": true, | |
| 333 | + "os": [ | |
| 334 | + "android" | |
| 335 | + ], | |
| 336 | + "engines": { | |
| 337 | + "node": ">=12" | |
| 338 | + } | |
| 339 | + }, | |
| 340 | + "node_modules/@esbuild/android-arm64": { | |
| 341 | + "version": "0.21.5", | |
| 342 | + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", | |
| 343 | + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", | |
| 344 | + "cpu": [ | |
| 345 | + "arm64" | |
| 346 | + ], | |
| 347 | + "dev": true, | |
| 348 | + "license": "MIT", | |
| 349 | + "optional": true, | |
| 350 | + "os": [ | |
| 351 | + "android" | |
| 352 | + ], | |
| 353 | + "engines": { | |
| 354 | + "node": ">=12" | |
| 355 | + } | |
| 356 | + }, | |
| 357 | + "node_modules/@esbuild/android-x64": { | |
| 358 | + "version": "0.21.5", | |
| 359 | + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", | |
| 360 | + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", | |
| 361 | + "cpu": [ | |
| 362 | + "x64" | |
| 363 | + ], | |
| 364 | + "dev": true, | |
| 365 | + "license": "MIT", | |
| 366 | + "optional": true, | |
| 367 | + "os": [ | |
| 368 | + "android" | |
| 369 | + ], | |
| 370 | + "engines": { | |
| 371 | + "node": ">=12" | |
| 372 | + } | |
| 373 | + }, | |
| 374 | + "node_modules/@esbuild/darwin-arm64": { | |
| 375 | + "version": "0.21.5", | |
| 376 | + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", | |
| 377 | + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", | |
| 378 | + "cpu": [ | |
| 379 | + "arm64" | |
| 380 | + ], | |
| 381 | + "dev": true, | |
| 382 | + "license": "MIT", | |
| 383 | + "optional": true, | |
| 384 | + "os": [ | |
| 385 | + "darwin" | |
| 386 | + ], | |
| 387 | + "engines": { | |
| 388 | + "node": ">=12" | |
| 389 | + } | |
| 390 | + }, | |
| 391 | + "node_modules/@esbuild/darwin-x64": { | |
| 392 | + "version": "0.21.5", | |
| 393 | + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", | |
| 394 | + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", | |
| 395 | + "cpu": [ | |
| 396 | + "x64" | |
| 397 | + ], | |
| 398 | + "dev": true, | |
| 399 | + "license": "MIT", | |
| 400 | + "optional": true, | |
| 401 | + "os": [ | |
| 402 | + "darwin" | |
| 403 | + ], | |
| 404 | + "engines": { | |
| 405 | + "node": ">=12" | |
| 406 | + } | |
| 407 | + }, | |
| 408 | + "node_modules/@esbuild/freebsd-arm64": { | |
| 409 | + "version": "0.21.5", | |
| 410 | + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", | |
| 411 | + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", | |
| 412 | + "cpu": [ | |
| 413 | + "arm64" | |
| 414 | + ], | |
| 415 | + "dev": true, | |
| 416 | + "license": "MIT", | |
| 417 | + "optional": true, | |
| 418 | + "os": [ | |
| 419 | + "freebsd" | |
| 420 | + ], | |
| 421 | + "engines": { | |
| 422 | + "node": ">=12" | |
| 423 | + } | |
| 424 | + }, | |
| 425 | + "node_modules/@esbuild/freebsd-x64": { | |
| 426 | + "version": "0.21.5", | |
| 427 | + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", | |
| 428 | + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", | |
| 429 | + "cpu": [ | |
| 430 | + "x64" | |
| 431 | + ], | |
| 432 | + "dev": true, | |
| 433 | + "license": "MIT", | |
| 434 | + "optional": true, | |
| 435 | + "os": [ | |
| 436 | + "freebsd" | |
| 437 | + ], | |
| 438 | + "engines": { | |
| 439 | + "node": ">=12" | |
| 440 | + } | |
| 441 | + }, | |
| 442 | + "node_modules/@esbuild/linux-arm": { | |
| 443 | + "version": "0.21.5", | |
| 444 | + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", | |
| 445 | + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", | |
| 446 | + "cpu": [ | |
| 447 | + "arm" | |
| 448 | + ], | |
| 449 | + "dev": true, | |
| 450 | + "license": "MIT", | |
| 451 | + "optional": true, | |
| 452 | + "os": [ | |
| 453 | + "linux" | |
| 454 | + ], | |
| 455 | + "engines": { | |
| 456 | + "node": ">=12" | |
| 457 | + } | |
| 458 | + }, | |
| 459 | + "node_modules/@esbuild/linux-arm64": { | |
| 460 | + "version": "0.21.5", | |
| 461 | + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", | |
| 462 | + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", | |
| 463 | + "cpu": [ | |
| 464 | + "arm64" | |
| 465 | + ], | |
| 466 | + "dev": true, | |
| 467 | + "license": "MIT", | |
| 468 | + "optional": true, | |
| 469 | + "os": [ | |
| 470 | + "linux" | |
| 471 | + ], | |
| 472 | + "engines": { | |
| 473 | + "node": ">=12" | |
| 474 | + } | |
| 475 | + }, | |
| 476 | + "node_modules/@esbuild/linux-ia32": { | |
| 477 | + "version": "0.21.5", | |
| 478 | + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", | |
| 479 | + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", | |
| 480 | + "cpu": [ | |
| 481 | + "ia32" | |
| 482 | + ], | |
| 483 | + "dev": true, | |
| 484 | + "license": "MIT", | |
| 485 | + "optional": true, | |
| 486 | + "os": [ | |
| 487 | + "linux" | |
| 488 | + ], | |
| 489 | + "engines": { | |
| 490 | + "node": ">=12" | |
| 491 | + } | |
| 492 | + }, | |
| 493 | + "node_modules/@esbuild/linux-loong64": { | |
| 494 | + "version": "0.21.5", | |
| 495 | + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", | |
| 496 | + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", | |
| 497 | + "cpu": [ | |
| 498 | + "loong64" | |
| 499 | + ], | |
| 500 | + "dev": true, | |
| 501 | + "license": "MIT", | |
| 502 | + "optional": true, | |
| 503 | + "os": [ | |
| 504 | + "linux" | |
| 505 | + ], | |
| 506 | + "engines": { | |
| 507 | + "node": ">=12" | |
| 508 | + } | |
| 509 | + }, | |
| 510 | + "node_modules/@esbuild/linux-mips64el": { | |
| 511 | + "version": "0.21.5", | |
| 512 | + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", | |
| 513 | + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", | |
| 514 | + "cpu": [ | |
| 515 | + "mips64el" | |
| 516 | + ], | |
| 517 | + "dev": true, | |
| 518 | + "license": "MIT", | |
| 519 | + "optional": true, | |
| 520 | + "os": [ | |
| 521 | + "linux" | |
| 522 | + ], | |
| 523 | + "engines": { | |
| 524 | + "node": ">=12" | |
| 525 | + } | |
| 526 | + }, | |
| 527 | + "node_modules/@esbuild/linux-ppc64": { | |
| 528 | + "version": "0.21.5", | |
| 529 | + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", | |
| 530 | + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", | |
| 531 | + "cpu": [ | |
| 532 | + "ppc64" | |
| 533 | + ], | |
| 534 | + "dev": true, | |
| 535 | + "license": "MIT", | |
| 536 | + "optional": true, | |
| 537 | + "os": [ | |
| 538 | + "linux" | |
| 539 | + ], | |
| 540 | + "engines": { | |
| 541 | + "node": ">=12" | |
| 542 | + } | |
| 543 | + }, | |
| 544 | + "node_modules/@esbuild/linux-riscv64": { | |
| 545 | + "version": "0.21.5", | |
| 546 | + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", | |
| 547 | + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", | |
| 548 | + "cpu": [ | |
| 549 | + "riscv64" | |
| 550 | + ], | |
| 551 | + "dev": true, | |
| 552 | + "license": "MIT", | |
| 553 | + "optional": true, | |
| 554 | + "os": [ | |
| 555 | + "linux" | |
| 556 | + ], | |
| 557 | + "engines": { | |
| 558 | + "node": ">=12" | |
| 559 | + } | |
| 560 | + }, | |
| 561 | + "node_modules/@esbuild/linux-s390x": { | |
| 562 | + "version": "0.21.5", | |
| 563 | + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", | |
| 564 | + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", | |
| 565 | + "cpu": [ | |
| 566 | + "s390x" | |
| 567 | + ], | |
| 568 | + "dev": true, | |
| 569 | + "license": "MIT", | |
| 570 | + "optional": true, | |
| 571 | + "os": [ | |
| 572 | + "linux" | |
| 573 | + ], | |
| 574 | + "engines": { | |
| 575 | + "node": ">=12" | |
| 576 | + } | |
| 577 | + }, | |
| 578 | + "node_modules/@esbuild/linux-x64": { | |
| 579 | + "version": "0.21.5", | |
| 580 | + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", | |
| 581 | + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", | |
| 582 | + "cpu": [ | |
| 583 | + "x64" | |
| 584 | + ], | |
| 585 | + "dev": true, | |
| 586 | + "license": "MIT", | |
| 587 | + "optional": true, | |
| 588 | + "os": [ | |
| 589 | + "linux" | |
| 590 | + ], | |
| 591 | + "engines": { | |
| 592 | + "node": ">=12" | |
| 593 | + } | |
| 594 | + }, | |
| 595 | + "node_modules/@esbuild/netbsd-x64": { | |
| 596 | + "version": "0.21.5", | |
| 597 | + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", | |
| 598 | + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", | |
| 599 | + "cpu": [ | |
| 600 | + "x64" | |
| 601 | + ], | |
| 602 | + "dev": true, | |
| 603 | + "license": "MIT", | |
| 604 | + "optional": true, | |
| 605 | + "os": [ | |
| 606 | + "netbsd" | |
| 607 | + ], | |
| 608 | + "engines": { | |
| 609 | + "node": ">=12" | |
| 610 | + } | |
| 611 | + }, | |
| 612 | + "node_modules/@esbuild/openbsd-x64": { | |
| 613 | + "version": "0.21.5", | |
| 614 | + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", | |
| 615 | + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", | |
| 616 | + "cpu": [ | |
| 617 | + "x64" | |
| 618 | + ], | |
| 619 | + "dev": true, | |
| 620 | + "license": "MIT", | |
| 621 | + "optional": true, | |
| 622 | + "os": [ | |
| 623 | + "openbsd" | |
| 624 | + ], | |
| 625 | + "engines": { | |
| 626 | + "node": ">=12" | |
| 627 | + } | |
| 628 | + }, | |
| 629 | + "node_modules/@esbuild/sunos-x64": { | |
| 630 | + "version": "0.21.5", | |
| 631 | + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", | |
| 632 | + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", | |
| 633 | + "cpu": [ | |
| 634 | + "x64" | |
| 635 | + ], | |
| 636 | + "dev": true, | |
| 637 | + "license": "MIT", | |
| 638 | + "optional": true, | |
| 639 | + "os": [ | |
| 640 | + "sunos" | |
| 641 | + ], | |
| 642 | + "engines": { | |
| 643 | + "node": ">=12" | |
| 644 | + } | |
| 645 | + }, | |
| 646 | + "node_modules/@esbuild/win32-arm64": { | |
| 647 | + "version": "0.21.5", | |
| 648 | + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", | |
| 649 | + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", | |
| 650 | + "cpu": [ | |
| 651 | + "arm64" | |
| 652 | + ], | |
| 653 | + "dev": true, | |
| 654 | + "license": "MIT", | |
| 655 | + "optional": true, | |
| 656 | + "os": [ | |
| 657 | + "win32" | |
| 658 | + ], | |
| 659 | + "engines": { | |
| 660 | + "node": ">=12" | |
| 661 | + } | |
| 662 | + }, | |
| 663 | + "node_modules/@esbuild/win32-ia32": { | |
| 664 | + "version": "0.21.5", | |
| 665 | + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", | |
| 666 | + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", | |
| 667 | + "cpu": [ | |
| 668 | + "ia32" | |
| 669 | + ], | |
| 670 | + "dev": true, | |
| 671 | + "license": "MIT", | |
| 672 | + "optional": true, | |
| 673 | + "os": [ | |
| 674 | + "win32" | |
| 675 | + ], | |
| 676 | + "engines": { | |
| 677 | + "node": ">=12" | |
| 678 | + } | |
| 679 | + }, | |
| 680 | + "node_modules/@esbuild/win32-x64": { | |
| 681 | + "version": "0.21.5", | |
| 682 | + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", | |
| 683 | + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", | |
| 684 | + "cpu": [ | |
| 685 | + "x64" | |
| 686 | + ], | |
| 687 | + "dev": true, | |
| 688 | + "license": "MIT", | |
| 689 | + "optional": true, | |
| 690 | + "os": [ | |
| 691 | + "win32" | |
| 692 | + ], | |
| 693 | + "engines": { | |
| 694 | + "node": ">=12" | |
| 695 | + } | |
| 696 | + }, | |
| 697 | + "node_modules/@jridgewell/gen-mapping": { | |
| 698 | + "version": "0.3.13", | |
| 699 | + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", | |
| 700 | + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", | |
| 701 | + "dev": true, | |
| 702 | + "license": "MIT", | |
| 703 | + "dependencies": { | |
| 704 | + "@jridgewell/sourcemap-codec": "^1.5.0", | |
| 705 | + "@jridgewell/trace-mapping": "^0.3.24" | |
| 706 | + } | |
| 707 | + }, | |
| 708 | + "node_modules/@jridgewell/remapping": { | |
| 709 | + "version": "2.3.5", | |
| 710 | + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", | |
| 711 | + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", | |
| 712 | + "dev": true, | |
| 713 | + "license": "MIT", | |
| 714 | + "dependencies": { | |
| 715 | + "@jridgewell/gen-mapping": "^0.3.5", | |
| 716 | + "@jridgewell/trace-mapping": "^0.3.24" | |
| 717 | + } | |
| 718 | + }, | |
| 719 | + "node_modules/@jridgewell/resolve-uri": { | |
| 720 | + "version": "3.1.2", | |
| 721 | + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", | |
| 722 | + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", | |
| 723 | + "dev": true, | |
| 724 | + "license": "MIT", | |
| 725 | + "engines": { | |
| 726 | + "node": ">=6.0.0" | |
| 727 | + } | |
| 728 | + }, | |
| 729 | + "node_modules/@jridgewell/sourcemap-codec": { | |
| 730 | + "version": "1.5.5", | |
| 731 | + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", | |
| 732 | + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", | |
| 733 | + "dev": true, | |
| 734 | + "license": "MIT" | |
| 735 | + }, | |
| 736 | + "node_modules/@jridgewell/trace-mapping": { | |
| 737 | + "version": "0.3.31", | |
| 738 | + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", | |
| 739 | + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", | |
| 740 | + "dev": true, | |
| 741 | + "license": "MIT", | |
| 742 | + "dependencies": { | |
| 743 | + "@jridgewell/resolve-uri": "^3.1.0", | |
| 744 | + "@jridgewell/sourcemap-codec": "^1.4.14" | |
| 745 | + } | |
| 746 | + }, | |
| 747 | + "node_modules/@mapbox/geojson-rewind": { | |
| 748 | + "version": "0.5.2", | |
| 749 | + "resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz", | |
| 750 | + "integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==", | |
| 751 | + "license": "ISC", | |
| 752 | + "dependencies": { | |
| 753 | + "get-stream": "^6.0.1", | |
| 754 | + "minimist": "^1.2.6" | |
| 755 | + }, | |
| 756 | + "bin": { | |
| 757 | + "geojson-rewind": "geojson-rewind" | |
| 758 | + } | |
| 759 | + }, | |
| 760 | + "node_modules/@mapbox/jsonlint-lines-primitives": { | |
| 761 | + "version": "2.0.3", | |
| 762 | + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", | |
| 763 | + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==", | |
| 764 | + "license": "MIT", | |
| 765 | + "engines": { | |
| 766 | + "node": ">= 22" | |
| 767 | + } | |
| 768 | + }, | |
| 769 | + "node_modules/@mapbox/point-geometry": { | |
| 770 | + "version": "0.1.0", | |
| 771 | + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-0.1.0.tgz", | |
| 772 | + "integrity": "sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ==", | |
| 773 | + "license": "ISC" | |
| 774 | + }, | |
| 775 | + "node_modules/@mapbox/tiny-sdf": { | |
| 776 | + "version": "2.2.0", | |
| 777 | + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", | |
| 778 | + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", | |
| 779 | + "license": "BSD-2-Clause" | |
| 780 | + }, | |
| 781 | + "node_modules/@mapbox/unitbezier": { | |
| 782 | + "version": "0.0.1", | |
| 783 | + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", | |
| 784 | + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", | |
| 785 | + "license": "BSD-2-Clause" | |
| 786 | + }, | |
| 787 | + "node_modules/@mapbox/vector-tile": { | |
| 788 | + "version": "1.3.1", | |
| 789 | + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-1.3.1.tgz", | |
| 790 | + "integrity": "sha512-MCEddb8u44/xfQ3oD+Srl/tNcQoqTw3goGk2oLsrFxOTc3dUp+kAnby3PvAeeBYSMSjSPD1nd1AJA6W49WnoUw==", | |
| 791 | + "license": "BSD-3-Clause", | |
| 792 | + "dependencies": { | |
| 793 | + "@mapbox/point-geometry": "~0.1.0" | |
| 794 | + } | |
| 795 | + }, | |
| 796 | + "node_modules/@mapbox/whoots-js": { | |
| 797 | + "version": "3.1.0", | |
| 798 | + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", | |
| 799 | + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", | |
| 800 | + "license": "ISC", | |
| 801 | + "engines": { | |
| 802 | + "node": ">=6.0.0" | |
| 803 | + } | |
| 804 | + }, | |
| 805 | + "node_modules/@maplibre/maplibre-gl-style-spec": { | |
| 806 | + "version": "20.4.0", | |
| 807 | + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-20.4.0.tgz", | |
| 808 | + "integrity": "sha512-AzBy3095fTFPjDjmWpR2w6HVRAZJ6hQZUCwk5Plz6EyfnfuQW1odeW5i2Ai47Y6TBA2hQnC+azscjBSALpaWgw==", | |
| 809 | + "license": "ISC", | |
| 810 | + "dependencies": { | |
| 811 | + "@mapbox/jsonlint-lines-primitives": "~2.0.2", | |
| 812 | + "@mapbox/unitbezier": "^0.0.1", | |
| 813 | + "json-stringify-pretty-compact": "^4.0.0", | |
| 814 | + "minimist": "^1.2.8", | |
| 815 | + "quickselect": "^2.0.0", | |
| 816 | + "rw": "^1.3.3", | |
| 817 | + "tinyqueue": "^3.0.0" | |
| 818 | + }, | |
| 819 | + "bin": { | |
| 820 | + "gl-style-format": "dist/gl-style-format.mjs", | |
| 821 | + "gl-style-migrate": "dist/gl-style-migrate.mjs", | |
| 822 | + "gl-style-validate": "dist/gl-style-validate.mjs" | |
| 823 | + } | |
| 824 | + }, | |
| 825 | + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/quickselect": { | |
| 826 | + "version": "2.0.0", | |
| 827 | + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-2.0.0.tgz", | |
| 828 | + "integrity": "sha512-RKJ22hX8mHe3Y6wH/N3wCM6BWtjaxIyyUIkpHOvfFnxdI4yD4tBXEBKSbriGujF6jnSVkJrffuo6vxACiSSxIw==", | |
| 829 | + "license": "ISC" | |
| 830 | + }, | |
| 831 | + "node_modules/@napi-rs/lzma-linux-x64-gnu": { | |
| 832 | + "version": "1.5.1", | |
| 833 | + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", | |
| 834 | + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", | |
| 835 | + "cpu": [ | |
| 836 | + "x64" | |
| 837 | + ], | |
| 838 | + "dev": true, | |
| 839 | + "libc": [ | |
| 840 | + "glibc" | |
| 841 | + ], | |
| 842 | + "license": "MIT", | |
| 843 | + "optional": true, | |
| 844 | + "os": [ | |
| 845 | + "linux" | |
| 846 | + ], | |
| 847 | + "engines": { | |
| 848 | + "node": "^22.20 || ^24.12 || >=25" | |
| 849 | + } | |
| 850 | + }, | |
| 851 | + "node_modules/@remix-run/router": { | |
| 852 | + "version": "1.23.3", | |
| 853 | + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz", | |
| 854 | + "integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==", | |
| 855 | + "license": "MIT", | |
| 856 | + "engines": { | |
| 857 | + "node": ">=14.0.0" | |
| 858 | + } | |
| 859 | + }, | |
| 860 | + "node_modules/@rolldown/pluginutils": { | |
| 861 | + "version": "1.0.0-beta.27", | |
| 862 | + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", | |
| 863 | + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", | |
| 864 | + "dev": true, | |
| 865 | + "license": "MIT" | |
| 866 | + }, | |
| 867 | + "node_modules/@rollup/rollup-android-arm-eabi": { | |
| 868 | + "version": "4.62.4", | |
| 869 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz", | |
| 870 | + "integrity": "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg==", | |
| 871 | + "cpu": [ | |
| 872 | + "arm" | |
| 873 | + ], | |
| 874 | + "dev": true, | |
| 875 | + "license": "MIT", | |
| 876 | + "optional": true, | |
| 877 | + "os": [ | |
| 878 | + "android" | |
| 879 | + ] | |
| 880 | + }, | |
| 881 | + "node_modules/@rollup/rollup-android-arm64": { | |
| 882 | + "version": "4.62.4", | |
| 883 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz", | |
| 884 | + "integrity": "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A==", | |
| 885 | + "cpu": [ | |
| 886 | + "arm64" | |
| 887 | + ], | |
| 888 | + "dev": true, | |
| 889 | + "license": "MIT", | |
| 890 | + "optional": true, | |
| 891 | + "os": [ | |
| 892 | + "android" | |
| 893 | + ] | |
| 894 | + }, | |
| 895 | + "node_modules/@rollup/rollup-darwin-arm64": { | |
| 896 | + "version": "4.62.4", | |
| 897 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz", | |
| 898 | + "integrity": "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw==", | |
| 899 | + "cpu": [ | |
| 900 | + "arm64" | |
| 901 | + ], | |
| 902 | + "dev": true, | |
| 903 | + "license": "MIT", | |
| 904 | + "optional": true, | |
| 905 | + "os": [ | |
| 906 | + "darwin" | |
| 907 | + ] | |
| 908 | + }, | |
| 909 | + "node_modules/@rollup/rollup-darwin-x64": { | |
| 910 | + "version": "4.62.4", | |
| 911 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz", | |
| 912 | + "integrity": "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A==", | |
| 913 | + "cpu": [ | |
| 914 | + "x64" | |
| 915 | + ], | |
| 916 | + "dev": true, | |
| 917 | + "license": "MIT", | |
| 918 | + "optional": true, | |
| 919 | + "os": [ | |
| 920 | + "darwin" | |
| 921 | + ] | |
| 922 | + }, | |
| 923 | + "node_modules/@rollup/rollup-freebsd-arm64": { | |
| 924 | + "version": "4.62.4", | |
| 925 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz", | |
| 926 | + "integrity": "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ==", | |
| 927 | + "cpu": [ | |
| 928 | + "arm64" | |
| 929 | + ], | |
| 930 | + "dev": true, | |
| 931 | + "license": "MIT", | |
| 932 | + "optional": true, | |
| 933 | + "os": [ | |
| 934 | + "freebsd" | |
| 935 | + ] | |
| 936 | + }, | |
| 937 | + "node_modules/@rollup/rollup-freebsd-x64": { | |
| 938 | + "version": "4.62.4", | |
| 939 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz", | |
| 940 | + "integrity": "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg==", | |
| 941 | + "cpu": [ | |
| 942 | + "x64" | |
| 943 | + ], | |
| 944 | + "dev": true, | |
| 945 | + "license": "MIT", | |
| 946 | + "optional": true, | |
| 947 | + "os": [ | |
| 948 | + "freebsd" | |
| 949 | + ] | |
| 950 | + }, | |
| 951 | + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { | |
| 952 | + "version": "4.62.4", | |
| 953 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz", | |
| 954 | + "integrity": "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA==", | |
| 955 | + "cpu": [ | |
| 956 | + "arm" | |
| 957 | + ], | |
| 958 | + "dev": true, | |
| 959 | + "libc": [ | |
| 960 | + "glibc" | |
| 961 | + ], | |
| 962 | + "license": "MIT", | |
| 963 | + "optional": true, | |
| 964 | + "os": [ | |
| 965 | + "linux" | |
| 966 | + ] | |
| 967 | + }, | |
| 968 | + "node_modules/@rollup/rollup-linux-arm-musleabihf": { | |
| 969 | + "version": "4.62.4", | |
| 970 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz", | |
| 971 | + "integrity": "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ==", | |
| 972 | + "cpu": [ | |
| 973 | + "arm" | |
| 974 | + ], | |
| 975 | + "dev": true, | |
| 976 | + "libc": [ | |
| 977 | + "musl" | |
| 978 | + ], | |
| 979 | + "license": "MIT", | |
| 980 | + "optional": true, | |
| 981 | + "os": [ | |
| 982 | + "linux" | |
| 983 | + ] | |
| 984 | + }, | |
| 985 | + "node_modules/@rollup/rollup-linux-arm64-gnu": { | |
| 986 | + "version": "4.62.4", | |
| 987 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz", | |
| 988 | + "integrity": "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ==", | |
| 989 | + "cpu": [ | |
| 990 | + "arm64" | |
| 991 | + ], | |
| 992 | + "dev": true, | |
| 993 | + "libc": [ | |
| 994 | + "glibc" | |
| 995 | + ], | |
| 996 | + "license": "MIT", | |
| 997 | + "optional": true, | |
| 998 | + "os": [ | |
| 999 | + "linux" | |
| 1000 | + ] | |
| 1001 | + }, | |
| 1002 | + "node_modules/@rollup/rollup-linux-arm64-musl": { | |
| 1003 | + "version": "4.62.4", | |
| 1004 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz", | |
| 1005 | + "integrity": "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g==", | |
| 1006 | + "cpu": [ | |
| 1007 | + "arm64" | |
| 1008 | + ], | |
| 1009 | + "dev": true, | |
| 1010 | + "libc": [ | |
| 1011 | + "musl" | |
| 1012 | + ], | |
| 1013 | + "license": "MIT", | |
| 1014 | + "optional": true, | |
| 1015 | + "os": [ | |
| 1016 | + "linux" | |
| 1017 | + ] | |
| 1018 | + }, | |
| 1019 | + "node_modules/@rollup/rollup-linux-loong64-gnu": { | |
| 1020 | + "version": "4.62.4", | |
| 1021 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz", | |
| 1022 | + "integrity": "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA==", | |
| 1023 | + "cpu": [ | |
| 1024 | + "loong64" | |
| 1025 | + ], | |
| 1026 | + "dev": true, | |
| 1027 | + "libc": [ | |
| 1028 | + "glibc" | |
| 1029 | + ], | |
| 1030 | + "license": "MIT", | |
| 1031 | + "optional": true, | |
| 1032 | + "os": [ | |
| 1033 | + "linux" | |
| 1034 | + ] | |
| 1035 | + }, | |
| 1036 | + "node_modules/@rollup/rollup-linux-loong64-musl": { | |
| 1037 | + "version": "4.62.4", | |
| 1038 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz", | |
| 1039 | + "integrity": "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ==", | |
| 1040 | + "cpu": [ | |
| 1041 | + "loong64" | |
| 1042 | + ], | |
| 1043 | + "dev": true, | |
| 1044 | + "libc": [ | |
| 1045 | + "musl" | |
| 1046 | + ], | |
| 1047 | + "license": "MIT", | |
| 1048 | + "optional": true, | |
| 1049 | + "os": [ | |
| 1050 | + "linux" | |
| 1051 | + ] | |
| 1052 | + }, | |
| 1053 | + "node_modules/@rollup/rollup-linux-ppc64-gnu": { | |
| 1054 | + "version": "4.62.4", | |
| 1055 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz", | |
| 1056 | + "integrity": "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w==", | |
| 1057 | + "cpu": [ | |
| 1058 | + "ppc64" | |
| 1059 | + ], | |
| 1060 | + "dev": true, | |
| 1061 | + "libc": [ | |
| 1062 | + "glibc" | |
| 1063 | + ], | |
| 1064 | + "license": "MIT", | |
| 1065 | + "optional": true, | |
| 1066 | + "os": [ | |
| 1067 | + "linux" | |
| 1068 | + ] | |
| 1069 | + }, | |
| 1070 | + "node_modules/@rollup/rollup-linux-ppc64-musl": { | |
| 1071 | + "version": "4.62.4", | |
| 1072 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz", | |
| 1073 | + "integrity": "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w==", | |
| 1074 | + "cpu": [ | |
| 1075 | + "ppc64" | |
| 1076 | + ], | |
| 1077 | + "dev": true, | |
| 1078 | + "libc": [ | |
| 1079 | + "musl" | |
| 1080 | + ], | |
| 1081 | + "license": "MIT", | |
| 1082 | + "optional": true, | |
| 1083 | + "os": [ | |
| 1084 | + "linux" | |
| 1085 | + ] | |
| 1086 | + }, | |
| 1087 | + "node_modules/@rollup/rollup-linux-riscv64-gnu": { | |
| 1088 | + "version": "4.62.4", | |
| 1089 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz", | |
| 1090 | + "integrity": "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ==", | |
| 1091 | + "cpu": [ | |
| 1092 | + "riscv64" | |
| 1093 | + ], | |
| 1094 | + "dev": true, | |
| 1095 | + "libc": [ | |
| 1096 | + "glibc" | |
| 1097 | + ], | |
| 1098 | + "license": "MIT", | |
| 1099 | + "optional": true, | |
| 1100 | + "os": [ | |
| 1101 | + "linux" | |
| 1102 | + ] | |
| 1103 | + }, | |
| 1104 | + "node_modules/@rollup/rollup-linux-riscv64-musl": { | |
| 1105 | + "version": "4.62.4", | |
| 1106 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz", | |
| 1107 | + "integrity": "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA==", | |
| 1108 | + "cpu": [ | |
| 1109 | + "riscv64" | |
| 1110 | + ], | |
| 1111 | + "dev": true, | |
| 1112 | + "libc": [ | |
| 1113 | + "musl" | |
| 1114 | + ], | |
| 1115 | + "license": "MIT", | |
| 1116 | + "optional": true, | |
| 1117 | + "os": [ | |
| 1118 | + "linux" | |
| 1119 | + ] | |
| 1120 | + }, | |
| 1121 | + "node_modules/@rollup/rollup-linux-s390x-gnu": { | |
| 1122 | + "version": "4.62.4", | |
| 1123 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz", | |
| 1124 | + "integrity": "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA==", | |
| 1125 | + "cpu": [ | |
| 1126 | + "s390x" | |
| 1127 | + ], | |
| 1128 | + "dev": true, | |
| 1129 | + "libc": [ | |
| 1130 | + "glibc" | |
| 1131 | + ], | |
| 1132 | + "license": "MIT", | |
| 1133 | + "optional": true, | |
| 1134 | + "os": [ | |
| 1135 | + "linux" | |
| 1136 | + ] | |
| 1137 | + }, | |
| 1138 | + "node_modules/@rollup/rollup-linux-x64-gnu": { | |
| 1139 | + "version": "4.62.4", | |
| 1140 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz", | |
| 1141 | + "integrity": "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw==", | |
| 1142 | + "cpu": [ | |
| 1143 | + "x64" | |
| 1144 | + ], | |
| 1145 | + "dev": true, | |
| 1146 | + "libc": [ | |
| 1147 | + "glibc" | |
| 1148 | + ], | |
| 1149 | + "license": "MIT", | |
| 1150 | + "optional": true, | |
| 1151 | + "os": [ | |
| 1152 | + "linux" | |
| 1153 | + ] | |
| 1154 | + }, | |
| 1155 | + "node_modules/@rollup/rollup-linux-x64-musl": { | |
| 1156 | + "version": "4.62.4", | |
| 1157 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz", | |
| 1158 | + "integrity": "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA==", | |
| 1159 | + "cpu": [ | |
| 1160 | + "x64" | |
| 1161 | + ], | |
| 1162 | + "dev": true, | |
| 1163 | + "libc": [ | |
| 1164 | + "musl" | |
| 1165 | + ], | |
| 1166 | + "license": "MIT", | |
| 1167 | + "optional": true, | |
| 1168 | + "os": [ | |
| 1169 | + "linux" | |
| 1170 | + ] | |
| 1171 | + }, | |
| 1172 | + "node_modules/@rollup/rollup-openbsd-x64": { | |
| 1173 | + "version": "4.62.4", | |
| 1174 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz", | |
| 1175 | + "integrity": "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ==", | |
| 1176 | + "cpu": [ | |
| 1177 | + "x64" | |
| 1178 | + ], | |
| 1179 | + "dev": true, | |
| 1180 | + "license": "MIT", | |
| 1181 | + "optional": true, | |
| 1182 | + "os": [ | |
| 1183 | + "openbsd" | |
| 1184 | + ] | |
| 1185 | + }, | |
| 1186 | + "node_modules/@rollup/rollup-openharmony-arm64": { | |
| 1187 | + "version": "4.62.4", | |
| 1188 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz", | |
| 1189 | + "integrity": "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ==", | |
| 1190 | + "cpu": [ | |
| 1191 | + "arm64" | |
| 1192 | + ], | |
| 1193 | + "dev": true, | |
| 1194 | + "license": "MIT", | |
| 1195 | + "optional": true, | |
| 1196 | + "os": [ | |
| 1197 | + "openharmony" | |
| 1198 | + ] | |
| 1199 | + }, | |
| 1200 | + "node_modules/@rollup/rollup-win32-arm64-msvc": { | |
| 1201 | + "version": "4.62.4", | |
| 1202 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz", | |
| 1203 | + "integrity": "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw==", | |
| 1204 | + "cpu": [ | |
| 1205 | + "arm64" | |
| 1206 | + ], | |
| 1207 | + "dev": true, | |
| 1208 | + "license": "MIT", | |
| 1209 | + "optional": true, | |
| 1210 | + "os": [ | |
| 1211 | + "win32" | |
| 1212 | + ] | |
| 1213 | + }, | |
| 1214 | + "node_modules/@rollup/rollup-win32-ia32-msvc": { | |
| 1215 | + "version": "4.62.4", | |
| 1216 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz", | |
| 1217 | + "integrity": "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ==", | |
| 1218 | + "cpu": [ | |
| 1219 | + "ia32" | |
| 1220 | + ], | |
| 1221 | + "dev": true, | |
| 1222 | + "license": "MIT", | |
| 1223 | + "optional": true, | |
| 1224 | + "os": [ | |
| 1225 | + "win32" | |
| 1226 | + ] | |
| 1227 | + }, | |
| 1228 | + "node_modules/@rollup/rollup-win32-x64-gnu": { | |
| 1229 | + "version": "4.62.4", | |
| 1230 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz", | |
| 1231 | + "integrity": "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw==", | |
| 1232 | + "cpu": [ | |
| 1233 | + "x64" | |
| 1234 | + ], | |
| 1235 | + "dev": true, | |
| 1236 | + "license": "MIT", | |
| 1237 | + "optional": true, | |
| 1238 | + "os": [ | |
| 1239 | + "win32" | |
| 1240 | + ] | |
| 1241 | + }, | |
| 1242 | + "node_modules/@rollup/rollup-win32-x64-msvc": { | |
| 1243 | + "version": "4.62.4", | |
| 1244 | + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz", | |
| 1245 | + "integrity": "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q==", | |
| 1246 | + "cpu": [ | |
| 1247 | + "x64" | |
| 1248 | + ], | |
| 1249 | + "dev": true, | |
| 1250 | + "license": "MIT", | |
| 1251 | + "optional": true, | |
| 1252 | + "os": [ | |
| 1253 | + "win32" | |
| 1254 | + ] | |
| 1255 | + }, | |
| 1256 | + "node_modules/@types/babel__core": { | |
| 1257 | + "version": "7.20.5", | |
| 1258 | + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", | |
| 1259 | + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", | |
| 1260 | + "dev": true, | |
| 1261 | + "license": "MIT", | |
| 1262 | + "dependencies": { | |
| 1263 | + "@babel/parser": "^7.20.7", | |
| 1264 | + "@babel/types": "^7.20.7", | |
| 1265 | + "@types/babel__generator": "*", | |
| 1266 | + "@types/babel__template": "*", | |
| 1267 | + "@types/babel__traverse": "*" | |
| 1268 | + } | |
| 1269 | + }, | |
| 1270 | + "node_modules/@types/babel__generator": { | |
| 1271 | + "version": "7.27.0", | |
| 1272 | + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", | |
| 1273 | + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", | |
| 1274 | + "dev": true, | |
| 1275 | + "license": "MIT", | |
| 1276 | + "dependencies": { | |
| 1277 | + "@babel/types": "^7.0.0" | |
| 1278 | + } | |
| 1279 | + }, | |
| 1280 | + "node_modules/@types/babel__template": { | |
| 1281 | + "version": "7.4.4", | |
| 1282 | + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", | |
| 1283 | + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", | |
| 1284 | + "dev": true, | |
| 1285 | + "license": "MIT", | |
| 1286 | + "dependencies": { | |
| 1287 | + "@babel/parser": "^7.1.0", | |
| 1288 | + "@babel/types": "^7.0.0" | |
| 1289 | + } | |
| 1290 | + }, | |
| 1291 | + "node_modules/@types/babel__traverse": { | |
| 1292 | + "version": "7.28.0", | |
| 1293 | + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", | |
| 1294 | + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", | |
| 1295 | + "dev": true, | |
| 1296 | + "license": "MIT", | |
| 1297 | + "dependencies": { | |
| 1298 | + "@babel/types": "^7.28.2" | |
| 1299 | + } | |
| 1300 | + }, | |
| 1301 | + "node_modules/@types/estree": { | |
| 1302 | + "version": "1.0.9", | |
| 1303 | + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", | |
| 1304 | + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", | |
| 1305 | + "dev": true, | |
| 1306 | + "license": "MIT" | |
| 1307 | + }, | |
| 1308 | + "node_modules/@types/geojson": { | |
| 1309 | + "version": "7946.0.16", | |
| 1310 | + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", | |
| 1311 | + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", | |
| 1312 | + "license": "MIT" | |
| 1313 | + }, | |
| 1314 | + "node_modules/@types/geojson-vt": { | |
| 1315 | + "version": "3.2.5", | |
| 1316 | + "resolved": "https://registry.npmjs.org/@types/geojson-vt/-/geojson-vt-3.2.5.tgz", | |
| 1317 | + "integrity": "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==", | |
| 1318 | + "license": "MIT", | |
| 1319 | + "dependencies": { | |
| 1320 | + "@types/geojson": "*" | |
| 1321 | + } | |
| 1322 | + }, | |
| 1323 | + "node_modules/@types/mapbox__point-geometry": { | |
| 1324 | + "version": "0.1.4", | |
| 1325 | + "resolved": "https://registry.npmjs.org/@types/mapbox__point-geometry/-/mapbox__point-geometry-0.1.4.tgz", | |
| 1326 | + "integrity": "sha512-mUWlSxAmYLfwnRBmgYV86tgYmMIICX4kza8YnE/eIlywGe2XoOxlpVnXWwir92xRLjwyarqwpu2EJKD2pk0IUA==", | |
| 1327 | + "license": "MIT" | |
| 1328 | + }, | |
| 1329 | + "node_modules/@types/mapbox__vector-tile": { | |
| 1330 | + "version": "1.3.4", | |
| 1331 | + "resolved": "https://registry.npmjs.org/@types/mapbox__vector-tile/-/mapbox__vector-tile-1.3.4.tgz", | |
| 1332 | + "integrity": "sha512-bpd8dRn9pr6xKvuEBQup8pwQfD4VUyqO/2deGjfpe6AwC8YRlyEipvefyRJUSiCJTZuCb8Pl1ciVV5ekqJ96Bg==", | |
| 1333 | + "license": "MIT", | |
| 1334 | + "dependencies": { | |
| 1335 | + "@types/geojson": "*", | |
| 1336 | + "@types/mapbox__point-geometry": "*", | |
| 1337 | + "@types/pbf": "*" | |
| 1338 | + } | |
| 1339 | + }, | |
| 1340 | + "node_modules/@types/pbf": { | |
| 1341 | + "version": "3.0.5", | |
| 1342 | + "resolved": "https://registry.npmjs.org/@types/pbf/-/pbf-3.0.5.tgz", | |
| 1343 | + "integrity": "sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==", | |
| 1344 | + "license": "MIT" | |
| 1345 | + }, | |
| 1346 | + "node_modules/@types/prop-types": { | |
| 1347 | + "version": "15.7.15", | |
| 1348 | + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", | |
| 1349 | + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", | |
| 1350 | + "dev": true, | |
| 1351 | + "license": "MIT" | |
| 1352 | + }, | |
| 1353 | + "node_modules/@types/react": { | |
| 1354 | + "version": "18.3.31", | |
| 1355 | + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", | |
| 1356 | + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", | |
| 1357 | + "dev": true, | |
| 1358 | + "license": "MIT", | |
| 1359 | + "dependencies": { | |
| 1360 | + "@types/prop-types": "*", | |
| 1361 | + "csstype": "^3.2.2" | |
| 1362 | + } | |
| 1363 | + }, | |
| 1364 | + "node_modules/@types/react-dom": { | |
| 1365 | + "version": "18.3.7", | |
| 1366 | + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", | |
| 1367 | + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", | |
| 1368 | + "dev": true, | |
| 1369 | + "license": "MIT", | |
| 1370 | + "peerDependencies": { | |
| 1371 | + "@types/react": "^18.0.0" | |
| 1372 | + } | |
| 1373 | + }, | |
| 1374 | + "node_modules/@types/supercluster": { | |
| 1375 | + "version": "7.1.3", | |
| 1376 | + "resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz", | |
| 1377 | + "integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==", | |
| 1378 | + "license": "MIT", | |
| 1379 | + "dependencies": { | |
| 1380 | + "@types/geojson": "*" | |
| 1381 | + } | |
| 1382 | + }, | |
| 1383 | + "node_modules/@vitejs/plugin-react": { | |
| 1384 | + "version": "4.7.0", | |
| 1385 | + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", | |
| 1386 | + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", | |
| 1387 | + "dev": true, | |
| 1388 | + "license": "MIT", | |
| 1389 | + "dependencies": { | |
| 1390 | + "@babel/core": "^7.28.0", | |
| 1391 | + "@babel/plugin-transform-react-jsx-self": "^7.27.1", | |
| 1392 | + "@babel/plugin-transform-react-jsx-source": "^7.27.1", | |
| 1393 | + "@rolldown/pluginutils": "1.0.0-beta.27", | |
| 1394 | + "@types/babel__core": "^7.20.5", | |
| 1395 | + "react-refresh": "^0.17.0" | |
| 1396 | + }, | |
| 1397 | + "engines": { | |
| 1398 | + "node": "^14.18.0 || >=16.0.0" | |
| 1399 | + }, | |
| 1400 | + "peerDependencies": { | |
| 1401 | + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" | |
| 1402 | + } | |
| 1403 | + }, | |
| 1404 | + "node_modules/baseline-browser-mapping": { | |
| 1405 | + "version": "2.11.15", | |
| 1406 | + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz", | |
| 1407 | + "integrity": "sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==", | |
| 1408 | + "dev": true, | |
| 1409 | + "license": "Apache-2.0", | |
| 1410 | + "bin": { | |
| 1411 | + "baseline-browser-mapping": "dist/cli.cjs" | |
| 1412 | + }, | |
| 1413 | + "engines": { | |
| 1414 | + "node": ">=6.0.0" | |
| 1415 | + } | |
| 1416 | + }, | |
| 1417 | + "node_modules/browserslist": { | |
| 1418 | + "version": "4.28.8", | |
| 1419 | + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", | |
| 1420 | + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", | |
| 1421 | + "dev": true, | |
| 1422 | + "funding": [ | |
| 1423 | + { | |
| 1424 | + "type": "opencollective", | |
| 1425 | + "url": "https://opencollective.com/browserslist" | |
| 1426 | + }, | |
| 1427 | + { | |
| 1428 | + "type": "tidelift", | |
| 1429 | + "url": "https://tidelift.com/funding/github/npm/browserslist" | |
| 1430 | + }, | |
| 1431 | + { | |
| 1432 | + "type": "github", | |
| 1433 | + "url": "https://github.com/sponsors/ai" | |
| 1434 | + } | |
| 1435 | + ], | |
| 1436 | + "license": "MIT", | |
| 1437 | + "dependencies": { | |
| 1438 | + "baseline-browser-mapping": "^2.11.12", | |
| 1439 | + "caniuse-lite": "^1.0.30001809", | |
| 1440 | + "electron-to-chromium": "^1.5.402", | |
| 1441 | + "node-releases": "^2.0.53", | |
| 1442 | + "update-browserslist-db": "^1.3.0" | |
| 1443 | + }, | |
| 1444 | + "bin": { | |
| 1445 | + "browserslist": "cli.js" | |
| 1446 | + }, | |
| 1447 | + "engines": { | |
| 1448 | + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" | |
| 1449 | + } | |
| 1450 | + }, | |
| 1451 | + "node_modules/caniuse-lite": { | |
| 1452 | + "version": "1.0.30001809", | |
| 1453 | + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", | |
| 1454 | + "integrity": "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==", | |
| 1455 | + "dev": true, | |
| 1456 | + "funding": [ | |
| 1457 | + { | |
| 1458 | + "type": "opencollective", | |
| 1459 | + "url": "https://opencollective.com/browserslist" | |
| 1460 | + }, | |
| 1461 | + { | |
| 1462 | + "type": "tidelift", | |
| 1463 | + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" | |
| 1464 | + }, | |
| 1465 | + { | |
| 1466 | + "type": "github", | |
| 1467 | + "url": "https://github.com/sponsors/ai" | |
| 1468 | + } | |
| 1469 | + ], | |
| 1470 | + "license": "CC-BY-4.0" | |
| 1471 | + }, | |
| 1472 | + "node_modules/convert-source-map": { | |
| 1473 | + "version": "2.0.0", | |
| 1474 | + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", | |
| 1475 | + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", | |
| 1476 | + "dev": true, | |
| 1477 | + "license": "MIT" | |
| 1478 | + }, | |
| 1479 | + "node_modules/csstype": { | |
| 1480 | + "version": "3.2.3", | |
| 1481 | + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", | |
| 1482 | + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", | |
| 1483 | + "dev": true, | |
| 1484 | + "license": "MIT" | |
| 1485 | + }, | |
| 1486 | + "node_modules/debug": { | |
| 1487 | + "version": "4.4.3", | |
| 1488 | + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", | |
| 1489 | + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", | |
| 1490 | + "dev": true, | |
| 1491 | + "license": "MIT", | |
| 1492 | + "dependencies": { | |
| 1493 | + "ms": "^2.1.3" | |
| 1494 | + }, | |
| 1495 | + "engines": { | |
| 1496 | + "node": ">=6.0" | |
| 1497 | + }, | |
| 1498 | + "peerDependenciesMeta": { | |
| 1499 | + "supports-color": { | |
| 1500 | + "optional": true | |
| 1501 | + } | |
| 1502 | + } | |
| 1503 | + }, | |
| 1504 | + "node_modules/earcut": { | |
| 1505 | + "version": "3.2.3", | |
| 1506 | + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", | |
| 1507 | + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", | |
| 1508 | + "license": "ISC" | |
| 1509 | + }, | |
| 1510 | + "node_modules/electron-to-chromium": { | |
| 1511 | + "version": "1.5.408", | |
| 1512 | + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.408.tgz", | |
| 1513 | + "integrity": "sha512-SLoprcYpJ/OH2v2ps0+N5biv9H4/KBT3+YmmDew64TwK5y9j2wv7pMOFY7IorVkyMtEyLSCRlXKLsNlakeAlPw==", | |
| 1514 | + "dev": true, | |
| 1515 | + "license": "ISC" | |
| 1516 | + }, | |
| 1517 | + "node_modules/esbuild": { | |
| 1518 | + "version": "0.21.5", | |
| 1519 | + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", | |
| 1520 | + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", | |
| 1521 | + "dev": true, | |
| 1522 | + "hasInstallScript": true, | |
| 1523 | + "license": "MIT", | |
| 1524 | + "bin": { | |
| 1525 | + "esbuild": "bin/esbuild" | |
| 1526 | + }, | |
| 1527 | + "engines": { | |
| 1528 | + "node": ">=12" | |
| 1529 | + }, | |
| 1530 | + "optionalDependencies": { | |
| 1531 | + "@esbuild/aix-ppc64": "0.21.5", | |
| 1532 | + "@esbuild/android-arm": "0.21.5", | |
| 1533 | + "@esbuild/android-arm64": "0.21.5", | |
| 1534 | + "@esbuild/android-x64": "0.21.5", | |
| 1535 | + "@esbuild/darwin-arm64": "0.21.5", | |
| 1536 | + "@esbuild/darwin-x64": "0.21.5", | |
| 1537 | + "@esbuild/freebsd-arm64": "0.21.5", | |
| 1538 | + "@esbuild/freebsd-x64": "0.21.5", | |
| 1539 | + "@esbuild/linux-arm": "0.21.5", | |
| 1540 | + "@esbuild/linux-arm64": "0.21.5", | |
| 1541 | + "@esbuild/linux-ia32": "0.21.5", | |
| 1542 | + "@esbuild/linux-loong64": "0.21.5", | |
| 1543 | + "@esbuild/linux-mips64el": "0.21.5", | |
| 1544 | + "@esbuild/linux-ppc64": "0.21.5", | |
| 1545 | + "@esbuild/linux-riscv64": "0.21.5", | |
| 1546 | + "@esbuild/linux-s390x": "0.21.5", | |
| 1547 | + "@esbuild/linux-x64": "0.21.5", | |
| 1548 | + "@esbuild/netbsd-x64": "0.21.5", | |
| 1549 | + "@esbuild/openbsd-x64": "0.21.5", | |
| 1550 | + "@esbuild/sunos-x64": "0.21.5", | |
| 1551 | + "@esbuild/win32-arm64": "0.21.5", | |
| 1552 | + "@esbuild/win32-ia32": "0.21.5", | |
| 1553 | + "@esbuild/win32-x64": "0.21.5" | |
| 1554 | + } | |
| 1555 | + }, | |
| 1556 | + "node_modules/escalade": { | |
| 1557 | + "version": "3.2.0", | |
| 1558 | + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", | |
| 1559 | + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", | |
| 1560 | + "dev": true, | |
| 1561 | + "license": "MIT", | |
| 1562 | + "engines": { | |
| 1563 | + "node": ">=6" | |
| 1564 | + } | |
| 1565 | + }, | |
| 1566 | + "node_modules/fsevents": { | |
| 1567 | + "version": "2.3.3", | |
| 1568 | + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", | |
| 1569 | + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", | |
| 1570 | + "dev": true, | |
| 1571 | + "hasInstallScript": true, | |
| 1572 | + "license": "MIT", | |
| 1573 | + "optional": true, | |
| 1574 | + "os": [ | |
| 1575 | + "darwin" | |
| 1576 | + ], | |
| 1577 | + "engines": { | |
| 1578 | + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" | |
| 1579 | + } | |
| 1580 | + }, | |
| 1581 | + "node_modules/gensync": { | |
| 1582 | + "version": "1.0.0-beta.2", | |
| 1583 | + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", | |
| 1584 | + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", | |
| 1585 | + "dev": true, | |
| 1586 | + "license": "MIT", | |
| 1587 | + "engines": { | |
| 1588 | + "node": ">=6.9.0" | |
| 1589 | + } | |
| 1590 | + }, | |
| 1591 | + "node_modules/geojson-vt": { | |
| 1592 | + "version": "4.0.3", | |
| 1593 | + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-4.0.3.tgz", | |
| 1594 | + "integrity": "sha512-jR1MwkLaZGa8Zftct9ZFruyWFrdl9ZyD2OliXNy9Qq5bBPeg5wHVpBQF9p5GjnicSDQqvBVpysxTPKmWdsfWMA==", | |
| 1595 | + "license": "ISC" | |
| 1596 | + }, | |
| 1597 | + "node_modules/get-stream": { | |
| 1598 | + "version": "6.0.1", | |
| 1599 | + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", | |
| 1600 | + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", | |
| 1601 | + "license": "MIT", | |
| 1602 | + "engines": { | |
| 1603 | + "node": ">=10" | |
| 1604 | + }, | |
| 1605 | + "funding": { | |
| 1606 | + "url": "https://github.com/sponsors/sindresorhus" | |
| 1607 | + } | |
| 1608 | + }, | |
| 1609 | + "node_modules/gl-matrix": { | |
| 1610 | + "version": "3.4.4", | |
| 1611 | + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", | |
| 1612 | + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", | |
| 1613 | + "license": "MIT" | |
| 1614 | + }, | |
| 1615 | + "node_modules/global-prefix": { | |
| 1616 | + "version": "4.0.0", | |
| 1617 | + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-4.0.0.tgz", | |
| 1618 | + "integrity": "sha512-w0Uf9Y9/nyHinEk5vMJKRie+wa4kR5hmDbEhGGds/kG1PwGLLHKRoNMeJOyCQjjBkANlnScqgzcFwGHgmgLkVA==", | |
| 1619 | + "license": "MIT", | |
| 1620 | + "dependencies": { | |
| 1621 | + "ini": "^4.1.3", | |
| 1622 | + "kind-of": "^6.0.3", | |
| 1623 | + "which": "^4.0.0" | |
| 1624 | + }, | |
| 1625 | + "engines": { | |
| 1626 | + "node": ">=16" | |
| 1627 | + } | |
| 1628 | + }, | |
| 1629 | + "node_modules/ieee754": { | |
| 1630 | + "version": "1.2.1", | |
| 1631 | + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", | |
| 1632 | + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", | |
| 1633 | + "funding": [ | |
| 1634 | + { | |
| 1635 | + "type": "github", | |
| 1636 | + "url": "https://github.com/sponsors/feross" | |
| 1637 | + }, | |
| 1638 | + { | |
| 1639 | + "type": "patreon", | |
| 1640 | + "url": "https://www.patreon.com/feross" | |
| 1641 | + }, | |
| 1642 | + { | |
| 1643 | + "type": "consulting", | |
| 1644 | + "url": "https://feross.org/support" | |
| 1645 | + } | |
| 1646 | + ], | |
| 1647 | + "license": "BSD-3-Clause" | |
| 1648 | + }, | |
| 1649 | + "node_modules/ini": { | |
| 1650 | + "version": "4.1.3", | |
| 1651 | + "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.3.tgz", | |
| 1652 | + "integrity": "sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg==", | |
| 1653 | + "license": "ISC", | |
| 1654 | + "engines": { | |
| 1655 | + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" | |
| 1656 | + } | |
| 1657 | + }, | |
| 1658 | + "node_modules/isexe": { | |
| 1659 | + "version": "3.1.5", | |
| 1660 | + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", | |
| 1661 | + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", | |
| 1662 | + "license": "BlueOak-1.0.0", | |
| 1663 | + "engines": { | |
| 1664 | + "node": ">=18" | |
| 1665 | + } | |
| 1666 | + }, | |
| 1667 | + "node_modules/js-tokens": { | |
| 1668 | + "version": "4.0.0", | |
| 1669 | + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", | |
| 1670 | + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", | |
| 1671 | + "license": "MIT" | |
| 1672 | + }, | |
| 1673 | + "node_modules/jsesc": { | |
| 1674 | + "version": "3.1.0", | |
| 1675 | + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", | |
| 1676 | + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", | |
| 1677 | + "dev": true, | |
| 1678 | + "license": "MIT", | |
| 1679 | + "bin": { | |
| 1680 | + "jsesc": "bin/jsesc" | |
| 1681 | + }, | |
| 1682 | + "engines": { | |
| 1683 | + "node": ">=6" | |
| 1684 | + } | |
| 1685 | + }, | |
| 1686 | + "node_modules/json-stringify-pretty-compact": { | |
| 1687 | + "version": "4.0.0", | |
| 1688 | + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", | |
| 1689 | + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", | |
| 1690 | + "license": "MIT" | |
| 1691 | + }, | |
| 1692 | + "node_modules/json5": { | |
| 1693 | + "version": "2.2.3", | |
| 1694 | + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", | |
| 1695 | + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", | |
| 1696 | + "dev": true, | |
| 1697 | + "license": "MIT", | |
| 1698 | + "bin": { | |
| 1699 | + "json5": "lib/cli.js" | |
| 1700 | + }, | |
| 1701 | + "engines": { | |
| 1702 | + "node": ">=6" | |
| 1703 | + } | |
| 1704 | + }, | |
| 1705 | + "node_modules/kdbush": { | |
| 1706 | + "version": "4.1.0", | |
| 1707 | + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", | |
| 1708 | + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", | |
| 1709 | + "license": "ISC" | |
| 1710 | + }, | |
| 1711 | + "node_modules/kind-of": { | |
| 1712 | + "version": "6.0.3", | |
| 1713 | + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", | |
| 1714 | + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", | |
| 1715 | + "license": "MIT", | |
| 1716 | + "engines": { | |
| 1717 | + "node": ">=0.10.0" | |
| 1718 | + } | |
| 1719 | + }, | |
| 1720 | + "node_modules/loose-envify": { | |
| 1721 | + "version": "1.4.0", | |
| 1722 | + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", | |
| 1723 | + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", | |
| 1724 | + "license": "MIT", | |
| 1725 | + "dependencies": { | |
| 1726 | + "js-tokens": "^3.0.0 || ^4.0.0" | |
| 1727 | + }, | |
| 1728 | + "bin": { | |
| 1729 | + "loose-envify": "cli.js" | |
| 1730 | + } | |
| 1731 | + }, | |
| 1732 | + "node_modules/lru-cache": { | |
| 1733 | + "version": "5.1.1", | |
| 1734 | + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", | |
| 1735 | + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", | |
| 1736 | + "dev": true, | |
| 1737 | + "license": "ISC", | |
| 1738 | + "dependencies": { | |
| 1739 | + "yallist": "^3.0.2" | |
| 1740 | + } | |
| 1741 | + }, | |
| 1742 | + "node_modules/maplibre-gl": { | |
| 1743 | + "version": "4.7.1", | |
| 1744 | + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-4.7.1.tgz", | |
| 1745 | + "integrity": "sha512-lgL7XpIwsgICiL82ITplfS7IGwrB1OJIw/pCvprDp2dhmSSEBgmPzYRvwYYYvJGJD7fxUv1Tvpih4nZ6VrLuaA==", | |
| 1746 | + "license": "BSD-3-Clause", | |
| 1747 | + "dependencies": { | |
| 1748 | + "@mapbox/geojson-rewind": "^0.5.2", | |
| 1749 | + "@mapbox/jsonlint-lines-primitives": "^2.0.2", | |
| 1750 | + "@mapbox/point-geometry": "^0.1.0", | |
| 1751 | + "@mapbox/tiny-sdf": "^2.0.6", | |
| 1752 | + "@mapbox/unitbezier": "^0.0.1", | |
| 1753 | + "@mapbox/vector-tile": "^1.3.1", | |
| 1754 | + "@mapbox/whoots-js": "^3.1.0", | |
| 1755 | + "@maplibre/maplibre-gl-style-spec": "^20.3.1", | |
| 1756 | + "@types/geojson": "^7946.0.14", | |
| 1757 | + "@types/geojson-vt": "3.2.5", | |
| 1758 | + "@types/mapbox__point-geometry": "^0.1.4", | |
| 1759 | + "@types/mapbox__vector-tile": "^1.3.4", | |
| 1760 | + "@types/pbf": "^3.0.5", | |
| 1761 | + "@types/supercluster": "^7.1.3", | |
| 1762 | + "earcut": "^3.0.0", | |
| 1763 | + "geojson-vt": "^4.0.2", | |
| 1764 | + "gl-matrix": "^3.4.3", | |
| 1765 | + "global-prefix": "^4.0.0", | |
| 1766 | + "kdbush": "^4.0.2", | |
| 1767 | + "murmurhash-js": "^1.0.0", | |
| 1768 | + "pbf": "^3.3.0", | |
| 1769 | + "potpack": "^2.0.0", | |
| 1770 | + "quickselect": "^3.0.0", | |
| 1771 | + "supercluster": "^8.0.1", | |
| 1772 | + "tinyqueue": "^3.0.0", | |
| 1773 | + "vt-pbf": "^3.1.3" | |
| 1774 | + }, | |
| 1775 | + "engines": { | |
| 1776 | + "node": ">=16.14.0", | |
| 1777 | + "npm": ">=8.1.0" | |
| 1778 | + }, | |
| 1779 | + "funding": { | |
| 1780 | + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" | |
| 1781 | + } | |
| 1782 | + }, | |
| 1783 | + "node_modules/minimist": { | |
| 1784 | + "version": "1.2.8", | |
| 1785 | + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", | |
| 1786 | + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", | |
| 1787 | + "license": "MIT", | |
| 1788 | + "funding": { | |
| 1789 | + "url": "https://github.com/sponsors/ljharb" | |
| 1790 | + } | |
| 1791 | + }, | |
| 1792 | + "node_modules/ms": { | |
| 1793 | + "version": "2.1.3", | |
| 1794 | + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", | |
| 1795 | + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", | |
| 1796 | + "dev": true, | |
| 1797 | + "license": "MIT" | |
| 1798 | + }, | |
| 1799 | + "node_modules/murmurhash-js": { | |
| 1800 | + "version": "1.0.0", | |
| 1801 | + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", | |
| 1802 | + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", | |
| 1803 | + "license": "MIT" | |
| 1804 | + }, | |
| 1805 | + "node_modules/nanoid": { | |
| 1806 | + "version": "3.3.18", | |
| 1807 | + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", | |
| 1808 | + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", | |
| 1809 | + "dev": true, | |
| 1810 | + "funding": [ | |
| 1811 | + { | |
| 1812 | + "type": "github", | |
| 1813 | + "url": "https://github.com/sponsors/ai" | |
| 1814 | + } | |
| 1815 | + ], | |
| 1816 | + "license": "MIT", | |
| 1817 | + "bin": { | |
| 1818 | + "nanoid": "bin/nanoid.cjs" | |
| 1819 | + }, | |
| 1820 | + "engines": { | |
| 1821 | + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" | |
| 1822 | + } | |
| 1823 | + }, | |
| 1824 | + "node_modules/node-releases": { | |
| 1825 | + "version": "2.0.53", | |
| 1826 | + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", | |
| 1827 | + "integrity": "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ==", | |
| 1828 | + "dev": true, | |
| 1829 | + "license": "MIT", | |
| 1830 | + "engines": { | |
| 1831 | + "node": ">=18" | |
| 1832 | + } | |
| 1833 | + }, | |
| 1834 | + "node_modules/pbf": { | |
| 1835 | + "version": "3.3.0", | |
| 1836 | + "resolved": "https://registry.npmjs.org/pbf/-/pbf-3.3.0.tgz", | |
| 1837 | + "integrity": "sha512-XDF38WCH3z5OV/OVa8GKUNtLAyneuzbCisx7QUCF8Q6Nutx0WnJrQe5O+kOtBlLfRNUws98Y58Lblp+NJG5T4Q==", | |
| 1838 | + "license": "BSD-3-Clause", | |
| 1839 | + "dependencies": { | |
| 1840 | + "ieee754": "^1.1.12", | |
| 1841 | + "resolve-protobuf-schema": "^2.1.0" | |
| 1842 | + }, | |
| 1843 | + "bin": { | |
| 1844 | + "pbf": "bin/pbf" | |
| 1845 | + } | |
| 1846 | + }, | |
| 1847 | + "node_modules/picocolors": { | |
| 1848 | + "version": "1.1.1", | |
| 1849 | + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", | |
| 1850 | + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", | |
| 1851 | + "dev": true, | |
| 1852 | + "license": "ISC" | |
| 1853 | + }, | |
| 1854 | + "node_modules/postcss": { | |
| 1855 | + "version": "8.5.26", | |
| 1856 | + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", | |
| 1857 | + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", | |
| 1858 | + "dev": true, | |
| 1859 | + "funding": [ | |
| 1860 | + { | |
| 1861 | + "type": "opencollective", | |
| 1862 | + "url": "https://opencollective.com/postcss/" | |
| 1863 | + }, | |
| 1864 | + { | |
| 1865 | + "type": "tidelift", | |
| 1866 | + "url": "https://tidelift.com/funding/github/npm/postcss" | |
| 1867 | + }, | |
| 1868 | + { | |
| 1869 | + "type": "github", | |
| 1870 | + "url": "https://github.com/sponsors/ai" | |
| 1871 | + } | |
| 1872 | + ], | |
| 1873 | + "license": "MIT", | |
| 1874 | + "dependencies": { | |
| 1875 | + "nanoid": "^3.3.17", | |
| 1876 | + "picocolors": "^1.1.1", | |
| 1877 | + "source-map-js": "^1.2.1" | |
| 1878 | + }, | |
| 1879 | + "engines": { | |
| 1880 | + "node": "^10 || ^12 || >=14" | |
| 1881 | + } | |
| 1882 | + }, | |
| 1883 | + "node_modules/potpack": { | |
| 1884 | + "version": "2.1.0", | |
| 1885 | + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", | |
| 1886 | + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", | |
| 1887 | + "license": "ISC" | |
| 1888 | + }, | |
| 1889 | + "node_modules/protocol-buffers-schema": { | |
| 1890 | + "version": "3.6.1", | |
| 1891 | + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", | |
| 1892 | + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", | |
| 1893 | + "license": "MIT" | |
| 1894 | + }, | |
| 1895 | + "node_modules/quickselect": { | |
| 1896 | + "version": "3.0.0", | |
| 1897 | + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", | |
| 1898 | + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", | |
| 1899 | + "license": "ISC" | |
| 1900 | + }, | |
| 1901 | + "node_modules/react": { | |
| 1902 | + "version": "18.3.1", | |
| 1903 | + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", | |
| 1904 | + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", | |
| 1905 | + "license": "MIT", | |
| 1906 | + "dependencies": { | |
| 1907 | + "loose-envify": "^1.1.0" | |
| 1908 | + }, | |
| 1909 | + "engines": { | |
| 1910 | + "node": ">=0.10.0" | |
| 1911 | + } | |
| 1912 | + }, | |
| 1913 | + "node_modules/react-dom": { | |
| 1914 | + "version": "18.3.1", | |
| 1915 | + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", | |
| 1916 | + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", | |
| 1917 | + "license": "MIT", | |
| 1918 | + "dependencies": { | |
| 1919 | + "loose-envify": "^1.1.0", | |
| 1920 | + "scheduler": "^0.23.2" | |
| 1921 | + }, | |
| 1922 | + "peerDependencies": { | |
| 1923 | + "react": "^18.3.1" | |
| 1924 | + } | |
| 1925 | + }, | |
| 1926 | + "node_modules/react-refresh": { | |
| 1927 | + "version": "0.17.0", | |
| 1928 | + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", | |
| 1929 | + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", | |
| 1930 | + "dev": true, | |
| 1931 | + "license": "MIT", | |
| 1932 | + "engines": { | |
| 1933 | + "node": ">=0.10.0" | |
| 1934 | + } | |
| 1935 | + }, | |
| 1936 | + "node_modules/react-router": { | |
| 1937 | + "version": "6.30.4", | |
| 1938 | + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz", | |
| 1939 | + "integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==", | |
| 1940 | + "license": "MIT", | |
| 1941 | + "dependencies": { | |
| 1942 | + "@remix-run/router": "1.23.3" | |
| 1943 | + }, | |
| 1944 | + "engines": { | |
| 1945 | + "node": ">=14.0.0" | |
| 1946 | + }, | |
| 1947 | + "peerDependencies": { | |
| 1948 | + "react": ">=16.8" | |
| 1949 | + } | |
| 1950 | + }, | |
| 1951 | + "node_modules/react-router-dom": { | |
| 1952 | + "version": "6.30.4", | |
| 1953 | + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz", | |
| 1954 | + "integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==", | |
| 1955 | + "license": "MIT", | |
| 1956 | + "dependencies": { | |
| 1957 | + "@remix-run/router": "1.23.3", | |
| 1958 | + "react-router": "6.30.4" | |
| 1959 | + }, | |
| 1960 | + "engines": { | |
| 1961 | + "node": ">=14.0.0" | |
| 1962 | + }, | |
| 1963 | + "peerDependencies": { | |
| 1964 | + "react": ">=16.8", | |
| 1965 | + "react-dom": ">=16.8" | |
| 1966 | + } | |
| 1967 | + }, | |
| 1968 | + "node_modules/resolve-protobuf-schema": { | |
| 1969 | + "version": "2.1.0", | |
| 1970 | + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", | |
| 1971 | + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", | |
| 1972 | + "license": "MIT", | |
| 1973 | + "dependencies": { | |
| 1974 | + "protocol-buffers-schema": "^3.3.1" | |
| 1975 | + } | |
| 1976 | + }, | |
| 1977 | + "node_modules/rollup": { | |
| 1978 | + "version": "4.62.4", | |
| 1979 | + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", | |
| 1980 | + "integrity": "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg==", | |
| 1981 | + "dev": true, | |
| 1982 | + "license": "MIT", | |
| 1983 | + "dependencies": { | |
| 1984 | + "@types/estree": "1.0.9" | |
| 1985 | + }, | |
| 1986 | + "bin": { | |
| 1987 | + "rollup": "dist/bin/rollup" | |
| 1988 | + }, | |
| 1989 | + "engines": { | |
| 1990 | + "node": ">=18.0.0", | |
| 1991 | + "npm": ">=8.0.0" | |
| 1992 | + }, | |
| 1993 | + "optionalDependencies": { | |
| 1994 | + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", | |
| 1995 | + "@rollup/rollup-android-arm-eabi": "4.62.4", | |
| 1996 | + "@rollup/rollup-android-arm64": "4.62.4", | |
| 1997 | + "@rollup/rollup-darwin-arm64": "4.62.4", | |
| 1998 | + "@rollup/rollup-darwin-x64": "4.62.4", | |
| 1999 | + "@rollup/rollup-freebsd-arm64": "4.62.4", | |
| 2000 | + "@rollup/rollup-freebsd-x64": "4.62.4", | |
| 2001 | + "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", | |
| 2002 | + "@rollup/rollup-linux-arm-musleabihf": "4.62.4", | |
| 2003 | + "@rollup/rollup-linux-arm64-gnu": "4.62.4", | |
| 2004 | + "@rollup/rollup-linux-arm64-musl": "4.62.4", | |
| 2005 | + "@rollup/rollup-linux-loong64-gnu": "4.62.4", | |
| 2006 | + "@rollup/rollup-linux-loong64-musl": "4.62.4", | |
| 2007 | + "@rollup/rollup-linux-ppc64-gnu": "4.62.4", | |
| 2008 | + "@rollup/rollup-linux-ppc64-musl": "4.62.4", | |
| 2009 | + "@rollup/rollup-linux-riscv64-gnu": "4.62.4", | |
| 2010 | + "@rollup/rollup-linux-riscv64-musl": "4.62.4", | |
| 2011 | + "@rollup/rollup-linux-s390x-gnu": "4.62.4", | |
| 2012 | + "@rollup/rollup-linux-x64-gnu": "4.62.4", | |
| 2013 | + "@rollup/rollup-linux-x64-musl": "4.62.4", | |
| 2014 | + "@rollup/rollup-openbsd-x64": "4.62.4", | |
| 2015 | + "@rollup/rollup-openharmony-arm64": "4.62.4", | |
| 2016 | + "@rollup/rollup-win32-arm64-msvc": "4.62.4", | |
| 2017 | + "@rollup/rollup-win32-ia32-msvc": "4.62.4", | |
| 2018 | + "@rollup/rollup-win32-x64-gnu": "4.62.4", | |
| 2019 | + "@rollup/rollup-win32-x64-msvc": "4.62.4", | |
| 2020 | + "fsevents": "~2.3.2" | |
| 2021 | + } | |
| 2022 | + }, | |
| 2023 | + "node_modules/rw": { | |
| 2024 | + "version": "1.3.3", | |
| 2025 | + "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", | |
| 2026 | + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", | |
| 2027 | + "license": "BSD-3-Clause" | |
| 2028 | + }, | |
| 2029 | + "node_modules/scheduler": { | |
| 2030 | + "version": "0.23.2", | |
| 2031 | + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", | |
| 2032 | + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", | |
| 2033 | + "license": "MIT", | |
| 2034 | + "dependencies": { | |
| 2035 | + "loose-envify": "^1.1.0" | |
| 2036 | + } | |
| 2037 | + }, | |
| 2038 | + "node_modules/semver": { | |
| 2039 | + "version": "6.3.1", | |
| 2040 | + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", | |
| 2041 | + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", | |
| 2042 | + "dev": true, | |
| 2043 | + "license": "ISC", | |
| 2044 | + "bin": { | |
| 2045 | + "semver": "bin/semver.js" | |
| 2046 | + } | |
| 2047 | + }, | |
| 2048 | + "node_modules/source-map-js": { | |
| 2049 | + "version": "1.2.1", | |
| 2050 | + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", | |
| 2051 | + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", | |
| 2052 | + "dev": true, | |
| 2053 | + "license": "BSD-3-Clause", | |
| 2054 | + "engines": { | |
| 2055 | + "node": ">=0.10.0" | |
| 2056 | + } | |
| 2057 | + }, | |
| 2058 | + "node_modules/supercluster": { | |
| 2059 | + "version": "8.0.1", | |
| 2060 | + "resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz", | |
| 2061 | + "integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==", | |
| 2062 | + "license": "ISC", | |
| 2063 | + "dependencies": { | |
| 2064 | + "kdbush": "^4.0.2" | |
| 2065 | + } | |
| 2066 | + }, | |
| 2067 | + "node_modules/tinyqueue": { | |
| 2068 | + "version": "3.0.0", | |
| 2069 | + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", | |
| 2070 | + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", | |
| 2071 | + "license": "ISC" | |
| 2072 | + }, | |
| 2073 | + "node_modules/typescript": { | |
| 2074 | + "version": "5.9.3", | |
| 2075 | + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", | |
| 2076 | + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", | |
| 2077 | + "dev": true, | |
| 2078 | + "license": "Apache-2.0", | |
| 2079 | + "bin": { | |
| 2080 | + "tsc": "bin/tsc", | |
| 2081 | + "tsserver": "bin/tsserver" | |
| 2082 | + }, | |
| 2083 | + "engines": { | |
| 2084 | + "node": ">=14.17" | |
| 2085 | + } | |
| 2086 | + }, | |
| 2087 | + "node_modules/update-browserslist-db": { | |
| 2088 | + "version": "1.3.1", | |
| 2089 | + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.1.tgz", | |
| 2090 | + "integrity": "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==", | |
| 2091 | + "dev": true, | |
| 2092 | + "funding": [ | |
| 2093 | + { | |
| 2094 | + "type": "opencollective", | |
| 2095 | + "url": "https://opencollective.com/browserslist" | |
| 2096 | + }, | |
| 2097 | + { | |
| 2098 | + "type": "tidelift", | |
| 2099 | + "url": "https://tidelift.com/funding/github/npm/browserslist" | |
| 2100 | + }, | |
| 2101 | + { | |
| 2102 | + "type": "github", | |
| 2103 | + "url": "https://github.com/sponsors/ai" | |
| 2104 | + } | |
| 2105 | + ], | |
| 2106 | + "license": "MIT", | |
| 2107 | + "dependencies": { | |
| 2108 | + "escalade": "^3.2.0", | |
| 2109 | + "picocolors": "^1.1.1" | |
| 2110 | + }, | |
| 2111 | + "bin": { | |
| 2112 | + "update-browserslist-db": "cli.js" | |
| 2113 | + }, | |
| 2114 | + "peerDependencies": { | |
| 2115 | + "browserslist": ">= 4.21.0" | |
| 2116 | + } | |
| 2117 | + }, | |
| 2118 | + "node_modules/vite": { | |
| 2119 | + "version": "5.4.21", | |
| 2120 | + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", | |
| 2121 | + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", | |
| 2122 | + "dev": true, | |
| 2123 | + "license": "MIT", | |
| 2124 | + "dependencies": { | |
| 2125 | + "esbuild": "^0.21.3", | |
| 2126 | + "postcss": "^8.4.43", | |
| 2127 | + "rollup": "^4.20.0" | |
| 2128 | + }, | |
| 2129 | + "bin": { | |
| 2130 | + "vite": "bin/vite.js" | |
| 2131 | + }, | |
| 2132 | + "engines": { | |
| 2133 | + "node": "^18.0.0 || >=20.0.0" | |
| 2134 | + }, | |
| 2135 | + "funding": { | |
| 2136 | + "url": "https://github.com/vitejs/vite?sponsor=1" | |
| 2137 | + }, | |
| 2138 | + "optionalDependencies": { | |
| 2139 | + "fsevents": "~2.3.3" | |
| 2140 | + }, | |
| 2141 | + "peerDependencies": { | |
| 2142 | + "@types/node": "^18.0.0 || >=20.0.0", | |
| 2143 | + "less": "*", | |
| 2144 | + "lightningcss": "^1.21.0", | |
| 2145 | + "sass": "*", | |
| 2146 | + "sass-embedded": "*", | |
| 2147 | + "stylus": "*", | |
| 2148 | + "sugarss": "*", | |
| 2149 | + "terser": "^5.4.0" | |
| 2150 | + }, | |
| 2151 | + "peerDependenciesMeta": { | |
| 2152 | + "@types/node": { | |
| 2153 | + "optional": true | |
| 2154 | + }, | |
| 2155 | + "less": { | |
| 2156 | + "optional": true | |
| 2157 | + }, | |
| 2158 | + "lightningcss": { | |
| 2159 | + "optional": true | |
| 2160 | + }, | |
| 2161 | + "sass": { | |
| 2162 | + "optional": true | |
| 2163 | + }, | |
| 2164 | + "sass-embedded": { | |
| 2165 | + "optional": true | |
| 2166 | + }, | |
| 2167 | + "stylus": { | |
| 2168 | + "optional": true | |
| 2169 | + }, | |
| 2170 | + "sugarss": { | |
| 2171 | + "optional": true | |
| 2172 | + }, | |
| 2173 | + "terser": { | |
| 2174 | + "optional": true | |
| 2175 | + } | |
| 2176 | + } | |
| 2177 | + }, | |
| 2178 | + "node_modules/vt-pbf": { | |
| 2179 | + "version": "3.1.3", | |
| 2180 | + "resolved": "https://registry.npmjs.org/vt-pbf/-/vt-pbf-3.1.3.tgz", | |
| 2181 | + "integrity": "sha512-2LzDFzt0mZKZ9IpVF2r69G9bXaP2Q2sArJCmcCgvfTdCCZzSyz4aCLoQyUilu37Ll56tCblIZrXFIjNUpGIlmA==", | |
| 2182 | + "license": "MIT", | |
| 2183 | + "dependencies": { | |
| 2184 | + "@mapbox/point-geometry": "0.1.0", | |
| 2185 | + "@mapbox/vector-tile": "^1.3.1", | |
| 2186 | + "pbf": "^3.2.1" | |
| 2187 | + } | |
| 2188 | + }, | |
| 2189 | + "node_modules/which": { | |
| 2190 | + "version": "4.0.0", | |
| 2191 | + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", | |
| 2192 | + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", | |
| 2193 | + "license": "ISC", | |
| 2194 | + "dependencies": { | |
| 2195 | + "isexe": "^3.1.1" | |
| 2196 | + }, | |
| 2197 | + "bin": { | |
| 2198 | + "node-which": "bin/which.js" | |
| 2199 | + }, | |
| 2200 | + "engines": { | |
| 2201 | + "node": "^16.13.0 || >=18.0.0" | |
| 2202 | + } | |
| 2203 | + }, | |
| 2204 | + "node_modules/yallist": { | |
| 2205 | + "version": "3.1.1", | |
| 2206 | + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", | |
| 2207 | + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", | |
| 2208 | + "dev": true, | |
| 2209 | + "license": "ISC" | |
| 2210 | + } | |
| 2211 | + } | |
| 2212 | +} | |
added
frontend/package.json
+26 −0
@@ -0,0 +1,26 @@ | ||
| 1 | +{ | |
| 2 | + "name": "jobka-frontend", | |
| 3 | + "private": true, | |
| 4 | + "version": "1.0.0", | |
| 5 | + "description": "Frontend Job·Ka — agrégateur d'offres d'emploi québécoises (Groupe KA)", | |
| 6 | + "author": "Simon-Pierre Boucher <contact@spboucher.ai>", | |
| 7 | + "type": "module", | |
| 8 | + "scripts": { | |
| 9 | + "dev": "vite", | |
| 10 | + "build": "tsc -b && vite build", | |
| 11 | + "preview": "vite preview" | |
| 12 | + }, | |
| 13 | + "dependencies": { | |
| 14 | + "maplibre-gl": "^4.7.1", | |
| 15 | + "react": "^18.3.1", | |
| 16 | + "react-dom": "^18.3.1", | |
| 17 | + "react-router-dom": "^6.26.2" | |
| 18 | + }, | |
| 19 | + "devDependencies": { | |
| 20 | + "@types/react": "^18.3.5", | |
| 21 | + "@types/react-dom": "^18.3.0", | |
| 22 | + "@vitejs/plugin-react": "^4.3.1", | |
| 23 | + "typescript": "^5.5.4", | |
| 24 | + "vite": "^5.4.3" | |
| 25 | + } | |
| 26 | +} | |
added
frontend/src/App.tsx
+60 −0
@@ -0,0 +1,60 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * Job·Ka — Groupe KA | |
| 4 | + * Auteur : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * Fichier : frontend/src/App.tsx | |
| 7 | + * Rôle : Gabarit global (en-tête, navigation, routes, pied de page) | |
| 8 | + * Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | + * ============================================================================= | |
| 10 | + */ | |
| 11 | +import { lazy, Suspense } from "react"; | |
| 12 | +import { NavLink, Route, Routes } from "react-router-dom"; | |
| 13 | +import Home from "./pages/Home"; | |
| 14 | +import JobPage from "./pages/Job"; | |
| 15 | +import Sources from "./pages/Sources"; | |
| 16 | +import Stats from "./pages/Stats"; | |
| 17 | + | |
| 18 | +// MapLibre pèse ~700 kB : chargé uniquement quand on visite /carte | |
| 19 | +const MapPage = lazy(() => import("./pages/MapPage")); | |
| 20 | + | |
| 21 | +export default function App() { | |
| 22 | + return ( | |
| 23 | + <> | |
| 24 | + <header className="site"> | |
| 25 | + <div className="container"> | |
| 26 | + <NavLink to="/" className="logo">Job<em>·</em>Ka</NavLink> | |
| 27 | + <nav className="main"> | |
| 28 | + <NavLink to="/" end className={({ isActive }) => (isActive ? "active" : "")}>Offres</NavLink> | |
| 29 | + <NavLink to="/carte" className={({ isActive }) => (isActive ? "active" : "")}>Carte</NavLink> | |
| 30 | + <NavLink to="/sources" className={({ isActive }) => (isActive ? "active" : "")}>Employeurs</NavLink> | |
| 31 | + <NavLink to="/stats" className={({ isActive }) => (isActive ? "active" : "")}>Statistiques</NavLink> | |
| 32 | + </nav> | |
| 33 | + </div> | |
| 34 | + </header> | |
| 35 | + <Routes> | |
| 36 | + <Route path="/" element={<Home />} /> | |
| 37 | + <Route path="/carte" element={ | |
| 38 | + <Suspense fallback={<div className="container empty">Chargement de la carte…</div>}> | |
| 39 | + <MapPage /> | |
| 40 | + </Suspense> | |
| 41 | + } /> | |
| 42 | + <Route path="/emploi/:uid" element={<JobPage />} /> | |
| 43 | + <Route path="/sources" element={<Sources />} /> | |
| 44 | + <Route path="/stats" element={<Stats />} /> | |
| 45 | + <Route path="*" element={<div className="container empty"><h2>Page introuvable</h2></div>} /> | |
| 46 | + </Routes> | |
| 47 | + <footer className="site"> | |
| 48 | + <div className="container"> | |
| 49 | + <span> | |
| 50 | + Une plateforme du <a href="https://www.groupe-ka.com">Groupe KA</a> —{" "} | |
| 51 | + <a href="mailto:contact@spboucher.ai">contact@spboucher.ai</a> | |
| 52 | + </span> | |
| 53 | + <span> | |
| 54 | + Chaque offre est traçable à sa source originale. Zéro boîte noire. | |
| 55 | + </span> | |
| 56 | + </div> | |
| 57 | + </footer> | |
| 58 | + </> | |
| 59 | + ); | |
| 60 | +} | |
added
frontend/src/api.ts
+171 −0
@@ -0,0 +1,171 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * Job·Ka — Groupe KA | |
| 4 | + * Auteur : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * Fichier : frontend/src/api.ts | |
| 7 | + * Rôle : Types + client API typé (timeout, erreurs) — miroir de jobka/web.py | |
| 8 | + * Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | + * ============================================================================= | |
| 10 | + */ | |
| 11 | + | |
| 12 | +export interface Job { | |
| 13 | + uid: string; | |
| 14 | + source: string; | |
| 15 | + external_id: string; | |
| 16 | + url: string; | |
| 17 | + employer: string; | |
| 18 | + title: string; | |
| 19 | + description?: string; | |
| 20 | + address: string; | |
| 21 | + city: string; | |
| 22 | + region: string; | |
| 23 | + postal_code: string; | |
| 24 | + location_label: string; | |
| 25 | + work_mode: string | null; | |
| 26 | + employment_type: string | null; | |
| 27 | + salary_min: number | null; | |
| 28 | + salary_max: number | null; | |
| 29 | + salary_unit: string | null; | |
| 30 | + salary_label: string; | |
| 31 | + salary_year_min: number | null; | |
| 32 | + salary_year_max: number | null; | |
| 33 | + salary_hour_min: number | null; | |
| 34 | + salary_hour_max: number | null; | |
| 35 | + benefits: string[]; | |
| 36 | + requirements: Record<string, unknown>; | |
| 37 | + date_posted: string | null; | |
| 38 | + date_deadline: string | null; | |
| 39 | + category: string; | |
| 40 | + ats: string; | |
| 41 | + details: Record<string, unknown>; | |
| 42 | + lat: number | null; | |
| 43 | + lng: number | null; | |
| 44 | + active: number; | |
| 45 | + dup_sources?: string[]; | |
| 46 | +} | |
| 47 | + | |
| 48 | +export interface JobList { | |
| 49 | + total: number; | |
| 50 | + count: number; | |
| 51 | + jobs: Job[]; | |
| 52 | +} | |
| 53 | + | |
| 54 | +export interface Facets { | |
| 55 | + cities: string[]; | |
| 56 | + categories: { category: string; n: number }[]; | |
| 57 | + employers: { employer: string; n: number }[]; | |
| 58 | + work_modes: string[]; | |
| 59 | + employment_types: string[]; | |
| 60 | + sources: { source: string; n: number }[]; | |
| 61 | +} | |
| 62 | + | |
| 63 | +export interface Source { | |
| 64 | + id: string; | |
| 65 | + name: string; | |
| 66 | + url: string; | |
| 67 | + careers_url: string; | |
| 68 | + sectors: string[]; | |
| 69 | + connector: string; | |
| 70 | + status: string; | |
| 71 | + region: string; | |
| 72 | + active_jobs: number; | |
| 73 | + last_sync: number | null; | |
| 74 | +} | |
| 75 | + | |
| 76 | +export interface Stats { | |
| 77 | + total: number; | |
| 78 | + employers: number; | |
| 79 | + sources: number; | |
| 80 | + with_salary: number; | |
| 81 | + avg_salary_year: number | null; | |
| 82 | + remote: number; | |
| 83 | + geocoded: number; | |
| 84 | + top_cities: { city: string; n: number }[]; | |
| 85 | + categories: { category: string; n: number }[]; | |
| 86 | + recent_syncs: { | |
| 87 | + source: string; ts: number; found: number; added: number; | |
| 88 | + updated: number; removed: number; ok: number; message: string; | |
| 89 | + }[]; | |
| 90 | +} | |
| 91 | + | |
| 92 | +async function get<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T> { | |
| 93 | + const url = new URL(path, window.location.origin); | |
| 94 | + if (params) { | |
| 95 | + for (const [k, v] of Object.entries(params)) { | |
| 96 | + if (v !== undefined && v !== "") url.searchParams.set(k, String(v)); | |
| 97 | + } | |
| 98 | + } | |
| 99 | + const ctl = new AbortController(); | |
| 100 | + const timer = setTimeout(() => ctl.abort(), 20000); | |
| 101 | + try { | |
| 102 | + const resp = await fetch(url.toString(), { signal: ctl.signal }); | |
| 103 | + if (!resp.ok) throw new Error(`API ${resp.status} : ${path}`); | |
| 104 | + return (await resp.json()) as T; | |
| 105 | + } finally { | |
| 106 | + clearTimeout(timer); | |
| 107 | + } | |
| 108 | +} | |
| 109 | + | |
| 110 | +export interface JobFilters { | |
| 111 | + q?: string; | |
| 112 | + city?: string; | |
| 113 | + category?: string; | |
| 114 | + work_mode?: string; | |
| 115 | + employment_type?: string; | |
| 116 | + salary_min?: number; | |
| 117 | + with_salary?: number; | |
| 118 | + sort?: string; | |
| 119 | +} | |
| 120 | + | |
| 121 | +export const fetchJobs = (filters: JobFilters, limit = 30, offset = 0) => | |
| 122 | + get<JobList>("/api/jobs", { ...filters, limit, offset }); | |
| 123 | + | |
| 124 | +export const fetchJob = (uid: string) => get<Job>(`/api/jobs/${uid}`); | |
| 125 | + | |
| 126 | +export const fetchFacets = () => get<Facets>("/api/facets"); | |
| 127 | + | |
| 128 | +export const fetchSources = () => get<{ sources: Source[] }>("/api/sources"); | |
| 129 | + | |
| 130 | +export const fetchStats = () => get<Stats>("/api/stats"); | |
| 131 | + | |
| 132 | +export const geojsonUrl = (filters: JobFilters) => { | |
| 133 | + const url = new URL("/api/jobs.geojson", window.location.origin); | |
| 134 | + for (const [k, v] of Object.entries(filters)) { | |
| 135 | + if (v !== undefined && v !== "") url.searchParams.set(k, String(v)); | |
| 136 | + } | |
| 137 | + return url.toString(); | |
| 138 | +}; | |
| 139 | + | |
| 140 | +// --- présentation ------------------------------------------------------------ | |
| 141 | + | |
| 142 | +const UNIT_FR: Record<string, string> = { | |
| 143 | + hour: "h", day: "jour", week: "sem.", biweek: "2 sem.", month: "mois", year: "an", | |
| 144 | +}; | |
| 145 | + | |
| 146 | +export function formatSalary(job: Pick<Job, "salary_min" | "salary_max" | "salary_unit">): string | null { | |
| 147 | + if (job.salary_min == null || !job.salary_unit) return null; | |
| 148 | + const fmt = (n: number) => | |
| 149 | + n >= 1000 ? n.toLocaleString("fr-CA", { maximumFractionDigits: 0 }) | |
| 150 | + : n.toLocaleString("fr-CA", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); | |
| 151 | + const unit = UNIT_FR[job.salary_unit] ?? job.salary_unit; | |
| 152 | + if (job.salary_max != null && job.salary_max > job.salary_min) { | |
| 153 | + return `${fmt(job.salary_min)} $ à ${fmt(job.salary_max)} $ / ${unit}`; | |
| 154 | + } | |
| 155 | + return `${fmt(job.salary_min)} $ / ${unit}`; | |
| 156 | +} | |
| 157 | + | |
| 158 | +export const MODE_FR: Record<string, string> = { | |
| 159 | + presentiel: "Présentiel", hybride: "Hybride", teletravail: "Télétravail", | |
| 160 | +}; | |
| 161 | + | |
| 162 | +export const TYPE_FR: Record<string, string> = { | |
| 163 | + temps_plein: "Temps plein", temps_partiel: "Temps partiel", | |
| 164 | + contractuel: "Contractuel", stage: "Stage", saisonnier: "Saisonnier", | |
| 165 | +}; | |
| 166 | + | |
| 167 | +export function formatDate(iso: string | null): string { | |
| 168 | + if (!iso) return ""; | |
| 169 | + const d = new Date(`${iso}T12:00:00`); | |
| 170 | + return d.toLocaleDateString("fr-CA", { day: "numeric", month: "long", year: "numeric" }); | |
| 171 | +} | |
added
frontend/src/components/JobCard.tsx
+30 −0
@@ -0,0 +1,30 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * Job·Ka — Groupe KA | |
| 4 | + * Auteur : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * Fichier : frontend/src/components/JobCard.tsx | |
| 7 | + * Rôle : Carte d'offre dans la liste (titre, employeur, badges, meta) | |
| 8 | + * Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | + * ============================================================================= | |
| 10 | + */ | |
| 11 | +import { Link } from "react-router-dom"; | |
| 12 | +import { formatDate, formatSalary, Job, MODE_FR, TYPE_FR } from "../api"; | |
| 13 | + | |
| 14 | +export default function JobCard({ job }: { job: Job }) { | |
| 15 | + const salary = formatSalary(job); | |
| 16 | + return ( | |
| 17 | + <Link to={`/emploi/${job.uid}`} className="job-card"> | |
| 18 | + <h3>{job.title}</h3> | |
| 19 | + <span className="employer">{job.employer}</span> | |
| 20 | + {job.city ? <span className="muted"> — {job.city}</span> : null} | |
| 21 | + <div className="meta"> | |
| 22 | + {salary && <span className="badge salary">{salary}</span>} | |
| 23 | + {job.work_mode && <span className="badge mode">{MODE_FR[job.work_mode] ?? job.work_mode}</span>} | |
| 24 | + {job.employment_type && <span className="badge">{TYPE_FR[job.employment_type] ?? job.employment_type}</span>} | |
| 25 | + {job.category && <span className="badge">{job.category}</span>} | |
| 26 | + {job.date_posted && <span>Publiée le {formatDate(job.date_posted)}</span>} | |
| 27 | + </div> | |
| 28 | + </Link> | |
| 29 | + ); | |
| 30 | +} | |
added
frontend/src/main.tsx
+23 −0
@@ -0,0 +1,23 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * Job·Ka — Groupe KA | |
| 4 | + * Auteur : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * Fichier : frontend/src/main.tsx | |
| 7 | + * Rôle : Point d'entrée React (StrictMode + routeur) | |
| 8 | + * Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | + * ============================================================================= | |
| 10 | + */ | |
| 11 | +import React from "react"; | |
| 12 | +import ReactDOM from "react-dom/client"; | |
| 13 | +import { BrowserRouter } from "react-router-dom"; | |
| 14 | +import App from "./App"; | |
| 15 | +import "./styles.css"; | |
| 16 | + | |
| 17 | +ReactDOM.createRoot(document.getElementById("root")!).render( | |
| 18 | + <React.StrictMode> | |
| 19 | + <BrowserRouter> | |
| 20 | + <App /> | |
| 21 | + </BrowserRouter> | |
| 22 | + </React.StrictMode>, | |
| 23 | +); | |
added
frontend/src/pages/Home.tsx
+136 −0
@@ -0,0 +1,136 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * Job·Ka — Groupe KA | |
| 4 | + * Auteur : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * Fichier : frontend/src/pages/Home.tsx | |
| 7 | + * Rôle : Page d'accueil — recherche, filtres, liste paginée des offres | |
| 8 | + * Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | + * ============================================================================= | |
| 10 | + */ | |
| 11 | +import { useEffect, useMemo, useState } from "react"; | |
| 12 | +import { useSearchParams } from "react-router-dom"; | |
| 13 | +import { Facets, fetchFacets, fetchJobs, Job, MODE_FR, TYPE_FR } from "../api"; | |
| 14 | +import JobCard from "../components/JobCard"; | |
| 15 | + | |
| 16 | +const PAGE = 30; | |
| 17 | + | |
| 18 | +export default function Home() { | |
| 19 | + const [params, setParams] = useSearchParams(); | |
| 20 | + const [facets, setFacets] = useState<Facets | null>(null); | |
| 21 | + const [jobs, setJobs] = useState<Job[]>([]); | |
| 22 | + const [total, setTotal] = useState(0); | |
| 23 | + const [loading, setLoading] = useState(true); | |
| 24 | + const [error, setError] = useState(""); | |
| 25 | + | |
| 26 | + const filters = useMemo(() => ({ | |
| 27 | + q: params.get("q") ?? "", | |
| 28 | + city: params.get("ville") ?? "", | |
| 29 | + category: params.get("categorie") ?? "", | |
| 30 | + work_mode: params.get("mode") ?? "", | |
| 31 | + employment_type: params.get("type") ?? "", | |
| 32 | + with_salary: params.get("salaire") === "1" ? 1 : undefined, | |
| 33 | + sort: params.get("tri") ?? "recent", | |
| 34 | + }), [params]); | |
| 35 | + const page = Math.max(1, parseInt(params.get("page") ?? "1", 10)); | |
| 36 | + | |
| 37 | + useEffect(() => { fetchFacets().then(setFacets).catch(() => {}); }, []); | |
| 38 | + | |
| 39 | + useEffect(() => { | |
| 40 | + setLoading(true); | |
| 41 | + setError(""); | |
| 42 | + fetchJobs(filters, PAGE, (page - 1) * PAGE) | |
| 43 | + .then((r) => { setJobs(r.jobs); setTotal(r.total); }) | |
| 44 | + .catch((e) => setError(String(e))) | |
| 45 | + .finally(() => setLoading(false)); | |
| 46 | + }, [filters, page]); | |
| 47 | + | |
| 48 | + const setParam = (key: string, value: string) => { | |
| 49 | + const next = new URLSearchParams(params); | |
| 50 | + if (value) next.set(key, value); else next.delete(key); | |
| 51 | + next.delete("page"); | |
| 52 | + setParams(next, { replace: true }); | |
| 53 | + }; | |
| 54 | + | |
| 55 | + const pages = Math.max(1, Math.ceil(total / PAGE)); | |
| 56 | + | |
| 57 | + return ( | |
| 58 | + <> | |
| 59 | + <section className="hero"> | |
| 60 | + <div className="container"> | |
| 61 | + <h1>L'emploi au Québec, <em>à la source</em>.</h1> | |
| 62 | + <p> | |
| 63 | + Job·Ka agrège les offres directement depuis les pages carrières des | |
| 64 | + employeurs québécois. Salaires transparents, lien vers l'offre | |
| 65 | + originale, aucun intermédiaire. | |
| 66 | + </p> | |
| 67 | + <div className="filters"> | |
| 68 | + <input | |
| 69 | + type="search" | |
| 70 | + placeholder="Titre, employeur, mots-clés…" | |
| 71 | + defaultValue={filters.q} | |
| 72 | + onKeyDown={(e) => { | |
| 73 | + if (e.key === "Enter") setParam("q", (e.target as HTMLInputElement).value); | |
| 74 | + }} | |
| 75 | + /> | |
| 76 | + <select value={filters.city} onChange={(e) => setParam("ville", e.target.value)}> | |
| 77 | + <option value="">Toutes les villes</option> | |
| 78 | + {facets?.cities.map((c) => <option key={c} value={c}>{c}</option>)} | |
| 79 | + </select> | |
| 80 | + <select value={filters.category} onChange={(e) => setParam("categorie", e.target.value)}> | |
| 81 | + <option value="">Toutes les catégories</option> | |
| 82 | + {facets?.categories.map((c) => ( | |
| 83 | + <option key={c.category} value={c.category}>{c.category} ({c.n})</option> | |
| 84 | + ))} | |
| 85 | + </select> | |
| 86 | + <select value={filters.work_mode} onChange={(e) => setParam("mode", e.target.value)}> | |
| 87 | + <option value="">Tous les modes</option> | |
| 88 | + {facets?.work_modes.map((m) => <option key={m} value={m}>{MODE_FR[m] ?? m}</option>)} | |
| 89 | + </select> | |
| 90 | + <select value={filters.employment_type} onChange={(e) => setParam("type", e.target.value)}> | |
| 91 | + <option value="">Tous les types</option> | |
| 92 | + {facets?.employment_types.map((t) => <option key={t} value={t}>{TYPE_FR[t] ?? t}</option>)} | |
| 93 | + </select> | |
| 94 | + <label className="check"> | |
| 95 | + <input | |
| 96 | + type="checkbox" | |
| 97 | + checked={filters.with_salary === 1} | |
| 98 | + onChange={(e) => setParam("salaire", e.target.checked ? "1" : "")} | |
| 99 | + /> | |
| 100 | + Salaire affiché | |
| 101 | + </label> | |
| 102 | + </div> | |
| 103 | + </div> | |
| 104 | + </section> | |
| 105 | + | |
| 106 | + <main className="container"> | |
| 107 | + <p className="muted" style={{ marginTop: 18 }}> | |
| 108 | + {loading ? "Chargement…" : `${total.toLocaleString("fr-CA")} offre${total > 1 ? "s" : ""} trouvée${total > 1 ? "s" : ""}`} | |
| 109 | + {" · "} | |
| 110 | + <select | |
| 111 | + value={filters.sort} | |
| 112 | + onChange={(e) => setParam("tri", e.target.value)} | |
| 113 | + style={{ border: "none", background: "none", color: "inherit", font: "inherit", cursor: "pointer" }} | |
| 114 | + > | |
| 115 | + <option value="recent">Plus récentes d'abord</option> | |
| 116 | + <option value="salary">Meilleurs salaires d'abord</option> | |
| 117 | + </select> | |
| 118 | + </p> | |
| 119 | + {error && <div className="empty">Erreur de chargement : {error}</div>} | |
| 120 | + <div className="job-list"> | |
| 121 | + {jobs.map((j) => <JobCard key={j.uid} job={j} />)} | |
| 122 | + </div> | |
| 123 | + {!loading && jobs.length === 0 && !error && ( | |
| 124 | + <div className="empty">Aucune offre ne correspond à ces filtres.</div> | |
| 125 | + )} | |
| 126 | + {pages > 1 && ( | |
| 127 | + <div className="pager"> | |
| 128 | + <button disabled={page <= 1} onClick={() => setParam("page", String(page - 1))}>← Précédent</button> | |
| 129 | + <button disabled>{page} / {pages}</button> | |
| 130 | + <button disabled={page >= pages} onClick={() => setParam("page", String(page + 1))}>Suivant →</button> | |
| 131 | + </div> | |
| 132 | + )} | |
| 133 | + </main> | |
| 134 | + </> | |
| 135 | + ); | |
| 136 | +} | |
added
frontend/src/pages/Job.tsx
+71 −0
@@ -0,0 +1,71 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * Job·Ka — Groupe KA | |
| 4 | + * Auteur : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * Fichier : frontend/src/pages/Job.tsx | |
| 7 | + * Rôle : Fiche d'une offre — faits, description, lien vers l'offre originale | |
| 8 | + * Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | + * ============================================================================= | |
| 10 | + */ | |
| 11 | +import { useEffect, useState } from "react"; | |
| 12 | +import { Link, useParams } from "react-router-dom"; | |
| 13 | +import { fetchJob, formatDate, formatSalary, Job, MODE_FR, TYPE_FR } from "../api"; | |
| 14 | + | |
| 15 | +export default function JobPage() { | |
| 16 | + const { uid = "" } = useParams(); | |
| 17 | + const [job, setJob] = useState<Job | null>(null); | |
| 18 | + const [error, setError] = useState(""); | |
| 19 | + | |
| 20 | + useEffect(() => { | |
| 21 | + setJob(null); | |
| 22 | + fetchJob(uid).then(setJob).catch((e) => setError(String(e))); | |
| 23 | + window.scrollTo(0, 0); | |
| 24 | + }, [uid]); | |
| 25 | + | |
| 26 | + if (error) return <div className="container empty"><h2>Offre introuvable</h2><Link to="/">← Retour aux offres</Link></div>; | |
| 27 | + if (!job) return <div className="container empty">Chargement…</div>; | |
| 28 | + | |
| 29 | + const salary = formatSalary(job); | |
| 30 | + const yearly = job.salary_year_min != null && job.salary_unit !== "year" | |
| 31 | + ? `≈ ${Math.round(job.salary_year_min!).toLocaleString("fr-CA")} $${job.salary_year_max && job.salary_year_max > job.salary_year_min! ? ` à ${Math.round(job.salary_year_max).toLocaleString("fr-CA")} $` : ""} / an` | |
| 32 | + : null; | |
| 33 | + | |
| 34 | + return ( | |
| 35 | + <main className="container"> | |
| 36 | + <div className="sheet"> | |
| 37 | + <p className="muted"><Link to="/">← Toutes les offres</Link></p> | |
| 38 | + <h1>{job.title}</h1> | |
| 39 | + <p style={{ margin: 0 }}> | |
| 40 | + <span className="employer" style={{ color: "var(--green)", fontWeight: 600 }}>{job.employer}</span> | |
| 41 | + {job.location_label && <span className="muted"> — {job.location_label}</span>} | |
| 42 | + </p> | |
| 43 | + <div className="facts"> | |
| 44 | + {salary && <span className="badge salary">{salary}{yearly ? ` (${yearly})` : ""}</span>} | |
| 45 | + {!salary && <span className="badge">Salaire non affiché par l'employeur</span>} | |
| 46 | + {job.work_mode && <span className="badge mode">{MODE_FR[job.work_mode] ?? job.work_mode}</span>} | |
| 47 | + {job.employment_type && <span className="badge">{TYPE_FR[job.employment_type] ?? job.employment_type}</span>} | |
| 48 | + {job.category && <span className="badge">{job.category}</span>} | |
| 49 | + {job.date_posted && <span className="badge">Publiée le {formatDate(job.date_posted)}</span>} | |
| 50 | + {job.date_deadline && <span className="badge">Date limite : {formatDate(job.date_deadline)}</span>} | |
| 51 | + {job.active === 0 && <span className="badge" style={{ background: "#fde8e8", color: "#a02222" }}>Offre retirée</span>} | |
| 52 | + </div> | |
| 53 | + <p> | |
| 54 | + <a className="btn" href={job.url} target="_blank" rel="noopener noreferrer"> | |
| 55 | + Postuler chez {job.employer} ↗ | |
| 56 | + </a> | |
| 57 | + </p> | |
| 58 | + <p className="muted"> | |
| 59 | + Offre collectée directement sur la page carrière ({job.ats}) — Job·Ka | |
| 60 | + n'est pas un intermédiaire, la candidature se fait chez l'employeur. | |
| 61 | + </p> | |
| 62 | + {job.description && ( | |
| 63 | + <> | |
| 64 | + <h2 className="section-title">Description du poste</h2> | |
| 65 | + <div className="description">{job.description}</div> | |
| 66 | + </> | |
| 67 | + )} | |
| 68 | + </div> | |
| 69 | + </main> | |
| 70 | + ); | |
| 71 | +} | |
added
frontend/src/pages/MapPage.tsx
+98 −0
@@ -0,0 +1,98 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * Job·Ka — Groupe KA | |
| 4 | + * Auteur : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * Fichier : frontend/src/pages/MapPage.tsx | |
| 7 | + * Rôle : Carte des offres géolocalisées (MapLibre GL, fond libre Carto — | |
| 8 | + * aucun jeton requis, contrairement à Mapbox/ka-maps) | |
| 9 | + * Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 10 | + * ============================================================================= | |
| 11 | + */ | |
| 12 | +import maplibregl from "maplibre-gl"; | |
| 13 | +import "maplibre-gl/dist/maplibre-gl.css"; | |
| 14 | +import { useEffect, useRef, useState } from "react"; | |
| 15 | +import { geojsonUrl } from "../api"; | |
| 16 | + | |
| 17 | +const STYLE = "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json"; | |
| 18 | +const QUEBEC_CENTER: [number, number] = [-71.9, 46.9]; | |
| 19 | + | |
| 20 | +export default function MapPage() { | |
| 21 | + const el = useRef<HTMLDivElement>(null); | |
| 22 | + const [counts, setCounts] = useState<{ geo: number; all: number } | null>(null); | |
| 23 | + | |
| 24 | + useEffect(() => { | |
| 25 | + if (!el.current) return; | |
| 26 | + const map = new maplibregl.Map({ | |
| 27 | + container: el.current, | |
| 28 | + style: STYLE, | |
| 29 | + center: QUEBEC_CENTER, | |
| 30 | + zoom: 5.6, | |
| 31 | + attributionControl: { compact: true }, | |
| 32 | + }); | |
| 33 | + map.addControl(new maplibregl.NavigationControl(), "top-right"); | |
| 34 | + | |
| 35 | + map.on("load", async () => { | |
| 36 | + const data = await fetch(geojsonUrl({})).then((r) => r.json()); | |
| 37 | + setCounts({ geo: data.totalGeocoded, all: data.totalMatching }); | |
| 38 | + map.addSource("jobs", { type: "geojson", data, cluster: true, clusterRadius: 44 }); | |
| 39 | + map.addLayer({ | |
| 40 | + id: "clusters", type: "circle", source: "jobs", filter: ["has", "point_count"], | |
| 41 | + paint: { | |
| 42 | + "circle-color": "#1c5c41", | |
| 43 | + "circle-radius": ["step", ["get", "point_count"], 16, 25, 22, 100, 28], | |
| 44 | + "circle-stroke-width": 2, "circle-stroke-color": "#ffffff", | |
| 45 | + }, | |
| 46 | + }); | |
| 47 | + map.addLayer({ | |
| 48 | + id: "cluster-count", type: "symbol", source: "jobs", filter: ["has", "point_count"], | |
| 49 | + layout: { "text-field": "{point_count_abbreviated}", "text-size": 13 }, | |
| 50 | + paint: { "text-color": "#ffffff" }, | |
| 51 | + }); | |
| 52 | + map.addLayer({ | |
| 53 | + id: "points", type: "circle", source: "jobs", filter: ["!", ["has", "point_count"]], | |
| 54 | + paint: { | |
| 55 | + "circle-color": "#1c5c41", "circle-radius": 7, | |
| 56 | + "circle-stroke-width": 2, "circle-stroke-color": "#ffffff", | |
| 57 | + }, | |
| 58 | + }); | |
| 59 | + map.on("click", "clusters", async (e) => { | |
| 60 | + const f = map.queryRenderedFeatures(e.point, { layers: ["clusters"] })[0]; | |
| 61 | + const src = map.getSource("jobs") as maplibregl.GeoJSONSource; | |
| 62 | + const zoom = await src.getClusterExpansionZoom(f.properties!.cluster_id); | |
| 63 | + map.easeTo({ center: (f.geometry as GeoJSON.Point).coordinates as [number, number], zoom }); | |
| 64 | + }); | |
| 65 | + map.on("click", "points", (e) => { | |
| 66 | + const f = e.features?.[0]; | |
| 67 | + if (!f) return; | |
| 68 | + const p = f.properties as Record<string, string>; | |
| 69 | + const salaire = p.salary_min && p.salary_min !== "null" | |
| 70 | + ? `<br/>💰 ${Number(p.salary_min).toLocaleString("fr-CA")} $${p.salary_max && p.salary_max !== p.salary_min ? ` à ${Number(p.salary_max).toLocaleString("fr-CA")} $` : ""} / ${p.salary_unit === "hour" ? "h" : "an"}` | |
| 71 | + : ""; | |
| 72 | + new maplibregl.Popup({ offset: 12 }) | |
| 73 | + .setLngLat((f.geometry as GeoJSON.Point).coordinates as [number, number]) | |
| 74 | + .setHTML( | |
| 75 | + `<div class="map-popup"><h4>${p.title}</h4>${p.employer} — ${p.city}${salaire}` + | |
| 76 | + `<br/><a href="/emploi/${p.uid}">Voir l'offre →</a></div>`, | |
| 77 | + ) | |
| 78 | + .addTo(map); | |
| 79 | + }); | |
| 80 | + map.on("mouseenter", "points", () => { map.getCanvas().style.cursor = "pointer"; }); | |
| 81 | + map.on("mouseleave", "points", () => { map.getCanvas().style.cursor = ""; }); | |
| 82 | + }); | |
| 83 | + | |
| 84 | + return () => map.remove(); | |
| 85 | + }, []); | |
| 86 | + | |
| 87 | + return ( | |
| 88 | + <div className="map-wrap"> | |
| 89 | + {counts && ( | |
| 90 | + <div className="map-count"> | |
| 91 | + {counts.geo.toLocaleString("fr-CA")} offres sur la carte | |
| 92 | + {counts.all > counts.geo ? ` · ${(counts.all - counts.geo).toLocaleString("fr-CA")} sans position` : ""} | |
| 93 | + </div> | |
| 94 | + )} | |
| 95 | + <div ref={el} style={{ height: "100%" }} /> | |
| 96 | + </div> | |
| 97 | + ); | |
| 98 | +} | |
added
frontend/src/pages/Sources.tsx
+52 −0
@@ -0,0 +1,52 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * Job·Ka — Groupe KA | |
| 4 | + * Auteur : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * Fichier : frontend/src/pages/Sources.tsx | |
| 7 | + * Rôle : Registre public des employeurs connectés (traçabilité des sources) | |
| 8 | + * Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | + * ============================================================================= | |
| 10 | + */ | |
| 11 | +import { useEffect, useState } from "react"; | |
| 12 | +import { fetchSources, Source } from "../api"; | |
| 13 | + | |
| 14 | +export default function Sources() { | |
| 15 | + const [sources, setSources] = useState<Source[]>([]); | |
| 16 | + | |
| 17 | + useEffect(() => { fetchSources().then((r) => setSources(r.sources)).catch(() => {}); }, []); | |
| 18 | + | |
| 19 | + return ( | |
| 20 | + <main className="container"> | |
| 21 | + <h1 className="section-title" style={{ fontSize: 24, marginTop: 28 }}>Employeurs connectés</h1> | |
| 22 | + <p className="muted"> | |
| 23 | + Chaque source est un connecteur automatisé vers la page carrière officielle | |
| 24 | + de l'employeur, resynchronisé jour et nuit. Zéro saisie manuelle. | |
| 25 | + </p> | |
| 26 | + <table className="data" style={{ margin: "18px 0 40px" }}> | |
| 27 | + <thead> | |
| 28 | + <tr> | |
| 29 | + <th>Employeur</th><th>Secteurs</th><th>Région</th><th>ATS</th> | |
| 30 | + <th>Offres actives</th><th>Dernière synchro</th> | |
| 31 | + </tr> | |
| 32 | + </thead> | |
| 33 | + <tbody> | |
| 34 | + {sources.map((s) => ( | |
| 35 | + <tr key={s.id}> | |
| 36 | + <td><a href={s.careers_url || s.url} target="_blank" rel="noopener noreferrer">{s.name}</a></td> | |
| 37 | + <td>{s.sectors.join(", ")}</td> | |
| 38 | + <td>{s.region}</td> | |
| 39 | + <td>{s.connector}</td> | |
| 40 | + <td>{s.active_jobs}</td> | |
| 41 | + <td> | |
| 42 | + {s.last_sync | |
| 43 | + ? new Date(s.last_sync * 1000).toLocaleString("fr-CA", { dateStyle: "medium", timeStyle: "short" }) | |
| 44 | + : "—"} | |
| 45 | + </td> | |
| 46 | + </tr> | |
| 47 | + ))} | |
| 48 | + </tbody> | |
| 49 | + </table> | |
| 50 | + </main> | |
| 51 | + ); | |
| 52 | +} | |
added
frontend/src/pages/Stats.tsx
+79 −0
@@ -0,0 +1,79 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * Job·Ka — Groupe KA | |
| 4 | + * Auteur : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * Fichier : frontend/src/pages/Stats.tsx | |
| 7 | + * Rôle : Statistiques du marché — tuiles + barres à teinte unique | |
| 8 | + * (magnitude portée par la longueur, étiquettes en encre de texte) | |
| 9 | + * Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 10 | + * ============================================================================= | |
| 11 | + */ | |
| 12 | +import { useEffect, useState } from "react"; | |
| 13 | +import { fetchStats, Stats as StatsData } from "../api"; | |
| 14 | + | |
| 15 | +function Bars({ rows }: { rows: { name: string; n: number }[] }) { | |
| 16 | + const max = Math.max(1, ...rows.map((r) => r.n)); | |
| 17 | + return ( | |
| 18 | + <div className="bars"> | |
| 19 | + {rows.map((r) => ( | |
| 20 | + <div className="bar-row" key={r.name} title={`${r.name} : ${r.n} offres`}> | |
| 21 | + <span className="name">{r.name}</span> | |
| 22 | + <span className="track"><span className="fill" style={{ width: `${(r.n / max) * 100}%` }} /></span> | |
| 23 | + <span className="n">{r.n.toLocaleString("fr-CA")}</span> | |
| 24 | + </div> | |
| 25 | + ))} | |
| 26 | + </div> | |
| 27 | + ); | |
| 28 | +} | |
| 29 | + | |
| 30 | +export default function Stats() { | |
| 31 | + const [stats, setStats] = useState<StatsData | null>(null); | |
| 32 | + | |
| 33 | + useEffect(() => { fetchStats().then(setStats).catch(() => {}); }, []); | |
| 34 | + | |
| 35 | + if (!stats) return <div className="container empty">Chargement…</div>; | |
| 36 | + | |
| 37 | + const pctSalary = stats.total ? Math.round((stats.with_salary / stats.total) * 100) : 0; | |
| 38 | + | |
| 39 | + return ( | |
| 40 | + <main className="container"> | |
| 41 | + <h1 className="section-title" style={{ fontSize: 24, marginTop: 28 }}>Le marché en chiffres</h1> | |
| 42 | + <div className="tiles"> | |
| 43 | + <div className="tile"><div className="value">{stats.total.toLocaleString("fr-CA")}</div><div className="label">Offres actives</div></div> | |
| 44 | + <div className="tile"><div className="value">{stats.employers.toLocaleString("fr-CA")}</div><div className="label">Employeurs</div></div> | |
| 45 | + <div className="tile"><div className="value">{pctSalary} %</div><div className="label">Offres avec salaire affiché</div></div> | |
| 46 | + <div className="tile"> | |
| 47 | + <div className="value"> | |
| 48 | + {stats.avg_salary_year ? `${Math.round(stats.avg_salary_year / 1000)} k$` : "—"} | |
| 49 | + </div> | |
| 50 | + <div className="label">Salaire annuel moyen (offres affichées)</div> | |
| 51 | + </div> | |
| 52 | + <div className="tile"><div className="value">{stats.remote.toLocaleString("fr-CA")}</div><div className="label">Postes en télétravail</div></div> | |
| 53 | + </div> | |
| 54 | + | |
| 55 | + <h2 className="section-title">Offres par catégorie</h2> | |
| 56 | + <Bars rows={stats.categories.map((c) => ({ name: c.category, n: c.n }))} /> | |
| 57 | + | |
| 58 | + <h2 className="section-title">Offres par ville</h2> | |
| 59 | + <Bars rows={stats.top_cities.map((c) => ({ name: c.city, n: c.n }))} /> | |
| 60 | + | |
| 61 | + <h2 className="section-title">Dernières synchronisations</h2> | |
| 62 | + <table className="data" style={{ marginBottom: 40 }}> | |
| 63 | + <thead> | |
| 64 | + <tr><th>Source</th><th>Quand</th><th>Trouvées</th><th>Ajouts</th><th>MAJ</th><th>Retraits</th><th>État</th></tr> | |
| 65 | + </thead> | |
| 66 | + <tbody> | |
| 67 | + {stats.recent_syncs.map((s, i) => ( | |
| 68 | + <tr key={i}> | |
| 69 | + <td>{s.source}</td> | |
| 70 | + <td>{new Date(s.ts * 1000).toLocaleString("fr-CA", { dateStyle: "short", timeStyle: "short" })}</td> | |
| 71 | + <td>{s.found}</td><td>{s.added}</td><td>{s.updated}</td><td>{s.removed}</td> | |
| 72 | + <td>{s.ok ? (s.message === "ok" ? "✓" : `⚠ ${s.message}`) : `✗ ${s.message}`}</td> | |
| 73 | + </tr> | |
| 74 | + ))} | |
| 75 | + </tbody> | |
| 76 | + </table> | |
| 77 | + </main> | |
| 78 | + ); | |
| 79 | +} | |
added
frontend/src/styles.css
+172 −0
@@ -0,0 +1,172 @@ | ||
| 1 | +/* | |
| 2 | +============================================================================= | |
| 3 | +Job·Ka — Groupe KA | |
| 4 | +Auteur : Simon-Pierre Boucher | |
| 5 | +Contact : contact@spboucher.ai | |
| 6 | +Fichier : frontend/src/styles.css | |
| 7 | +Rôle : Système de design unique de l'application (clair, style Groupe KA) | |
| 8 | +Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | +============================================================================= | |
| 10 | +*/ | |
| 11 | + | |
| 12 | +:root { | |
| 13 | + --ink: #14181a; /* encre principale */ | |
| 14 | + --ink-2: #4a5459; /* encre secondaire */ | |
| 15 | + --ink-3: #7d8a90; /* encre atténuée */ | |
| 16 | + --surface: #ffffff; | |
| 17 | + --surface-2: #f4f6f5; | |
| 18 | + --border: #e3e8e6; | |
| 19 | + --brand: #141814; /* noir-vert Groupe KA */ | |
| 20 | + --accent: #d9f26b; /* lime Groupe KA (sur fond sombre uniquement) */ | |
| 21 | + --green: #1c5c41; /* vert profond — liens, barres, accents sur clair */ | |
| 22 | + --green-soft: #e7f2ec; | |
| 23 | + --radius: 12px; | |
| 24 | + --shadow: 0 1px 2px rgb(20 24 20 / 6%), 0 4px 14px rgb(20 24 20 / 6%); | |
| 25 | +} | |
| 26 | + | |
| 27 | +* { box-sizing: border-box; } | |
| 28 | + | |
| 29 | +body { | |
| 30 | + margin: 0; | |
| 31 | + font-family: "Inter", system-ui, -apple-system, sans-serif; | |
| 32 | + color: var(--ink); | |
| 33 | + background: var(--surface-2); | |
| 34 | + font-size: 15px; | |
| 35 | + line-height: 1.55; | |
| 36 | +} | |
| 37 | + | |
| 38 | +a { color: var(--green); text-decoration: none; } | |
| 39 | +a:hover { text-decoration: underline; } | |
| 40 | + | |
| 41 | +.container { max-width: 1080px; margin: 0 auto; padding: 0 20px; } | |
| 42 | + | |
| 43 | +/* --- en-tête ---------------------------------------------------------------- */ | |
| 44 | +header.site { | |
| 45 | + background: var(--brand); | |
| 46 | + color: #fff; | |
| 47 | + position: sticky; top: 0; z-index: 50; | |
| 48 | +} | |
| 49 | +header.site .container { | |
| 50 | + display: flex; align-items: center; gap: 24px; height: 58px; | |
| 51 | +} | |
| 52 | +.logo { font-weight: 800; font-size: 20px; color: #fff; letter-spacing: -0.3px; } | |
| 53 | +.logo em { color: var(--accent); font-style: normal; } | |
| 54 | +nav.main { display: flex; gap: 4px; margin-left: auto; } | |
| 55 | +nav.main a { | |
| 56 | + color: #d7ddda; padding: 7px 12px; border-radius: 8px; font-weight: 500; | |
| 57 | +} | |
| 58 | +nav.main a:hover { color: #fff; background: rgb(255 255 255 / 8%); text-decoration: none; } | |
| 59 | +nav.main a.active { color: var(--accent); background: rgb(255 255 255 / 10%); } | |
| 60 | + | |
| 61 | +/* --- héros ------------------------------------------------------------------ */ | |
| 62 | +.hero { background: var(--brand); color: #fff; padding: 40px 0 34px; } | |
| 63 | +.hero h1 { margin: 0 0 6px; font-size: 30px; font-weight: 800; letter-spacing: -0.5px; } | |
| 64 | +.hero h1 em { color: var(--accent); font-style: normal; } | |
| 65 | +.hero p { margin: 0 0 20px; color: #b9c2be; max-width: 640px; } | |
| 66 | + | |
| 67 | +/* --- filtres ------------------------------------------------------------------ */ | |
| 68 | +.filters { display: flex; flex-wrap: wrap; gap: 10px; } | |
| 69 | +.filters input[type="search"], .filters select { | |
| 70 | + height: 42px; padding: 0 12px; border-radius: 10px; border: 1px solid var(--border); | |
| 71 | + background: #fff; color: var(--ink); font: inherit; min-width: 130px; | |
| 72 | +} | |
| 73 | +.filters input[type="search"] { flex: 1 1 220px; } | |
| 74 | +.filters label.check { | |
| 75 | + display: inline-flex; align-items: center; gap: 7px; background: #fff; | |
| 76 | + border: 1px solid var(--border); border-radius: 10px; padding: 0 14px; | |
| 77 | + height: 42px; cursor: pointer; user-select: none; white-space: nowrap; | |
| 78 | +} | |
| 79 | + | |
| 80 | +/* --- cartes d'offres ------------------------------------------------------------ */ | |
| 81 | +.job-list { display: grid; gap: 12px; margin: 22px 0; } | |
| 82 | +.job-card { | |
| 83 | + display: block; background: var(--surface); border: 1px solid var(--border); | |
| 84 | + border-radius: var(--radius); padding: 18px 20px; color: inherit; | |
| 85 | + transition: border-color 0.12s, box-shadow 0.12s; | |
| 86 | +} | |
| 87 | +.job-card:hover { border-color: var(--green); box-shadow: var(--shadow); text-decoration: none; } | |
| 88 | +.job-card h3 { margin: 0 0 2px; font-size: 17px; font-weight: 700; color: var(--ink); } | |
| 89 | +.job-card .employer { color: var(--green); font-weight: 600; } | |
| 90 | +.job-card .meta { color: var(--ink-3); font-size: 13.5px; margin-top: 6px; display: flex; flex-wrap: wrap; gap: 6px 14px; } | |
| 91 | + | |
| 92 | +.badge { | |
| 93 | + display: inline-block; padding: 2px 9px; border-radius: 999px; | |
| 94 | + font-size: 12.5px; font-weight: 600; background: var(--surface-2); color: var(--ink-2); | |
| 95 | +} | |
| 96 | +.badge.salary { background: var(--green-soft); color: var(--green); } | |
| 97 | +.badge.mode { background: #eef1ff; color: #3c4a9e; } | |
| 98 | + | |
| 99 | +/* --- fiche ------------------------------------------------------------------------- */ | |
| 100 | +.sheet { | |
| 101 | + background: var(--surface); border: 1px solid var(--border); | |
| 102 | + border-radius: var(--radius); padding: 28px 32px; margin: 24px 0; | |
| 103 | +} | |
| 104 | +.sheet h1 { margin: 0 0 4px; font-size: 24px; letter-spacing: -0.3px; } | |
| 105 | +.sheet .facts { display: flex; flex-wrap: wrap; gap: 8px; margin: 14px 0 20px; } | |
| 106 | +.sheet .description { white-space: pre-line; color: var(--ink-2); } | |
| 107 | +.btn { | |
| 108 | + display: inline-block; background: var(--brand); color: var(--accent); | |
| 109 | + font-weight: 700; padding: 11px 22px; border-radius: 10px; border: none; | |
| 110 | + cursor: pointer; font: inherit; font-weight: 700; | |
| 111 | +} | |
| 112 | +.btn:hover { text-decoration: none; opacity: 0.92; } | |
| 113 | + | |
| 114 | +/* --- tuiles de statistiques (hero numbers) --------------------------------------- */ | |
| 115 | +.tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin: 22px 0; } | |
| 116 | +.tile { | |
| 117 | + background: var(--surface); border: 1px solid var(--border); | |
| 118 | + border-radius: var(--radius); padding: 18px 20px; | |
| 119 | +} | |
| 120 | +.tile .value { font-size: 26px; font-weight: 800; letter-spacing: -0.5px; } | |
| 121 | +.tile .label { color: var(--ink-3); font-size: 13px; margin-top: 2px; } | |
| 122 | + | |
| 123 | +/* --- barres horizontales (magnitude, teinte unique) --------------------------------- */ | |
| 124 | +.bars { display: grid; gap: 8px; margin: 10px 0 26px; } | |
| 125 | +.bar-row { display: grid; grid-template-columns: 190px 1fr 52px; align-items: center; gap: 10px; } | |
| 126 | +.bar-row .name { font-size: 13.5px; color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } | |
| 127 | +.bar-row .track { background: var(--surface-2); border-radius: 4px; height: 14px; } | |
| 128 | +.bar-row .fill { background: var(--green); border-radius: 4px 4px 4px 4px; height: 14px; min-width: 3px; } | |
| 129 | +.bar-row .n { font-size: 13px; color: var(--ink-2); text-align: right; font-variant-numeric: tabular-nums; } | |
| 130 | + | |
| 131 | +/* --- tableaux ----------------------------------------------------------------------- */ | |
| 132 | +table.data { width: 100%; border-collapse: collapse; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; } | |
| 133 | +table.data th, table.data td { text-align: left; padding: 10px 14px; border-bottom: 1px solid var(--border); font-size: 14px; } | |
| 134 | +table.data th { background: var(--surface-2); color: var(--ink-2); font-weight: 600; } | |
| 135 | +table.data tr:last-child td { border-bottom: none; } | |
| 136 | + | |
| 137 | +/* --- carte -------------------------------------------------------------------------- */ | |
| 138 | +.map-wrap { height: calc(100vh - 58px); position: relative; } | |
| 139 | +.map-wrap .maplibregl-map { height: 100%; } | |
| 140 | +.map-count { | |
| 141 | + position: absolute; top: 14px; left: 14px; z-index: 5; | |
| 142 | + background: var(--brand); color: #fff; border-radius: 10px; | |
| 143 | + padding: 8px 14px; font-size: 13.5px; box-shadow: var(--shadow); | |
| 144 | +} | |
| 145 | +.map-popup h4 { margin: 0 0 2px; font-size: 14px; } | |
| 146 | +.map-popup { font: 13px "Inter", sans-serif; } | |
| 147 | + | |
| 148 | +/* --- pagination / divers ------------------------------------------------------------- */ | |
| 149 | +.pager { display: flex; gap: 8px; justify-content: center; margin: 26px 0; } | |
| 150 | +.pager button { | |
| 151 | + background: var(--surface); border: 1px solid var(--border); border-radius: 8px; | |
| 152 | + padding: 8px 16px; cursor: pointer; font: inherit; | |
| 153 | +} | |
| 154 | +.pager button:disabled { opacity: 0.4; cursor: default; } | |
| 155 | +.empty { text-align: center; color: var(--ink-3); padding: 60px 0; } | |
| 156 | +.section-title { font-size: 19px; font-weight: 700; margin: 30px 0 4px; } | |
| 157 | +.muted { color: var(--ink-3); font-size: 13px; } | |
| 158 | + | |
| 159 | +/* --- pied de page ----------------------------------------------------------------------- */ | |
| 160 | +footer.site { | |
| 161 | + background: var(--brand); color: #9aa5a0; margin-top: 60px; | |
| 162 | + padding: 26px 0; font-size: 13.5px; | |
| 163 | +} | |
| 164 | +footer.site a { color: #d7ddda; } | |
| 165 | +footer.site .container { display: flex; flex-wrap: wrap; gap: 8px 24px; justify-content: space-between; } | |
| 166 | + | |
| 167 | +@media (max-width: 640px) { | |
| 168 | + nav.main { gap: 0; } | |
| 169 | + nav.main a { padding: 7px 8px; font-size: 13.5px; } | |
| 170 | + .bar-row { grid-template-columns: 120px 1fr 44px; } | |
| 171 | + .sheet { padding: 20px; } | |
| 172 | +} | |
added
frontend/tsconfig.json
+21 −0
@@ -0,0 +1,21 @@ | ||
| 1 | +{ | |
| 2 | + "_comment": "Job·Ka — Groupe KA | Auteur : Simon-Pierre Boucher <contact@spboucher.ai> | Fichier : frontend/tsconfig.json | Rôle : configuration TypeScript | Créé/Modifié : 2026-08-17", | |
| 3 | + "compilerOptions": { | |
| 4 | + "target": "ES2020", | |
| 5 | + "useDefineForClassFields": true, | |
| 6 | + "lib": ["ES2020", "DOM", "DOM.Iterable"], | |
| 7 | + "module": "ESNext", | |
| 8 | + "skipLibCheck": true, | |
| 9 | + "moduleResolution": "bundler", | |
| 10 | + "allowImportingTsExtensions": true, | |
| 11 | + "resolveJsonModule": true, | |
| 12 | + "isolatedModules": true, | |
| 13 | + "noEmit": true, | |
| 14 | + "jsx": "react-jsx", | |
| 15 | + "strict": true, | |
| 16 | + "noUnusedLocals": false, | |
| 17 | + "noUnusedParameters": false, | |
| 18 | + "noFallthroughCasesInSwitch": true | |
| 19 | + }, | |
| 20 | + "include": ["src"] | |
| 21 | +} | |
added
frontend/vite.config.ts
+23 −0
@@ -0,0 +1,23 @@ | ||
| 1 | +/** | |
| 2 | + * ============================================================================= | |
| 3 | + * Job·Ka — Groupe KA | |
| 4 | + * Auteur : Simon-Pierre Boucher | |
| 5 | + * Contact : contact@spboucher.ai | |
| 6 | + * Fichier : frontend/vite.config.ts | |
| 7 | + * Rôle : Configuration Vite (proxy API en dev, build dans dist/) | |
| 8 | + * Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | + * ============================================================================= | |
| 10 | + */ | |
| 11 | +import react from "@vitejs/plugin-react"; | |
| 12 | +import { defineConfig } from "vite"; | |
| 13 | + | |
| 14 | +export default defineConfig({ | |
| 15 | + plugins: [react()], | |
| 16 | + server: { | |
| 17 | + proxy: { | |
| 18 | + "/api": "http://localhost:8096", | |
| 19 | + "/health": "http://localhost:8096", | |
| 20 | + }, | |
| 21 | + }, | |
| 22 | + build: { outDir: "dist" }, | |
| 23 | +}); | |
added
jobka/__init__.py
+8 −0
@@ -0,0 +1,8 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : jobka/__init__.py | |
| 6 | +# Rôle : Paquet principal de Job·Ka (agrégateur d'offres d'emploi québécoises) | |
| 7 | +# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 8 | +# ============================================================================= | |
added
jobka/connectors/__init__.py
+32 −0
@@ -0,0 +1,32 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : jobka/connectors/__init__.py | |
| 6 | +# Rôle : Registre AUTO-DÉCOUVRANT des connecteurs — toute sous-classe de | |
| 7 | +# BaseConnector avec un source_id non vide est enregistrée, aucun | |
| 8 | +# fichier partagé à modifier pour ajouter un connecteur | |
| 9 | +# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 10 | +# ============================================================================= | |
| 11 | +from __future__ import annotations | |
| 12 | + | |
| 13 | +import importlib | |
| 14 | +import pkgutil | |
| 15 | +import sys | |
| 16 | + | |
| 17 | +from .base import BaseConnector | |
| 18 | + | |
| 19 | +CONNECTORS: dict[str, type[BaseConnector]] = {} | |
| 20 | + | |
| 21 | +for _mod in pkgutil.iter_modules(__path__): | |
| 22 | + if _mod.name in ("base", "__init__"): | |
| 23 | + continue | |
| 24 | + try: | |
| 25 | + module = importlib.import_module(f"{__name__}.{_mod.name}") | |
| 26 | + except Exception as exc: # un connecteur cassé ne bloque pas les autres | |
| 27 | + print(f"[job-ka] connecteur '{_mod.name}' ignoré : {exc}", file=sys.stderr) | |
| 28 | + continue | |
| 29 | + for obj in vars(module).values(): | |
| 30 | + if (isinstance(obj, type) and issubclass(obj, BaseConnector) | |
| 31 | + and obj is not BaseConnector and getattr(obj, "source_id", "")): | |
| 32 | + CONNECTORS[obj.source_id] = obj | |
added
jobka/connectors/base.py
+150 −0
@@ -0,0 +1,150 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : jobka/connectors/base.py | |
| 6 | +# Rôle : Classe de base des connecteurs + backends de fetch (requests | |
| 7 | +# direct, Scrapfly pour les pages carrières JS / derrière anti-bot) | |
| 8 | +# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | +# ============================================================================= | |
| 10 | +from __future__ import annotations | |
| 11 | + | |
| 12 | +import json | |
| 13 | +import os | |
| 14 | +import time | |
| 15 | + | |
| 16 | +import requests | |
| 17 | + | |
| 18 | +from ..schema import JobPosting | |
| 19 | + | |
| 20 | +USER_AGENT = "JobKaBot/1.0 (+https://www.job-ka.com; contact@spboucher.ai)" | |
| 21 | + | |
| 22 | +SCRAPFLY_API = "https://api.scrapfly.io/scrape" | |
| 23 | + | |
| 24 | + | |
| 25 | +class BaseConnector: | |
| 26 | + """Un connecteur = un adaptateur propre à un employeur ou à un ATS. | |
| 27 | + | |
| 28 | + Sous-classes : définir `source_id` et implémenter `fetch()` qui retourne | |
| 29 | + la liste complète des offres actuellement affichées à la source. | |
| 30 | + Le pipeline (ingest.py) s'occupe du diff avec la base de données. | |
| 31 | + """ | |
| 32 | + | |
| 33 | + source_id: str = "" | |
| 34 | + ats: str = "custom" # workday | lever | greenhouse | … | |
| 35 | + request_delay: float = 0.6 # politesse entre requêtes | |
| 36 | + timeout: int = 30 | |
| 37 | + use_detail_cache: bool = True # cache BD des pages détail | |
| 38 | + | |
| 39 | + def __init__(self) -> None: | |
| 40 | + self.session = requests.Session() | |
| 41 | + self.session.headers["User-Agent"] = USER_AGENT | |
| 42 | + self._last_request = 0.0 | |
| 43 | + self._detail_con = None | |
| 44 | + | |
| 45 | + # -- backends ------------------------------------------------------------- | |
| 46 | + def _throttle(self) -> None: | |
| 47 | + wait = self.request_delay - (time.time() - self._last_request) | |
| 48 | + if wait > 0: | |
| 49 | + time.sleep(wait) | |
| 50 | + | |
| 51 | + def get(self, url: str, **kw) -> requests.Response: | |
| 52 | + """GET direct avec throttling poli.""" | |
| 53 | + self._throttle() | |
| 54 | + resp = self.session.get(url, timeout=self.timeout, **kw) | |
| 55 | + self._last_request = time.time() | |
| 56 | + resp.raise_for_status() | |
| 57 | + return resp | |
| 58 | + | |
| 59 | + def post(self, url: str, **kw) -> requests.Response: | |
| 60 | + """POST direct avec throttling poli (APIs de recherche internes).""" | |
| 61 | + self._throttle() | |
| 62 | + resp = self.session.post(url, timeout=self.timeout, **kw) | |
| 63 | + self._last_request = time.time() | |
| 64 | + resp.raise_for_status() | |
| 65 | + return resp | |
| 66 | + | |
| 67 | + def scrapfly(self, url: str, render_js: bool = True, asp: bool = True, | |
| 68 | + rendering_wait: int = 0, country: str = "ca", | |
| 69 | + wait_for_selector: str | None = None, | |
| 70 | + js_scenario: list | str | None = None, | |
| 71 | + proxy_pool: str | None = None, headers: dict | None = None, | |
| 72 | + method: str = "GET", body: str | None = None) -> dict: | |
| 73 | + """Appel Scrapfly complet — retourne le dict `result` (content, status_code…). | |
| 74 | + | |
| 75 | + - `js_scenario` : liste d'étapes [{"scroll_y":…},{"wait":…}] (base64) | |
| 76 | + pour charger les listes d'offres virtualisées. | |
| 77 | + - `proxy_pool` : ex. "public_residential_pool" (WAF/anti-bot agressif). | |
| 78 | + - `headers`/`method`/`body` : pour rejouer une API JSON interne via ASP. | |
| 79 | + """ | |
| 80 | + import base64 | |
| 81 | + key = os.environ.get("SCRAPFLY_API_KEY") | |
| 82 | + if not key: | |
| 83 | + raise RuntimeError("SCRAPFLY_API_KEY manquant (voir .env)") | |
| 84 | + params: dict = {"key": key, "url": url, "country": country} | |
| 85 | + if asp: | |
| 86 | + params["asp"] = "true" | |
| 87 | + if render_js: | |
| 88 | + params["render_js"] = "true" | |
| 89 | + if rendering_wait: | |
| 90 | + params["rendering_wait"] = rendering_wait | |
| 91 | + if wait_for_selector: | |
| 92 | + params["wait_for_selector"] = wait_for_selector | |
| 93 | + if proxy_pool: | |
| 94 | + params["proxy_pool"] = proxy_pool | |
| 95 | + if js_scenario is not None: | |
| 96 | + js = js_scenario if isinstance(js_scenario, str) else json.dumps(js_scenario) | |
| 97 | + params["js_scenario"] = base64.urlsafe_b64encode(js.encode()).decode() | |
| 98 | + if headers: | |
| 99 | + for k, v in headers.items(): | |
| 100 | + params[f"headers[{k}]"] = v | |
| 101 | + self._throttle() | |
| 102 | + if method.upper() == "POST": | |
| 103 | + resp = requests.post(SCRAPFLY_API, params=params, | |
| 104 | + data=(body or ""), timeout=180) | |
| 105 | + else: | |
| 106 | + resp = requests.get(SCRAPFLY_API, params=params, timeout=180) | |
| 107 | + self._last_request = time.time() | |
| 108 | + try: | |
| 109 | + return resp.json().get("result") or {} | |
| 110 | + except ValueError: | |
| 111 | + return {} | |
| 112 | + | |
| 113 | + def get_scrapfly(self, url: str, render_js: bool = True, asp: bool = True, | |
| 114 | + rendering_wait: int = 0, country: str = "ca", | |
| 115 | + wait_for_selector: str | None = None, | |
| 116 | + js_scenario: list | str | None = None, | |
| 117 | + proxy_pool: str | None = None) -> str: | |
| 118 | + """HTML rendu via Scrapfly (ASP = bypass anti-bot + rendu JS). Retourne | |
| 119 | + le HTML (result.content) ou "" en cas d'échec ASP.""" | |
| 120 | + return self.scrapfly(url, render_js=render_js, asp=asp, | |
| 121 | + rendering_wait=rendering_wait, country=country, | |
| 122 | + wait_for_selector=wait_for_selector, | |
| 123 | + js_scenario=js_scenario, proxy_pool=proxy_pool | |
| 124 | + ).get("content") or "" | |
| 125 | + | |
| 126 | + def detail(self, external_id: str, key: str, fetch_fn) -> dict: | |
| 127 | + """Payload « page détail » avec cache : `fetch_fn` n'est appelé que si | |
| 128 | + l'offre est nouvelle ou si sa clé (hash du contenu liste) a changé. | |
| 129 | + | |
| 130 | + Permet d'extraire les champs riches (description complète, salaire, | |
| 131 | + exigences) sans revisiter chaque page détail à chaque synchronisation. | |
| 132 | + `fetch_fn` doit retourner un dict JSON-sérialisable. | |
| 133 | + """ | |
| 134 | + if not self.use_detail_cache: | |
| 135 | + return fetch_fn() or {} | |
| 136 | + from .. import db | |
| 137 | + if self._detail_con is None: | |
| 138 | + self._detail_con = db.connect() | |
| 139 | + cached = db.get_cached_detail(self._detail_con, self.source_id, | |
| 140 | + str(external_id), key) | |
| 141 | + if cached is not None: | |
| 142 | + return cached | |
| 143 | + payload = fetch_fn() or {} | |
| 144 | + db.put_cached_detail(self._detail_con, self.source_id, | |
| 145 | + str(external_id), key, payload) | |
| 146 | + return payload | |
| 147 | + | |
| 148 | + # -- contrat -------------------------------------------------------------- | |
| 149 | + def fetch(self) -> list[JobPosting]: | |
| 150 | + raise NotImplementedError | |
added
jobka/connectors/cae.py
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : jobka/connectors/cae.py | |
| 6 | +# Rôle : Connecteur CAE (aéronautique, siège à Saint-Laurent/Montréal) — | |
| 7 | +# Workday cae.wd3, site « career », ~275 postes mondiaux filtrés QC | |
| 8 | +# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | +# ============================================================================= | |
| 10 | +from .workday import WorkdayConnector | |
| 11 | + | |
| 12 | + | |
| 13 | +class CaeConnector(WorkdayConnector): | |
| 14 | + source_id = "cae" | |
| 15 | + EMPLOYER = "CAE" | |
| 16 | + TENANT = "cae" | |
| 17 | + HOST = "wd3" | |
| 18 | + SITE = "career" | |
added
jobka/connectors/desjardins.py
+18 −0
@@ -0,0 +1,18 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : jobka/connectors/desjardins.py | |
| 6 | +# Rôle : Connecteur Mouvement Desjardins (coopérative financière, Lévis) — | |
| 7 | +# Workday desjardins.wd10, site « Desjardins », ~270 postes | |
| 8 | +# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | +# ============================================================================= | |
| 10 | +from .workday import WorkdayConnector | |
| 11 | + | |
| 12 | + | |
| 13 | +class DesjardinsConnector(WorkdayConnector): | |
| 14 | + source_id = "desjardins" | |
| 15 | + EMPLOYER = "Mouvement Desjardins" | |
| 16 | + TENANT = "desjardins" | |
| 17 | + HOST = "wd10" | |
| 18 | + SITE = "Desjardins" | |
added
jobka/connectors/greenhouse.py
+67 −0
@@ -0,0 +1,67 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : jobka/connectors/greenhouse.py | |
| 6 | +# Rôle : Classe de plateforme Greenhouse (boards-api.greenhouse.io) — un | |
| 7 | +# employeur = une sous-classe de ~8 lignes (BOARD, EMPLOYER) | |
| 8 | +# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | +# ============================================================================= | |
| 10 | +"""Plateforme Greenhouse. | |
| 11 | + | |
| 12 | +API publique : GET https://boards-api.greenhouse.io/v1/boards/<board>/jobs | |
| 13 | +avec ?content=true -> {jobs:[{id, title, absolute_url, location:{name}, | |
| 14 | +first_published, updated_at, application_deadline, content(HTML échappé), | |
| 15 | +departments, offices, metadata}]}. | |
| 16 | + | |
| 17 | +Une seule requête liste TOUT (contenu inclus) : pas de page détail. | |
| 18 | +`application_deadline` alimente date_deadline (retrait automatique). | |
| 19 | +""" | |
| 20 | +from __future__ import annotations | |
| 21 | + | |
| 22 | +from ..schema import JobPosting, clean_html, is_quebec_location | |
| 23 | +from .base import BaseConnector | |
| 24 | + | |
| 25 | + | |
| 26 | +class GreenhouseConnector(BaseConnector): | |
| 27 | + """Base Greenhouse — sous-classes : définir source_id, EMPLOYER, BOARD.""" | |
| 28 | + | |
| 29 | + ats = "greenhouse" | |
| 30 | + request_delay = 1.0 | |
| 31 | + | |
| 32 | + EMPLOYER = "" | |
| 33 | + BOARD = "" | |
| 34 | + quebec_only = True | |
| 35 | + | |
| 36 | + def _keep(self, item: dict) -> bool: | |
| 37 | + if not self.quebec_only: | |
| 38 | + return True | |
| 39 | + # strict sur le lieu du POSTE (les bureaux de l'entreprise sont trop | |
| 40 | + # larges : un employeur québécois peut afficher un poste aux États-Unis) | |
| 41 | + return is_quebec_location((item.get("location") or {}).get("name") or "") | |
| 42 | + | |
| 43 | + def fetch(self) -> list[JobPosting]: | |
| 44 | + data = self.get( | |
| 45 | + f"https://boards-api.greenhouse.io/v1/boards/{self.BOARD}/jobs", | |
| 46 | + params={"content": "true"}).json() | |
| 47 | + out: list[JobPosting] = [] | |
| 48 | + for item in data.get("jobs") or []: | |
| 49 | + if not self._keep(item): | |
| 50 | + continue | |
| 51 | + deadline = item.get("application_deadline") or None | |
| 52 | + job = JobPosting( | |
| 53 | + source=self.source_id, external_id=str(item.get("id") or ""), | |
| 54 | + url=item.get("absolute_url") or "", | |
| 55 | + employer=self.EMPLOYER or item.get("company_name") or "", | |
| 56 | + title=item.get("title") or "", | |
| 57 | + description=clean_html(item.get("content") or ""), | |
| 58 | + location_label=(item.get("location") or {}).get("name") or "", | |
| 59 | + date_posted=item.get("first_published") or item.get("updated_at"), | |
| 60 | + date_deadline=deadline, | |
| 61 | + ats=self.ats, | |
| 62 | + ) | |
| 63 | + deps = [d.get("name") for d in item.get("departments") or [] if d.get("name")] | |
| 64 | + if deps: | |
| 65 | + job.details["team"] = deps[0] | |
| 66 | + out.append(job) | |
| 67 | + return out | |
added
jobka/connectors/lever.py
+85 −0
@@ -0,0 +1,85 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : jobka/connectors/lever.py | |
| 6 | +# Rôle : Classe de plateforme Lever (api.lever.co/v0/postings) — un | |
| 7 | +# employeur = une sous-classe de ~8 lignes (ORG, EMPLOYER) | |
| 8 | +# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | +# ============================================================================= | |
| 10 | +"""Plateforme Lever. | |
| 11 | + | |
| 12 | +API publique : GET https://api.lever.co/v0/postings/<org>?mode=json | |
| 13 | +-> liste de {id, text, hostedUrl, createdAt(epoch ms), country, | |
| 14 | + workplaceType(remote|hybrid|on-site|unspecified), descriptionBodyPlain, | |
| 15 | + categories:{location, allLocations, commitment, team, department}, | |
| 16 | + salaryRange:{min, max, currency, interval} quand publié}. | |
| 17 | + | |
| 18 | +Une seule requête liste TOUT (descriptions incluses) : pas de page détail. | |
| 19 | +""" | |
| 20 | +from __future__ import annotations | |
| 21 | + | |
| 22 | +from ..schema import JobPosting, is_quebec_location | |
| 23 | +from .base import BaseConnector | |
| 24 | + | |
| 25 | +_WORKPLACE = {"remote": "teletravail", "hybrid": "hybride", | |
| 26 | + "on-site": "presentiel", "onsite": "presentiel"} | |
| 27 | +_INTERVAL = {"per-hour-salary": "hour", "per-year-salary": "year", | |
| 28 | + "per-month-salary": "month", "per-week-salary": "week", | |
| 29 | + "hourly": "hour", "yearly": "year", "annual": "year", | |
| 30 | + "monthly": "month"} | |
| 31 | + | |
| 32 | + | |
| 33 | +class LeverConnector(BaseConnector): | |
| 34 | + """Base Lever — sous-classes : définir source_id, EMPLOYER, ORG.""" | |
| 35 | + | |
| 36 | + ats = "lever" | |
| 37 | + request_delay = 1.0 | |
| 38 | + | |
| 39 | + EMPLOYER = "" | |
| 40 | + ORG = "" | |
| 41 | + quebec_only = True | |
| 42 | + | |
| 43 | + def _keep(self, item: dict) -> bool: | |
| 44 | + if not self.quebec_only: | |
| 45 | + return True | |
| 46 | + cats = item.get("categories") or {} | |
| 47 | + locs = cats.get("allLocations") or [cats.get("location") or ""] | |
| 48 | + return any(is_quebec_location(l or "") for l in locs) | |
| 49 | + | |
| 50 | + def fetch(self) -> list[JobPosting]: | |
| 51 | + items = self.get(f"https://api.lever.co/v0/postings/{self.ORG}", | |
| 52 | + params={"mode": "json"}).json() | |
| 53 | + out: list[JobPosting] = [] | |
| 54 | + for item in items: | |
| 55 | + if not self._keep(item): | |
| 56 | + continue | |
| 57 | + cats = item.get("categories") or {} | |
| 58 | + sal = item.get("salaryRange") or {} | |
| 59 | + desc = (item.get("descriptionBodyPlain") | |
| 60 | + or item.get("descriptionPlain") or "") | |
| 61 | + # les puces (exigences, avantages) sont dans lists[] | |
| 62 | + for lst in item.get("lists") or []: | |
| 63 | + titre = lst.get("text") or "" | |
| 64 | + contenu = lst.get("content") or "" | |
| 65 | + if contenu: | |
| 66 | + from ..normalize import clean_html | |
| 67 | + desc += f"\n\n{titre}\n{clean_html(contenu)}" | |
| 68 | + job = JobPosting( | |
| 69 | + source=self.source_id, external_id=str(item.get("id") or ""), | |
| 70 | + url=item.get("hostedUrl") or "", | |
| 71 | + employer=self.EMPLOYER, | |
| 72 | + title=item.get("text") or "", | |
| 73 | + description=desc, | |
| 74 | + location_label=cats.get("location") or "", | |
| 75 | + work_mode=_WORKPLACE.get(item.get("workplaceType") or ""), | |
| 76 | + date_posted=item.get("createdAt"), | |
| 77 | + salary_min=sal.get("min"), salary_max=sal.get("max"), | |
| 78 | + salary_unit=_INTERVAL.get((sal.get("interval") or "").lower()), | |
| 79 | + ats=self.ats, | |
| 80 | + ) | |
| 81 | + job.details["employment_label"] = cats.get("commitment") or "" | |
| 82 | + if cats.get("team"): | |
| 83 | + job.details["team"] = cats["team"] | |
| 84 | + out.append(job) | |
| 85 | + return out | |
added
jobka/connectors/mistplay.py
+15 −0
@@ -0,0 +1,15 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : jobka/connectors/mistplay.py | |
| 6 | +# Rôle : Connecteur Mistplay (jeu mobile, Montréal) — Lever, org « mistplay » | |
| 7 | +# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 8 | +# ============================================================================= | |
| 9 | +from .lever import LeverConnector | |
| 10 | + | |
| 11 | + | |
| 12 | +class MistplayConnector(LeverConnector): | |
| 13 | + source_id = "mistplay" | |
| 14 | + EMPLOYER = "Mistplay" | |
| 15 | + ORG = "mistplay" | |
added
jobka/connectors/poka.py
+16 −0
@@ -0,0 +1,16 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : jobka/connectors/poka.py | |
| 6 | +# Rôle : Connecteur Poka (plateforme SaaS manufacturière, Québec) — | |
| 7 | +# Greenhouse, board « poka » | |
| 8 | +# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | +# ============================================================================= | |
| 10 | +from .greenhouse import GreenhouseConnector | |
| 11 | + | |
| 12 | + | |
| 13 | +class PokaConnector(GreenhouseConnector): | |
| 14 | + source_id = "poka" | |
| 15 | + EMPLOYER = "Poka" | |
| 16 | + BOARD = "poka" | |
added
jobka/connectors/smartrecruiters.py
+124 −0
@@ -0,0 +1,124 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : jobka/connectors/smartrecruiters.py | |
| 6 | +# Rôle : Classe de plateforme SmartRecruiters (api.smartrecruiters.com) — | |
| 7 | +# un employeur = une sous-classe de ~8 lignes (COMPANY, EMPLOYER) | |
| 8 | +# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | +# ============================================================================= | |
| 10 | +"""Plateforme SmartRecruiters. | |
| 11 | + | |
| 12 | +API publique : | |
| 13 | +- liste : GET https://api.smartrecruiters.com/v1/companies/<company>/postings | |
| 14 | + ?limit=100&offset=N -> {totalFound, content:[{id, name, releasedDate, | |
| 15 | + location:{city, region, country, remote, latitude, longitude}, | |
| 16 | + typeOfEmployment:{label}, experienceLevel, ref}]} | |
| 17 | +- détail : GET .../postings/<id> -> {jobAd:{sections:{jobDescription, | |
| 18 | + qualifications, additionalInformation:{title, text(HTML)}}}} | |
| 19 | + | |
| 20 | +Le lieu inclut lat/lng : on les passe directement (validés par finalize()). | |
| 21 | +Page détail avec cache BD (clé = releasedDate) + budget de requêtes. | |
| 22 | +""" | |
| 23 | +from __future__ import annotations | |
| 24 | + | |
| 25 | +import os | |
| 26 | + | |
| 27 | +from ..schema import JobPosting, clean_html | |
| 28 | +from .base import BaseConnector | |
| 29 | + | |
| 30 | +PAGE_SIZE = 100 | |
| 31 | +MAX_DETAILS = int(os.environ.get("JOBKA_SR_DETAIL_LIMIT", "300")) | |
| 32 | + | |
| 33 | + | |
| 34 | +class SmartRecruitersConnector(BaseConnector): | |
| 35 | + """Base SmartRecruiters — sous-classes : source_id, EMPLOYER, COMPANY.""" | |
| 36 | + | |
| 37 | + ats = "smartrecruiters" | |
| 38 | + request_delay = 0.7 | |
| 39 | + | |
| 40 | + EMPLOYER = "" | |
| 41 | + COMPANY = "" | |
| 42 | + max_pages = 20 | |
| 43 | + quebec_only = True | |
| 44 | + | |
| 45 | + @property | |
| 46 | + def _base(self) -> str: | |
| 47 | + return f"https://api.smartrecruiters.com/v1/companies/{self.COMPANY}/postings" | |
| 48 | + | |
| 49 | + def _keep(self, item: dict) -> bool: | |
| 50 | + if not self.quebec_only: | |
| 51 | + return True | |
| 52 | + loc = item.get("location") or {} | |
| 53 | + if (loc.get("country") or "").lower() != "ca": | |
| 54 | + return False | |
| 55 | + from ..schema import is_quebec_location | |
| 56 | + return is_quebec_location( | |
| 57 | + f"{loc.get('city') or ''}, {loc.get('region') or ''}") | |
| 58 | + | |
| 59 | + def _fetch_detail(self, posting_id: str) -> dict: | |
| 60 | + data = self.get(f"{self._base}/{posting_id}").json() | |
| 61 | + sections = ((data.get("jobAd") or {}).get("sections") or {}) | |
| 62 | + parts = [] | |
| 63 | + for name in ("companyDescription", "jobDescription", "qualifications", | |
| 64 | + "additionalInformation"): | |
| 65 | + sec = sections.get(name) or {} | |
| 66 | + if sec.get("text"): | |
| 67 | + parts.append(f"{sec.get('title') or ''}\n{sec['text']}") | |
| 68 | + return {"description_html": "\n\n".join(parts)} | |
| 69 | + | |
| 70 | + def fetch(self) -> list[JobPosting]: | |
| 71 | + out: list[JobPosting] = [] | |
| 72 | + details_used = 0 | |
| 73 | + offset = 0 | |
| 74 | + for _ in range(self.max_pages): | |
| 75 | + data = self.get(self._base, params={"limit": PAGE_SIZE, | |
| 76 | + "offset": offset}).json() | |
| 77 | + items = data.get("content") or [] | |
| 78 | + if not items: | |
| 79 | + break | |
| 80 | + for item in items: | |
| 81 | + if not self._keep(item): | |
| 82 | + continue | |
| 83 | + loc = item.get("location") or {} | |
| 84 | + eid = str(item.get("id") or "") | |
| 85 | + if not eid: | |
| 86 | + continue | |
| 87 | + try: | |
| 88 | + lat = float(loc.get("latitude")) | |
| 89 | + lng = float(loc.get("longitude")) | |
| 90 | + except (TypeError, ValueError): | |
| 91 | + lat = lng = None | |
| 92 | + job = JobPosting( | |
| 93 | + source=self.source_id, external_id=eid, | |
| 94 | + url=f"https://jobs.smartrecruiters.com/{self.COMPANY}/{eid}", | |
| 95 | + employer=self.EMPLOYER or (item.get("company") or {}).get("name", ""), | |
| 96 | + title=item.get("name") or "", | |
| 97 | + city=(loc.get("city") or ""), | |
| 98 | + location_label=loc.get("fullLocation") | |
| 99 | + or f"{loc.get('city') or ''}, {loc.get('region') or ''}", | |
| 100 | + work_mode="teletravail" if loc.get("remote") | |
| 101 | + else ("hybride" if loc.get("hybrid") else None), | |
| 102 | + date_posted=item.get("releasedDate"), | |
| 103 | + lat=lat, lng=lng, | |
| 104 | + ats=self.ats, | |
| 105 | + ) | |
| 106 | + job.details["employment_label"] = \ | |
| 107 | + (item.get("typeOfEmployment") or {}).get("label") or "" | |
| 108 | + key = str(item.get("releasedDate") or "")[:19] | |
| 109 | + if details_used < MAX_DETAILS: | |
| 110 | + fresh = [False] | |
| 111 | + | |
| 112 | + def _fn(pid=eid, fresh=fresh): | |
| 113 | + fresh[0] = True | |
| 114 | + return self._fetch_detail(pid) | |
| 115 | + | |
| 116 | + d = self.detail(eid, key, _fn) | |
| 117 | + if fresh[0]: | |
| 118 | + details_used += 1 | |
| 119 | + job.description = clean_html(d.get("description_html", "")) | |
| 120 | + out.append(job) | |
| 121 | + offset += PAGE_SIZE | |
| 122 | + if offset >= int(data.get("totalFound") or 0): | |
| 123 | + break | |
| 124 | + return out | |
added
jobka/connectors/ubisoft.py
+16 −0
@@ -0,0 +1,16 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : jobka/connectors/ubisoft.py | |
| 6 | +# Rôle : Connecteur Ubisoft (jeux vidéo — studios de Montréal, Québec et | |
| 7 | +# Sherbrooke) — SmartRecruiters, company « Ubisoft2 », filtré QC | |
| 8 | +# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | +# ============================================================================= | |
| 10 | +from .smartrecruiters import SmartRecruitersConnector | |
| 11 | + | |
| 12 | + | |
| 13 | +class UbisoftConnector(SmartRecruitersConnector): | |
| 14 | + source_id = "ubisoft" | |
| 15 | + EMPLOYER = "Ubisoft" | |
| 16 | + COMPANY = "Ubisoft2" | |
added
jobka/connectors/workday.py
+174 −0
@@ -0,0 +1,174 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : jobka/connectors/workday.py | |
| 6 | +# Rôle : Classe de plateforme Workday (API JSON publique wday/cxs) — un | |
| 7 | +# employeur = une sous-classe de ~10 lignes (TENANT, HOST, SITE) | |
| 8 | +# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | +# ============================================================================= | |
| 10 | +"""Plateforme Workday. | |
| 11 | + | |
| 12 | +L'API publique des sites carrières Workday est stable et non authentifiée : | |
| 13 | +- liste : POST https://<tenant>.<host>.myworkdayjobs.com/wday/cxs/<tenant>/<site>/jobs | |
| 14 | + body {"appliedFacets":{},"limit":20,"offset":N,"searchText":""} | |
| 15 | + -> {total, jobPostings:[{title, externalPath, locationsText, | |
| 16 | + postedOn, bulletFields:[req_id]}]} | |
| 17 | +- détail : GET .../wday/cxs/<tenant>/<site><externalPath> | |
| 18 | + -> {jobPostingInfo:{jobDescription(HTML), location, timeType, | |
| 19 | + jobReqId, startDate, …}} | |
| 20 | + | |
| 21 | +Les sites listent souvent des postes hors Québec : on filtre côté client par | |
| 22 | +`is_quebec_location`, puis on ne visite la page détail QUE pour les postes | |
| 23 | +québécois (avec cache BD — le détail n'est re-téléchargé que si la ligne de | |
| 24 | +liste change). | |
| 25 | +""" | |
| 26 | +from __future__ import annotations | |
| 27 | + | |
| 28 | +import hashlib | |
| 29 | +import os | |
| 30 | +import re | |
| 31 | + | |
| 32 | +from ..schema import JobPosting, clean_html, is_quebec_location | |
| 33 | +from .base import BaseConnector | |
| 34 | + | |
| 35 | +PAGE_SIZE = 20 | |
| 36 | +MAX_DETAILS = int(os.environ.get("JOBKA_WORKDAY_DETAIL_LIMIT", "300")) | |
| 37 | + | |
| 38 | + | |
| 39 | +class WorkdayConnector(BaseConnector): | |
| 40 | + """Base Workday — sous-classes : définir source_id, EMPLOYER, TENANT, | |
| 41 | + HOST (wd1/wd3/wd10…), SITE, et au besoin surcharger `_keep()`.""" | |
| 42 | + | |
| 43 | + ats = "workday" | |
| 44 | + request_delay = 0.7 | |
| 45 | + | |
| 46 | + EMPLOYER = "" | |
| 47 | + TENANT = "" | |
| 48 | + HOST = "wd3" | |
| 49 | + SITE = "" | |
| 50 | + max_pages = 60 # garde-fou (60 × 20 = 1200 postes) | |
| 51 | + quebec_only = True # filtrer les lieux hors Québec | |
| 52 | + | |
| 53 | + @property | |
| 54 | + def _base(self) -> str: | |
| 55 | + return f"https://{self.TENANT}.{self.HOST}.myworkdayjobs.com" | |
| 56 | + | |
| 57 | + def _keep(self, item: dict) -> bool: | |
| 58 | + """Crochet de filtrage d'une ligne de liste (défaut : lieu québécois).""" | |
| 59 | + if not self.quebec_only: | |
| 60 | + return True | |
| 61 | + return is_quebec_location(item.get("locationsText") or "") | |
| 62 | + | |
| 63 | + @staticmethod | |
| 64 | + def _find_qc_facet(facets: list) -> dict | None: | |
| 65 | + """Cherche une facette « Québec » (Region, State/Province…) dans la | |
| 66 | + réponse liste. Workday la fournit sur la plupart des tenants — filtrer | |
| 67 | + côté serveur est plus fiable que deviner depuis « Acton Vale » ou | |
| 68 | + « 2 Locations », et évite de paginer les postes hors province. | |
| 69 | + """ | |
| 70 | + def walk(param: str | None, values: list) -> dict | None: | |
| 71 | + for v in values or []: | |
| 72 | + p = v.get("facetParameter") or param | |
| 73 | + desc = (v.get("descriptor") or "").strip().lower() | |
| 74 | + if desc in ("quebec", "québec", "qc", "quebec, canada") and \ | |
| 75 | + v.get("id") and p: | |
| 76 | + return {p: [v["id"]]} | |
| 77 | + found = walk(p, v.get("values")) | |
| 78 | + if found: | |
| 79 | + return found | |
| 80 | + return None | |
| 81 | + for f in facets or []: | |
| 82 | + found = walk(f.get("facetParameter"), f.get("values")) | |
| 83 | + if found: | |
| 84 | + return found | |
| 85 | + return None | |
| 86 | + | |
| 87 | + def _list_pages(self): | |
| 88 | + url = f"{self._base}/wday/cxs/{self.TENANT}/{self.SITE}/jobs" | |
| 89 | + applied: dict = {} | |
| 90 | + offset = 0 | |
| 91 | + total = 0 | |
| 92 | + first = True | |
| 93 | + for _ in range(self.max_pages): | |
| 94 | + data = self.post(url, json={"appliedFacets": applied, | |
| 95 | + "limit": PAGE_SIZE, | |
| 96 | + "offset": offset, "searchText": ""}).json() | |
| 97 | + if first: | |
| 98 | + first = False | |
| 99 | + if self.quebec_only: | |
| 100 | + qc = self._find_qc_facet(data.get("facets")) | |
| 101 | + if qc: | |
| 102 | + # filtre serveur trouvé : rejouer la pagination avec, et | |
| 103 | + # neutraliser le filtre client (libellés de villes nues) | |
| 104 | + applied = qc | |
| 105 | + self._server_filtered = True | |
| 106 | + continue | |
| 107 | + items = data.get("jobPostings") or [] | |
| 108 | + if not items: | |
| 109 | + return | |
| 110 | + yield from items | |
| 111 | + offset += PAGE_SIZE | |
| 112 | + # Workday ne renvoie `total` que sur la première page quand une | |
| 113 | + # facette est appliquée (0 ensuite) : mémoriser la valeur connue | |
| 114 | + # et s'arrêter aussi sur page incomplète. | |
| 115 | + page_total = int(data.get("total") or 0) | |
| 116 | + if page_total: | |
| 117 | + total = page_total | |
| 118 | + if len(items) < PAGE_SIZE or (total and offset >= total): | |
| 119 | + return | |
| 120 | + | |
| 121 | + def _fetch_detail(self, external_path: str) -> dict: | |
| 122 | + data = self.get(f"{self._base}/wday/cxs/{self.TENANT}/{self.SITE}" | |
| 123 | + f"{external_path}").json() | |
| 124 | + info = data.get("jobPostingInfo") or {} | |
| 125 | + return { | |
| 126 | + "description_html": info.get("jobDescription") or "", | |
| 127 | + "location": info.get("location") or "", | |
| 128 | + "time_type": info.get("timeType") or "", | |
| 129 | + "posted_on": info.get("postedOn") or "", | |
| 130 | + "req_id": info.get("jobReqId") or "", | |
| 131 | + } | |
| 132 | + | |
| 133 | + def fetch(self) -> list[JobPosting]: | |
| 134 | + out: list[JobPosting] = [] | |
| 135 | + details_used = 0 | |
| 136 | + self._server_filtered = False | |
| 137 | + for item in self._list_pages(): | |
| 138 | + if not self._server_filtered and not self._keep(item): | |
| 139 | + continue | |
| 140 | + path = item.get("externalPath") or "" | |
| 141 | + bullets = item.get("bulletFields") or [] | |
| 142 | + eid = (bullets[0] if bullets else "") or path.rsplit("_", 1)[-1] | |
| 143 | + if not eid or not path: | |
| 144 | + continue | |
| 145 | + job = JobPosting( | |
| 146 | + source=self.source_id, external_id=str(eid), | |
| 147 | + url=f"{self._base}/{self.SITE}{path}", | |
| 148 | + employer=self.EMPLOYER, | |
| 149 | + title=item.get("title") or "", | |
| 150 | + location_label=item.get("locationsText") or "", | |
| 151 | + date_posted=item.get("postedOn") or None, | |
| 152 | + ats=self.ats, | |
| 153 | + ) | |
| 154 | + # page détail (description, type d'emploi) avec cache + budget | |
| 155 | + key = hashlib.sha1( | |
| 156 | + f"{path}|{item.get('postedOn','')}".encode()).hexdigest()[:12] | |
| 157 | + if details_used < MAX_DETAILS: | |
| 158 | + fresh = [False] | |
| 159 | + | |
| 160 | + def _fn(p=path, fresh=fresh): | |
| 161 | + fresh[0] = True | |
| 162 | + return self._fetch_detail(p) | |
| 163 | + | |
| 164 | + d = self.detail(str(eid), key, _fn) | |
| 165 | + if fresh[0]: | |
| 166 | + details_used += 1 | |
| 167 | + job.description = clean_html(d.get("description_html", "")) | |
| 168 | + job.details["employment_label"] = d.get("time_type", "") | |
| 169 | + # « 2 Locations » : le vrai lieu principal est sur la page détail | |
| 170 | + if d.get("location") and (not job.location_label or re.match( | |
| 171 | + r"^\d+\s+locations?$", job.location_label, re.I)): | |
| 172 | + job.location_label = d["location"] | |
| 173 | + out.append(job) | |
| 174 | + return out | |
added
jobka/db.py
+369 −0
@@ -0,0 +1,369 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : jobka/db.py | |
| 6 | +# Rôle : Persistance SQLite — upsert avec détection de changements, cycle de | |
| 7 | +# vie avec délai de grâce, anti-dérive, expiration sur date limite | |
| 8 | +# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | +# ============================================================================= | |
| 10 | +from __future__ import annotations | |
| 11 | + | |
| 12 | +import datetime as _dt | |
| 13 | +import json | |
| 14 | +import sqlite3 | |
| 15 | +import statistics | |
| 16 | +import time | |
| 17 | +from pathlib import Path | |
| 18 | + | |
| 19 | +from .schema import JobPosting | |
| 20 | + | |
| 21 | +DB_PATH = Path(__file__).resolve().parent.parent / "data" / "jobka.db" | |
| 22 | + | |
| 23 | +# Nombre d'exécutions consécutives où une offre doit être absente de la | |
| 24 | +# source avant d'être désactivée (délai de grâce contre les ratés ponctuels). | |
| 25 | +MISS_GRACE = 2 | |
| 26 | + | |
| 27 | +# Dérive : si une source retourne <= DRIFT_RATIO × sa médiane historique | |
| 28 | +# (médiane >= DRIFT_MIN_BASE offres), on alerte et on suspend les retraits. | |
| 29 | +DRIFT_RATIO = 0.25 | |
| 30 | +DRIFT_MIN_BASE = 8 | |
| 31 | +DRIFT_HISTORY = 5 | |
| 32 | + | |
| 33 | +_SCHEMA = """ | |
| 34 | +CREATE TABLE IF NOT EXISTS jobs ( | |
| 35 | + uid TEXT PRIMARY KEY, | |
| 36 | + source TEXT NOT NULL, | |
| 37 | + external_id TEXT NOT NULL, | |
| 38 | + url TEXT, | |
| 39 | + employer TEXT, | |
| 40 | + title TEXT, | |
| 41 | + description TEXT, | |
| 42 | + address TEXT, | |
| 43 | + city TEXT, | |
| 44 | + region TEXT, | |
| 45 | + postal_code TEXT, | |
| 46 | + location_label TEXT, | |
| 47 | + work_mode TEXT, | |
| 48 | + employment_type TEXT, | |
| 49 | + salary_min REAL, | |
| 50 | + salary_max REAL, | |
| 51 | + salary_unit TEXT, | |
| 52 | + salary_label TEXT, | |
| 53 | + salary_year_min REAL, -- conversions dérivées ($/an, $/h) pour filtres/tri | |
| 54 | + salary_year_max REAL, | |
| 55 | + salary_hour_min REAL, | |
| 56 | + salary_hour_max REAL, | |
| 57 | + benefits TEXT, -- JSON (liste de textes source) | |
| 58 | + requirements TEXT, -- JSON (scolarité, expérience, langues) | |
| 59 | + date_posted TEXT, | |
| 60 | + date_deadline TEXT, | |
| 61 | + category TEXT, | |
| 62 | + ats TEXT, | |
| 63 | + details TEXT, -- JSON (champs structurés, sparse) | |
| 64 | + lat REAL, | |
| 65 | + lng REAL, | |
| 66 | + geocode_failed INTEGER DEFAULT 0, | |
| 67 | + content_hash TEXT, | |
| 68 | + first_seen REAL, | |
| 69 | + last_seen REAL, | |
| 70 | + updated_at REAL, | |
| 71 | + miss_count INTEGER DEFAULT 0, | |
| 72 | + active INTEGER DEFAULT 1, | |
| 73 | + dup_of TEXT, -- uid de l'offre canonique si doublon inter-sources | |
| 74 | + dup_sources TEXT -- JSON : autres sources où l'offre est publiée | |
| 75 | +); | |
| 76 | +CREATE INDEX IF NOT EXISTS idx_jobs_source ON jobs(source); | |
| 77 | +CREATE INDEX IF NOT EXISTS idx_jobs_city ON jobs(city); | |
| 78 | +CREATE INDEX IF NOT EXISTS idx_jobs_active ON jobs(active); | |
| 79 | +CREATE INDEX IF NOT EXISTS idx_jobs_category ON jobs(category); | |
| 80 | +CREATE INDEX IF NOT EXISTS idx_jobs_geo ON jobs(lat, lng); | |
| 81 | + | |
| 82 | +CREATE TABLE IF NOT EXISTS sync_log ( | |
| 83 | + id INTEGER PRIMARY KEY AUTOINCREMENT, | |
| 84 | + source TEXT, | |
| 85 | + ts REAL, | |
| 86 | + found INTEGER, | |
| 87 | + added INTEGER, | |
| 88 | + updated INTEGER, | |
| 89 | + removed INTEGER, | |
| 90 | + ok INTEGER, | |
| 91 | + message TEXT, | |
| 92 | + stats TEXT -- JSON : taux de champs null, missed, alerte… | |
| 93 | +); | |
| 94 | + | |
| 95 | +CREATE TABLE IF NOT EXISTS detail_cache ( | |
| 96 | + source TEXT NOT NULL, | |
| 97 | + external_id TEXT NOT NULL, | |
| 98 | + key TEXT, -- hash du contenu « liste » de l'offre | |
| 99 | + payload TEXT, -- JSON opaque propre au connecteur | |
| 100 | + fetched_at REAL, | |
| 101 | + PRIMARY KEY (source, external_id) | |
| 102 | +); | |
| 103 | + | |
| 104 | +CREATE TABLE IF NOT EXISTS salary_log ( | |
| 105 | + uid TEXT NOT NULL, | |
| 106 | + ts REAL NOT NULL, | |
| 107 | + s_min REAL, | |
| 108 | + s_max REAL, | |
| 109 | + unit TEXT | |
| 110 | +); | |
| 111 | +CREATE INDEX IF NOT EXISTS idx_salary_log_uid ON salary_log(uid); | |
| 112 | + | |
| 113 | +CREATE TABLE IF NOT EXISTS geocode_cache ( | |
| 114 | + address TEXT PRIMARY KEY, -- adresse/ville normalisée (clé de cache) | |
| 115 | + lat REAL, | |
| 116 | + lng REAL, | |
| 117 | + provider TEXT, | |
| 118 | + failed INTEGER DEFAULT 0, | |
| 119 | + ts REAL | |
| 120 | +); | |
| 121 | +""" | |
| 122 | + | |
| 123 | +# Colonnes ajoutées après la v1 — migration automatique des bases existantes. | |
| 124 | +_MIGRATIONS: dict[str, dict[str, str]] = { | |
| 125 | + "jobs": {}, | |
| 126 | + "sync_log": {}, | |
| 127 | +} | |
| 128 | + | |
| 129 | + | |
| 130 | +def connect() -> sqlite3.Connection: | |
| 131 | + DB_PATH.parent.mkdir(parents=True, exist_ok=True) | |
| 132 | + con = sqlite3.connect(DB_PATH) | |
| 133 | + con.row_factory = sqlite3.Row | |
| 134 | + con.executescript(_SCHEMA) | |
| 135 | + for table, cols in _MIGRATIONS.items(): | |
| 136 | + existing = {r["name"] for r in con.execute(f"PRAGMA table_info({table})")} | |
| 137 | + for col, decl in cols.items(): | |
| 138 | + if col not in existing: | |
| 139 | + con.execute(f"ALTER TABLE {table} ADD COLUMN {col} {decl}") | |
| 140 | + con.commit() | |
| 141 | + return con | |
| 142 | + | |
| 143 | + | |
| 144 | +# --------------------------------------------------------------------------- | |
| 145 | +# Synchronisation d'une source | |
| 146 | +# --------------------------------------------------------------------------- | |
| 147 | + | |
| 148 | +def _drift_alert(con: sqlite3.Connection, source: str, found: int, | |
| 149 | + null_salary_rate: float) -> str | None: | |
| 150 | + """Détecte une dérive du connecteur (chute du volume ou des salaires extraits). | |
| 151 | + | |
| 152 | + Retourne un message d'alerte, ou None si tout est normal. | |
| 153 | + """ | |
| 154 | + hist = con.execute( | |
| 155 | + "SELECT found, stats FROM sync_log WHERE source=? AND ok=1" | |
| 156 | + " ORDER BY ts DESC LIMIT ?", (source, DRIFT_HISTORY)).fetchall() | |
| 157 | + if len(hist) < 3: | |
| 158 | + return None | |
| 159 | + med_found = statistics.median(r["found"] for r in hist) | |
| 160 | + if med_found >= DRIFT_MIN_BASE and found <= DRIFT_RATIO * med_found: | |
| 161 | + return (f"dérive: {found} offre(s) trouvée(s) contre une médiane de " | |
| 162 | + f"{med_found:.0f} — retraits suspendus, vérifier le connecteur") | |
| 163 | + if found >= DRIFT_MIN_BASE and null_salary_rate >= 0.95: | |
| 164 | + rates = [] | |
| 165 | + for r in hist: | |
| 166 | + try: | |
| 167 | + rates.append(json.loads(r["stats"] or "{}")["null_salary_rate"]) | |
| 168 | + except (KeyError, ValueError, TypeError): | |
| 169 | + continue | |
| 170 | + if rates and statistics.median(rates) <= 0.5: | |
| 171 | + return (f"dérive: {null_salary_rate:.0%} des offres sans salaire " | |
| 172 | + f"(habituellement {statistics.median(rates):.0%}) — " | |
| 173 | + "le format de la source a probablement changé") | |
| 174 | + return None | |
| 175 | + | |
| 176 | + | |
| 177 | +def sync_source(con: sqlite3.Connection, source: str, | |
| 178 | + postings: list[JobPosting]) -> dict: | |
| 179 | + """Synchronise les offres d'une source. | |
| 180 | + | |
| 181 | + - nouvelle offre -> insertion | |
| 182 | + - offre modifiée -> mise à jour (comparaison de content_hash) | |
| 183 | + - offre disparue -> miss_count += 1, puis active=0 après MISS_GRACE | |
| 184 | + exécutions consécutives (délai de grâce) | |
| 185 | + - dérive détectée -> alerte consignée, retraits suspendus | |
| 186 | + """ | |
| 187 | + now = time.time() | |
| 188 | + added = updated = 0 | |
| 189 | + seen_uids = set() | |
| 190 | + | |
| 191 | + n = len(postings) | |
| 192 | + null_salary = sum(1 for p in postings if p.salary_min is None) | |
| 193 | + null_city = sum(1 for p in postings if not p.city) | |
| 194 | + null_salary_rate = round(null_salary / n, 3) if n else 0.0 | |
| 195 | + | |
| 196 | + alert = _drift_alert(con, source, n, null_salary_rate) | |
| 197 | + | |
| 198 | + for job in postings: | |
| 199 | + seen_uids.add(job.uid) | |
| 200 | + h = job.content_hash() | |
| 201 | + row = con.execute( | |
| 202 | + "SELECT content_hash, salary_min, salary_max FROM jobs WHERE uid=?", | |
| 203 | + (job.uid,)).fetchone() | |
| 204 | + params = dict( | |
| 205 | + uid=job.uid, source=job.source, external_id=job.external_id, | |
| 206 | + url=job.url, employer=job.employer, title=job.title, | |
| 207 | + description=job.description, address=job.address, city=job.city, | |
| 208 | + region=job.region, postal_code=job.postal_code, | |
| 209 | + location_label=job.location_label, work_mode=job.work_mode, | |
| 210 | + employment_type=job.employment_type, | |
| 211 | + salary_min=job.salary_min, salary_max=job.salary_max, | |
| 212 | + salary_unit=job.salary_unit, salary_label=job.salary_label, | |
| 213 | + salary_year_min=job.salary_year_min(), | |
| 214 | + salary_year_max=job.salary_year_max(), | |
| 215 | + salary_hour_min=job.salary_hour_min(), | |
| 216 | + salary_hour_max=job.salary_hour_max(), | |
| 217 | + benefits=json.dumps(job.benefits, ensure_ascii=False), | |
| 218 | + requirements=json.dumps(job.requirements, ensure_ascii=False), | |
| 219 | + date_posted=job.date_posted, date_deadline=job.date_deadline, | |
| 220 | + category=job.category, ats=job.ats, | |
| 221 | + details=json.dumps(job.details, ensure_ascii=False), | |
| 222 | + lat=job.lat, lng=job.lng, content_hash=h, now=now, | |
| 223 | + ) | |
| 224 | + if row is None: | |
| 225 | + con.execute( | |
| 226 | + """INSERT INTO jobs (uid, source, external_id, url, employer, | |
| 227 | + title, description, address, city, region, postal_code, | |
| 228 | + location_label, work_mode, employment_type, salary_min, | |
| 229 | + salary_max, salary_unit, salary_label, salary_year_min, | |
| 230 | + salary_year_max, salary_hour_min, salary_hour_max, benefits, | |
| 231 | + requirements, date_posted, date_deadline, category, ats, | |
| 232 | + details, lat, lng, content_hash, first_seen, last_seen, | |
| 233 | + updated_at, miss_count, active) | |
| 234 | + VALUES (:uid,:source,:external_id,:url,:employer,:title, | |
| 235 | + :description,:address,:city,:region,:postal_code, | |
| 236 | + :location_label,:work_mode,:employment_type,:salary_min, | |
| 237 | + :salary_max,:salary_unit,:salary_label,:salary_year_min, | |
| 238 | + :salary_year_max,:salary_hour_min,:salary_hour_max, | |
| 239 | + :benefits,:requirements,:date_posted,:date_deadline, | |
| 240 | + :category,:ats,:details,:lat,:lng,:content_hash, | |
| 241 | + :now,:now,:now,0,1)""", params) | |
| 242 | + if job.salary_min is not None: # point de départ de l'historique | |
| 243 | + con.execute( | |
| 244 | + "INSERT INTO salary_log (uid, ts, s_min, s_max, unit)" | |
| 245 | + " VALUES (?,?,?,?,?)", | |
| 246 | + (job.uid, now, job.salary_min, job.salary_max, job.salary_unit)) | |
| 247 | + added += 1 | |
| 248 | + elif row["content_hash"] != h: | |
| 249 | + # COALESCE : ne jamais écraser des coordonnées géocodées par null. | |
| 250 | + # Si l'adresse change, geocode_failed est remis à 0 (correction du | |
| 251 | + # bogue relevé chez Lou·Ka) pour permettre un re-géocodage. | |
| 252 | + con.execute( | |
| 253 | + """UPDATE jobs SET url=:url, employer=:employer, title=:title, | |
| 254 | + description=:description, | |
| 255 | + geocode_failed=CASE WHEN address<>:address OR city<>:city | |
| 256 | + THEN 0 ELSE geocode_failed END, | |
| 257 | + address=:address, city=:city, region=:region, | |
| 258 | + postal_code=:postal_code, location_label=:location_label, | |
| 259 | + work_mode=:work_mode, employment_type=:employment_type, | |
| 260 | + salary_min=:salary_min, salary_max=:salary_max, | |
| 261 | + salary_unit=:salary_unit, salary_label=:salary_label, | |
| 262 | + salary_year_min=:salary_year_min, | |
| 263 | + salary_year_max=:salary_year_max, | |
| 264 | + salary_hour_min=:salary_hour_min, | |
| 265 | + salary_hour_max=:salary_hour_max, | |
| 266 | + benefits=:benefits, requirements=:requirements, | |
| 267 | + date_posted=:date_posted, date_deadline=:date_deadline, | |
| 268 | + category=:category, ats=:ats, details=:details, | |
| 269 | + lat=COALESCE(:lat, lat), lng=COALESCE(:lng, lng), | |
| 270 | + content_hash=:content_hash, last_seen=:now, | |
| 271 | + updated_at=:now, miss_count=0, active=1 | |
| 272 | + WHERE uid=:uid""", params) | |
| 273 | + if (job.salary_min, job.salary_max) != (row["salary_min"], row["salary_max"]): | |
| 274 | + con.execute( | |
| 275 | + "INSERT INTO salary_log (uid, ts, s_min, s_max, unit)" | |
| 276 | + " VALUES (?,?,?,?,?)", | |
| 277 | + (job.uid, now, job.salary_min, job.salary_max, job.salary_unit)) | |
| 278 | + updated += 1 | |
| 279 | + else: | |
| 280 | + con.execute( | |
| 281 | + "UPDATE jobs SET last_seen=?, miss_count=0, active=1 WHERE uid=?", | |
| 282 | + (now, job.uid)) | |
| 283 | + | |
| 284 | + # Offres de cette source qui n'apparaissent plus : délai de grâce, | |
| 285 | + # puis désactivation. Suspendu si une dérive est détectée. | |
| 286 | + removed = missed = 0 | |
| 287 | + if not alert: | |
| 288 | + for r in con.execute( | |
| 289 | + "SELECT uid, miss_count FROM jobs WHERE source=? AND active=1", | |
| 290 | + (source,)).fetchall(): | |
| 291 | + if r["uid"] in seen_uids: | |
| 292 | + continue | |
| 293 | + missed += 1 | |
| 294 | + if r["miss_count"] + 1 >= MISS_GRACE: | |
| 295 | + con.execute( | |
| 296 | + "UPDATE jobs SET active=0, miss_count=?, updated_at=?" | |
| 297 | + " WHERE uid=?", (r["miss_count"] + 1, now, r["uid"])) | |
| 298 | + removed += 1 | |
| 299 | + else: | |
| 300 | + con.execute("UPDATE jobs SET miss_count=miss_count+1 WHERE uid=?", | |
| 301 | + (r["uid"],)) | |
| 302 | + | |
| 303 | + stats = { | |
| 304 | + "null_salary_rate": null_salary_rate, | |
| 305 | + "null_city_rate": round(null_city / n, 3) if n else 0.0, | |
| 306 | + "missed": missed, | |
| 307 | + } | |
| 308 | + if alert: | |
| 309 | + stats["alert"] = alert | |
| 310 | + con.execute( | |
| 311 | + "INSERT INTO sync_log (source, ts, found, added, updated, removed, ok," | |
| 312 | + " message, stats) VALUES (?,?,?,?,?,?,1,?,?)", | |
| 313 | + (source, now, n, added, updated, removed, alert or "ok", | |
| 314 | + json.dumps(stats, ensure_ascii=False))) | |
| 315 | + con.commit() | |
| 316 | + out = {"source": source, "found": n, "added": added, | |
| 317 | + "updated": updated, "removed": removed} | |
| 318 | + if alert: | |
| 319 | + out["alert"] = alert | |
| 320 | + return out | |
| 321 | + | |
| 322 | + | |
| 323 | +def expire_past_deadline(con: sqlite3.Connection) -> int: | |
| 324 | + """Désactive les offres dont la date limite est dépassée (offres zombies : | |
| 325 | + encore affichées sur la page carrière mais plus ouvertes aux candidatures).""" | |
| 326 | + today = _dt.date.today().isoformat() | |
| 327 | + cur = con.execute( | |
| 328 | + "UPDATE jobs SET active=0, updated_at=? WHERE active=1" | |
| 329 | + " AND date_deadline IS NOT NULL AND date_deadline < ?", | |
| 330 | + (time.time(), today)) | |
| 331 | + con.commit() | |
| 332 | + return cur.rowcount | |
| 333 | + | |
| 334 | + | |
| 335 | +def log_failure(con: sqlite3.Connection, source: str, message: str) -> None: | |
| 336 | + con.execute( | |
| 337 | + "INSERT INTO sync_log (source, ts, found, added, updated, removed, ok, message)" | |
| 338 | + " VALUES (?,?,0,0,0,0,0,?)", (source, time.time(), message)) | |
| 339 | + con.commit() | |
| 340 | + | |
| 341 | + | |
| 342 | +# --------------------------------------------------------------------------- | |
| 343 | +# Cache des pages détail (« détail si nouveau/modifié ») | |
| 344 | +# --------------------------------------------------------------------------- | |
| 345 | + | |
| 346 | +def get_cached_detail(con: sqlite3.Connection, source: str, | |
| 347 | + external_id: str, key: str) -> dict | None: | |
| 348 | + """Payload détail mis en cache si la clé (hash liste) n'a pas changé.""" | |
| 349 | + row = con.execute( | |
| 350 | + "SELECT key, payload FROM detail_cache WHERE source=? AND external_id=?", | |
| 351 | + (source, external_id)).fetchone() | |
| 352 | + if row and row["key"] == key and row["payload"]: | |
| 353 | + try: | |
| 354 | + return json.loads(row["payload"]) | |
| 355 | + except ValueError: | |
| 356 | + return None | |
| 357 | + return None | |
| 358 | + | |
| 359 | + | |
| 360 | +def put_cached_detail(con: sqlite3.Connection, source: str, | |
| 361 | + external_id: str, key: str, payload: dict) -> None: | |
| 362 | + con.execute( | |
| 363 | + "INSERT INTO detail_cache (source, external_id, key, payload, fetched_at)" | |
| 364 | + " VALUES (?,?,?,?,?)" | |
| 365 | + " ON CONFLICT(source, external_id) DO UPDATE SET" | |
| 366 | + " key=excluded.key, payload=excluded.payload, fetched_at=excluded.fetched_at", | |
| 367 | + (source, external_id, key, json.dumps(payload, ensure_ascii=False), | |
| 368 | + time.time())) | |
| 369 | + con.commit() | |
added
jobka/dedup.py
+202 −0
@@ -0,0 +1,202 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : jobka/dedup.py | |
| 6 | +# Rôle : Déduplication inter-sources — blocage par (titre, employeur, ville) | |
| 7 | +# + union-find, offres canoniques par autorité de la source | |
| 8 | +# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | +# ============================================================================= | |
| 10 | +"""Déduplication des offres publiées par plusieurs sources. | |
| 11 | + | |
| 12 | +Même squelette que Lou·Ka (clé exacte + union-find, AUCUN fuzzy matching) : | |
| 13 | +1. blocage par clé « titre normalisé | employeur normalisé | ville » ; | |
| 14 | +2. confirmation de paire : clé identique obligatoire, refus sur signal | |
| 15 | + contradictoire (type d'emploi différent, salaires connus s'écartant de | |
| 16 | + plus de SALARY_TOL), et exigence d'un second signal concordant ; | |
| 17 | +3. union-find avec contrainte : jamais deux offres de la même source dans | |
| 18 | + une composante (et jamais de dédup intra-source) ; | |
| 19 | +4. canonique = source la plus autoritaire (page carrière de l'employeur | |
| 20 | + avant agrégat), puis richesse du contenu. Les doublons sont MASQUÉS | |
| 21 | + (dup_of), jamais supprimés. Recalcul intégral idempotent à chaque passe. | |
| 22 | +""" | |
| 23 | +from __future__ import annotations | |
| 24 | + | |
| 25 | +import json | |
| 26 | +import re | |
| 27 | +import sqlite3 | |
| 28 | + | |
| 29 | +from . import db | |
| 30 | +from .normalize import strip_accents | |
| 31 | + | |
| 32 | +SALARY_TOL = 0.04 # ±4 % d'écart toléré entre salaires « identiques » | |
| 33 | +MAX_BLOCK = 200 # bloc anormalement gros = clé trop générique, ignorer | |
| 34 | + | |
| 35 | +# Sources agrégatrices (guichets, portails) — moins autoritaires que la page | |
| 36 | +# carrière de l'employeur. À enrichir quand des portails s'ajouteront. | |
| 37 | +AGGREGATORS: set[str] = set() | |
| 38 | + | |
| 39 | +# Mots de bruit RH retirés du titre pour le blocage (bilinguisme, urgence…) | |
| 40 | +_TITLE_NOISE = re.compile( | |
| 41 | + r"\b(h/f|f/h|m/f|f/m|h-f|\(h/f\)|bilingue|bilingual|urgent|nouveau|new|" | |
| 42 | + r"junior|senior|sr|jr|intermediaire|intermediate|principal[e]?|lead|" | |
| 43 | + r"temps plein|temps partiel|full[- ]?time|part[- ]?time|permanent[e]?|" | |
| 44 | + r"contractuel(?:le)?|stage|stagiaire|remote|teletravail|hybride|hybrid)\b", | |
| 45 | + re.I) | |
| 46 | + | |
| 47 | + | |
| 48 | +def _norm(s: str) -> str: | |
| 49 | + s = strip_accents((s or "").lower()) | |
| 50 | + s = re.sub(r"[^a-z0-9]+", " ", s) | |
| 51 | + return re.sub(r"\s+", " ", s).strip() | |
| 52 | + | |
| 53 | + | |
| 54 | +def _block_key(title: str, employer: str, city: str) -> str: | |
| 55 | + t = _TITLE_NOISE.sub(" ", strip_accents((title or "").lower())) | |
| 56 | + t = re.sub(r"[^a-z0-9]+", " ", t) | |
| 57 | + t = re.sub(r"\s+", " ", t).strip() | |
| 58 | + e = _norm(employer) | |
| 59 | + c = _norm(city) | |
| 60 | + if not t or not e: | |
| 61 | + return "" # sans titre ou employeur, pas de blocage fiable | |
| 62 | + return f"{t}|{e}|{c}" | |
| 63 | + | |
| 64 | + | |
| 65 | +def _salary_close(a_min, a_max, b_min, b_max) -> bool | None: | |
| 66 | + """True/False si comparables, None si l'un des deux est inconnu.""" | |
| 67 | + if a_min is None or b_min is None: | |
| 68 | + return None | |
| 69 | + a, b = float(a_min), float(b_min) | |
| 70 | + if max(a, b) == 0: | |
| 71 | + return None | |
| 72 | + if abs(a - b) / max(a, b) > SALARY_TOL: | |
| 73 | + return False | |
| 74 | + if a_max is not None and b_max is not None: | |
| 75 | + a2, b2 = float(a_max), float(b_max) | |
| 76 | + if max(a2, b2) and abs(a2 - b2) / max(a2, b2) > SALARY_TOL: | |
| 77 | + return False | |
| 78 | + return True | |
| 79 | + | |
| 80 | + | |
| 81 | +def _same_posting(a: sqlite3.Row, b: sqlite3.Row) -> bool: | |
| 82 | + """Deux offres de la même clé de blocage sont-elles le même poste ? | |
| 83 | + | |
| 84 | + Clé identique obligatoire (déjà garanti par le blocage). Ensuite : | |
| 85 | + refus sur signal contradictoire, puis exigence d'un second signal | |
| 86 | + concordant — la clé seule ne suffit pas (deux affichages distincts du | |
| 87 | + même titre chez le même employeur restent deux postes). | |
| 88 | + """ | |
| 89 | + # signaux contradictoires -> refus | |
| 90 | + ta, tb = a["employment_type"], b["employment_type"] | |
| 91 | + if ta and tb and ta != tb: | |
| 92 | + return False | |
| 93 | + sal = _salary_close(a["salary_year_min"], a["salary_year_max"], | |
| 94 | + b["salary_year_min"], b["salary_year_max"]) | |
| 95 | + if sal is False: | |
| 96 | + return False | |
| 97 | + | |
| 98 | + # second signal concordant requis | |
| 99 | + if sal is True: | |
| 100 | + return True | |
| 101 | + if ta and tb and ta == tb: | |
| 102 | + return True | |
| 103 | + da, db_ = a["date_posted"], b["date_posted"] | |
| 104 | + if da and db_: | |
| 105 | + try: | |
| 106 | + import datetime as _dt | |
| 107 | + delta = abs((_dt.date.fromisoformat(da) - _dt.date.fromisoformat(db_)).days) | |
| 108 | + if delta <= 7: | |
| 109 | + return True | |
| 110 | + except ValueError: | |
| 111 | + pass | |
| 112 | + pa, pb = a["postal_code"], b["postal_code"] | |
| 113 | + if pa and pb and pa == pb: | |
| 114 | + return True | |
| 115 | + return False | |
| 116 | + | |
| 117 | + | |
| 118 | +def _authority(source: str) -> int: | |
| 119 | + """0 = page carrière directe (le plus autoritaire), 10 = agrégateur.""" | |
| 120 | + return 10 if source in AGGREGATORS else 0 | |
| 121 | + | |
| 122 | + | |
| 123 | +def run(con: sqlite3.Connection | None = None) -> dict: | |
| 124 | + """Recalcule dup_of / dup_sources pour toutes les offres actives.""" | |
| 125 | + own = con is None | |
| 126 | + if own: | |
| 127 | + con = db.connect() | |
| 128 | + | |
| 129 | + rows = con.execute( | |
| 130 | + """SELECT uid, source, title, employer, city, employment_type, | |
| 131 | + salary_year_min, salary_year_max, date_posted, postal_code, | |
| 132 | + LENGTH(description) desc_len | |
| 133 | + FROM jobs WHERE active=1""").fetchall() | |
| 134 | + | |
| 135 | + by_key: dict[str, list[int]] = {} | |
| 136 | + for i, r in enumerate(rows): | |
| 137 | + k = _block_key(r["title"], r["employer"], r["city"]) | |
| 138 | + if k: | |
| 139 | + by_key.setdefault(k, []).append(i) | |
| 140 | + | |
| 141 | + # union-find avec contrainte anti-transitive : une composante ne peut | |
| 142 | + # contenir deux offres de la même source | |
| 143 | + parent = list(range(len(rows))) | |
| 144 | + srcset: dict[int, set[str]] = {i: {rows[i]["source"]} for i in range(len(rows))} | |
| 145 | + | |
| 146 | + def find(i: int) -> int: | |
| 147 | + while parent[i] != i: | |
| 148 | + parent[i] = parent[parent[i]] | |
| 149 | + i = parent[i] | |
| 150 | + return i | |
| 151 | + | |
| 152 | + def union(i: int, j: int) -> bool: | |
| 153 | + ri, rj = find(i), find(j) | |
| 154 | + if ri == rj: | |
| 155 | + return True | |
| 156 | + if srcset[ri] & srcset[rj]: | |
| 157 | + return False | |
| 158 | + parent[rj] = ri | |
| 159 | + srcset[ri] |= srcset.pop(rj) | |
| 160 | + return True | |
| 161 | + | |
| 162 | + for key, members in by_key.items(): | |
| 163 | + if len(members) < 2 or len(members) > MAX_BLOCK: | |
| 164 | + continue | |
| 165 | + for x in range(len(members)): | |
| 166 | + for y in range(x + 1, len(members)): | |
| 167 | + i, j = members[x], members[y] | |
| 168 | + if rows[i]["source"] == rows[j]["source"]: | |
| 169 | + continue # jamais de dédup intra-source | |
| 170 | + if _same_posting(rows[i], rows[j]): | |
| 171 | + union(i, j) | |
| 172 | + | |
| 173 | + # résolution : canonique par (autorité, richesse, uid) | |
| 174 | + groups: dict[int, list[int]] = {} | |
| 175 | + for i in range(len(rows)): | |
| 176 | + groups.setdefault(find(i), []).append(i) | |
| 177 | + | |
| 178 | + n_groups = hidden = 0 | |
| 179 | + con.execute("UPDATE jobs SET dup_of=NULL, dup_sources=NULL") | |
| 180 | + for members in groups.values(): | |
| 181 | + if len(members) < 2: | |
| 182 | + continue | |
| 183 | + n_groups += 1 | |
| 184 | + members.sort(key=lambda i: (_authority(rows[i]["source"]), | |
| 185 | + -(rows[i]["desc_len"] or 0), rows[i]["uid"])) | |
| 186 | + canon = rows[members[0]]["uid"] | |
| 187 | + others = [rows[i]["source"] for i in members[1:]] | |
| 188 | + con.execute("UPDATE jobs SET dup_sources=? WHERE uid=?", | |
| 189 | + (json.dumps(sorted(set(others)), ensure_ascii=False), canon)) | |
| 190 | + for i in members[1:]: | |
| 191 | + con.execute("UPDATE jobs SET dup_of=? WHERE uid=?", | |
| 192 | + (canon, rows[i]["uid"])) | |
| 193 | + hidden += 1 | |
| 194 | + con.commit() | |
| 195 | + if own: | |
| 196 | + con.close() | |
| 197 | + return {"groups": n_groups, "duplicates_hidden": hidden, | |
| 198 | + "jobs_scanned": len(rows)} | |
| 199 | + | |
| 200 | + | |
| 201 | +if __name__ == "__main__": | |
| 202 | + print(run()) | |
added
jobka/fixtures.py
+194 −0
@@ -0,0 +1,194 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : jobka/fixtures.py | |
| 6 | +# Rôle : Enregistrement/rejeu des réponses HTTP des sources pour les tests | |
| 7 | +# hors ligne (`python run.py record <source>` puis pytest) | |
| 8 | +# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | +# ============================================================================= | |
| 10 | +"""Enregistrement/rejeu des réponses HTTP des sources. | |
| 11 | + | |
| 12 | +`python run.py record <source>` capture toutes les réponses (HTML/JSON) dans | |
| 13 | +tests/fixtures/<source>/ + un instantané `expected.json` du parsing. Les tests | |
| 14 | +(tests/test_connectors.py) rejouent ces fixtures hors-ligne : si un connecteur | |
| 15 | +régresse, le test casse — sans toucher au site réel. | |
| 16 | +""" | |
| 17 | +from __future__ import annotations | |
| 18 | + | |
| 19 | +import hashlib | |
| 20 | +import json | |
| 21 | +import re | |
| 22 | +from pathlib import Path | |
| 23 | + | |
| 24 | +import requests | |
| 25 | +from requests.adapters import BaseAdapter, HTTPAdapter | |
| 26 | + | |
| 27 | +from .schema import JobPosting | |
| 28 | + | |
| 29 | +ROOT = Path(__file__).resolve().parent.parent | |
| 30 | +FIXTURES_DIR = ROOT / "tests" / "fixtures" | |
| 31 | + | |
| 32 | + | |
| 33 | +class MissingFixture(Exception): | |
| 34 | + """Le connecteur a demandé une URL absente des fixtures enregistrées.""" | |
| 35 | + | |
| 36 | + | |
| 37 | +# Secrets tiers présents dans le HTML public des sites sources (clés Google | |
| 38 | +# Maps, jetons) : caviardés à l'enregistrement. | |
| 39 | +_SECRET_RES = [ | |
| 40 | + (re.compile(rb"AIza[0-9A-Za-z_-]{35}"), b"AIza_CLE_CAVIARDEE_JOBKA_000000000000000"), | |
| 41 | + (re.compile(rb"(sk|pk)\.eyJ[A-Za-z0-9_.=-]+"), rb"\1.JETON_CAVIARDE_JOBKA"), | |
| 42 | +] | |
| 43 | + | |
| 44 | + | |
| 45 | +def _redact(content: bytes) -> bytes: | |
| 46 | + for rx, repl in _SECRET_RES: | |
| 47 | + content = rx.sub(repl, content) | |
| 48 | + return content | |
| 49 | + | |
| 50 | + | |
| 51 | +def _req_key(method: str, url: str, body: bytes | str | None) -> str: | |
| 52 | + if isinstance(body, str): | |
| 53 | + body = body.encode("utf-8") | |
| 54 | + h = hashlib.sha1() | |
| 55 | + h.update(f"{method.upper()} {url} ".encode()) | |
| 56 | + if body: | |
| 57 | + h.update(body) | |
| 58 | + return h.hexdigest()[:20] | |
| 59 | + | |
| 60 | + | |
| 61 | +def _ext_for(content_type: str) -> str: | |
| 62 | + if "json" in content_type: | |
| 63 | + return ".json" | |
| 64 | + if "html" in content_type: | |
| 65 | + return ".html" | |
| 66 | + return ".txt" | |
| 67 | + | |
| 68 | + | |
| 69 | +# --------------------------------------------------------------------------- | |
| 70 | +# Enregistrement | |
| 71 | +# --------------------------------------------------------------------------- | |
| 72 | + | |
| 73 | +class _RecordAdapter(HTTPAdapter): | |
| 74 | + def __init__(self, store: dict, out_dir: Path): | |
| 75 | + super().__init__() | |
| 76 | + self.store = store | |
| 77 | + self.out_dir = out_dir | |
| 78 | + | |
| 79 | + def send(self, request, **kwargs): # type: ignore[override] | |
| 80 | + resp = super().send(request, **kwargs) | |
| 81 | + key = _req_key(request.method or "GET", request.url or "", request.body) | |
| 82 | + ctype = resp.headers.get("Content-Type", "") | |
| 83 | + fname = key + _ext_for(ctype) | |
| 84 | + (self.out_dir / fname).write_bytes(_redact(resp.content)) | |
| 85 | + self.store[key] = { | |
| 86 | + "method": request.method, "url": request.url, | |
| 87 | + "status": resp.status_code, "content_type": ctype, "file": fname, | |
| 88 | + } | |
| 89 | + if resp.headers.get("Location"): | |
| 90 | + self.store[key]["location"] = resp.headers["Location"] | |
| 91 | + return resp | |
| 92 | + | |
| 93 | + | |
| 94 | +def record(source_id: str) -> dict: | |
| 95 | + """Exécute le connecteur en enregistrant chaque réponse HTTP en fixture. | |
| 96 | + | |
| 97 | + Écrit tests/fixtures/<source>/{index.json, expected.json, *.html|json}. | |
| 98 | + Retourne un résumé {source, requests, jobs}. | |
| 99 | + """ | |
| 100 | + from .connectors import CONNECTORS | |
| 101 | + cls = CONNECTORS[source_id] | |
| 102 | + out_dir = FIXTURES_DIR / source_id | |
| 103 | + out_dir.mkdir(parents=True, exist_ok=True) | |
| 104 | + for old in out_dir.iterdir(): | |
| 105 | + old.unlink() | |
| 106 | + | |
| 107 | + inst = cls() | |
| 108 | + inst.use_detail_cache = False # capturer aussi les pages détail | |
| 109 | + store: dict = {} | |
| 110 | + adapter = _RecordAdapter(store, out_dir) | |
| 111 | + inst.session.mount("https://", adapter) | |
| 112 | + inst.session.mount("http://", adapter) | |
| 113 | + | |
| 114 | + postings = inst.fetch() | |
| 115 | + (out_dir / "index.json").write_text( | |
| 116 | + json.dumps(store, ensure_ascii=False, indent=1), encoding="utf-8") | |
| 117 | + (out_dir / "expected.json").write_text( | |
| 118 | + json.dumps(snapshot(postings), ensure_ascii=False, indent=1), | |
| 119 | + encoding="utf-8") | |
| 120 | + return {"source": source_id, "requests": len(store), "jobs": len(postings)} | |
| 121 | + | |
| 122 | + | |
| 123 | +def snapshot(postings: list[JobPosting]) -> dict: | |
| 124 | + """Instantané compact et déterministe du parsing (champs bruts, pré-finalize). | |
| 125 | + | |
| 126 | + Les dates relatives normalisées dépendent du jour d'exécution : on ne | |
| 127 | + snapshotte que les champs stables pour éviter les tests fragiles. | |
| 128 | + """ | |
| 129 | + rows = [] | |
| 130 | + for p in sorted(postings, key=lambda x: x.uid): | |
| 131 | + rows.append({ | |
| 132 | + "uid": p.uid, "url": p.url, "title": p.title, | |
| 133 | + "employer": p.employer, "city": p.city, | |
| 134 | + "location_label": p.location_label, | |
| 135 | + "salary_min": p.salary_min, "salary_max": p.salary_max, | |
| 136 | + "salary_unit": p.salary_unit, | |
| 137 | + "employment_type": p.employment_type, "ats": p.ats, | |
| 138 | + "desc_len": len(p.description), | |
| 139 | + }) | |
| 140 | + return {"count": len(rows), "jobs": rows} | |
| 141 | + | |
| 142 | + | |
| 143 | +# --------------------------------------------------------------------------- | |
| 144 | +# Rejeu | |
| 145 | +# --------------------------------------------------------------------------- | |
| 146 | + | |
| 147 | +class _ReplayAdapter(BaseAdapter): | |
| 148 | + def __init__(self, index: dict, fixtures_dir: Path): | |
| 149 | + super().__init__() | |
| 150 | + self.index = index | |
| 151 | + self.dir = fixtures_dir | |
| 152 | + | |
| 153 | + def send(self, request, **kwargs): # type: ignore[override] | |
| 154 | + key = _req_key(request.method or "GET", request.url or "", request.body) | |
| 155 | + meta = self.index.get(key) | |
| 156 | + if meta is None: | |
| 157 | + raise MissingFixture(f"{request.method} {request.url}") | |
| 158 | + resp = requests.models.Response() | |
| 159 | + resp.status_code = meta["status"] | |
| 160 | + resp._content = (self.dir / meta["file"]).read_bytes() | |
| 161 | + resp.headers["Content-Type"] = meta.get("content_type", "") | |
| 162 | + if meta.get("location"): | |
| 163 | + resp.headers["Location"] = meta["location"] | |
| 164 | + resp.url = request.url or "" | |
| 165 | + resp.request = request | |
| 166 | + resp.encoding = "utf-8" | |
| 167 | + return resp | |
| 168 | + | |
| 169 | + def close(self): | |
| 170 | + pass | |
| 171 | + | |
| 172 | + | |
| 173 | +def replay_connector(source_id: str): | |
| 174 | + """Instancie le connecteur branché sur ses fixtures (aucun réseau).""" | |
| 175 | + from .connectors import CONNECTORS | |
| 176 | + cls = CONNECTORS[source_id] | |
| 177 | + fdir = FIXTURES_DIR / source_id | |
| 178 | + index = json.loads((fdir / "index.json").read_text(encoding="utf-8")) | |
| 179 | + | |
| 180 | + inst = cls() | |
| 181 | + inst.request_delay = 0.0 # pas de politesse hors-ligne | |
| 182 | + inst.use_detail_cache = False # tests indépendants de la BD | |
| 183 | + adapter = _ReplayAdapter(index, fdir) | |
| 184 | + inst.session.mount("https://", adapter) | |
| 185 | + inst.session.mount("http://", adapter) | |
| 186 | + return inst | |
| 187 | + | |
| 188 | + | |
| 189 | +def recorded_sources() -> list[str]: | |
| 190 | + """Sources ayant des fixtures enregistrées (pour paramétrer les tests).""" | |
| 191 | + if not FIXTURES_DIR.exists(): | |
| 192 | + return [] | |
| 193 | + return sorted(p.name for p in FIXTURES_DIR.iterdir() | |
| 194 | + if (p / "index.json").exists()) | |
added
jobka/geocode.py
+344 −0
@@ -0,0 +1,344 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : jobka/geocode.py | |
| 6 | +# Rôle : Géocodage des lieux de travail — Nominatim + repli Adresses Québec, | |
| 7 | +# cache persistant, centroïde de ville quand l'adresse est absente | |
| 8 | +# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | +# ============================================================================= | |
| 10 | +"""Géocodage des offres (pipeline hérité de Lou·Ka). | |
| 11 | + | |
| 12 | +- cache persistant (table geocode_cache) : un lieu n'est géocodé qu'une seule | |
| 13 | + fois, par adresse normalisée — ou par VILLE quand l'offre n'a pas d'adresse | |
| 14 | + (cas majoritaire pour l'emploi : le centroïde de ville suffit à la carte). | |
| 15 | +- politesse : 1 requête/seconde max (règle Nominatim), User-Agent identifiable. | |
| 16 | +- validation : bounding box provinciale — jamais de coordonnées inventées. | |
| 17 | +""" | |
| 18 | +from __future__ import annotations | |
| 19 | + | |
| 20 | +import re | |
| 21 | +import time | |
| 22 | + | |
| 23 | +import requests | |
| 24 | + | |
| 25 | +from . import db | |
| 26 | +from .normalize import strip_accents | |
| 27 | + | |
| 28 | +NOMINATIM_URL = "https://nominatim.openstreetmap.org/search" | |
| 29 | +# Repli officiel du gouvernement du Québec (Adresses Québec / MERN) : couvre | |
| 30 | +# les adresses trop récentes pour OpenStreetMap. | |
| 31 | +AQ_URL = ("https://servicescarto.mern.gouv.qc.ca/pes/rest/services/Territoire/" | |
| 32 | + "Adresse_Geocodage/GeocodeServer/findAddressCandidates") | |
| 33 | +AQ_BATCH_URL = ("https://servicescarto.mern.gouv.qc.ca/pes/rest/services/Territoire/" | |
| 34 | + "Adresse_Geocodage/GeocodeServer/geocodeAddresses") | |
| 35 | +AQ_MIN_SCORE = 75 | |
| 36 | +BATCH_SIZE = 200 | |
| 37 | +# ASCII pur : le serveur ArcGIS d'Adresses Québec retourne 500 si l'en-tête | |
| 38 | +# User-Agent contient des accents (latin-1) | |
| 39 | +USER_AGENT = "JobKaBot/1.0 (agregateur emplois Quebec; +contact@spboucher.ai)" | |
| 40 | +REQUEST_DELAY = 1.1 # règle Nominatim : max 1 req/s | |
| 41 | +RETRY_FAILED_AFTER = 30 * 86400 # re-tenter les échecs après 30 jours | |
| 42 | + | |
| 43 | +# Garde-fou provincial (lat_min, lat_max, lng_min, lng_max) — même boîte que | |
| 44 | +# schema.finalize() | |
| 45 | +_BBOX_QC = (44.50, 63.00, -80.00, -56.00) | |
| 46 | + | |
| 47 | + | |
| 48 | +def _in_bbox(lat: float, lng: float) -> bool: | |
| 49 | + return _BBOX_QC[0] <= lat <= _BBOX_QC[1] and _BBOX_QC[2] <= lng <= _BBOX_QC[3] | |
| 50 | + | |
| 51 | + | |
| 52 | +def norm_key(address: str, city: str = "") -> str: | |
| 53 | + """Clé de cache : adresse normalisée, ou « ville <ville> » sans adresse.""" | |
| 54 | + s = strip_accents((address or "").lower()) | |
| 55 | + s = re.sub(r"\b(?:bureau|suite|local|etage|app?t?|#)\.?\s*[\w-]+\b", " ", s) | |
| 56 | + s = re.sub(r"[^a-z0-9]+", " ", s) | |
| 57 | + s = re.sub(r"\s+", " ", s).strip() | |
| 58 | + if s and len(s) >= 6: | |
| 59 | + return s | |
| 60 | + c = strip_accents((city or "").lower()) | |
| 61 | + c = re.sub(r"[^a-z0-9]+", " ", c).strip() | |
| 62 | + return f"ville {c}" if c else "" | |
| 63 | + | |
| 64 | + | |
| 65 | +class Geocoder: | |
| 66 | + def __init__(self, con) -> None: | |
| 67 | + self.con = con | |
| 68 | + self.session = requests.Session() | |
| 69 | + self.session.headers["User-Agent"] = USER_AGENT | |
| 70 | + self._last = 0.0 | |
| 71 | + | |
| 72 | + @staticmethod | |
| 73 | + def _clean(address: str) -> str: | |
| 74 | + """Nettoyages qui aident Nominatim sur les adresses québécoises.""" | |
| 75 | + s = (address or "").strip() | |
| 76 | + s = re.sub(r"^(\d+)-(\d+)\s", r"\2 ", s) # unité-civique | |
| 77 | + s = re.sub(r",?\s*(?:bureau|suite|local|app\.?|apt\.?|etage|étage)\s*[\w-]+\b", | |
| 78 | + "", s, flags=re.I) | |
| 79 | + s = re.sub(r"\s*\((qu[ée]bec)\)", r", \1", s, flags=re.I) | |
| 80 | + return s | |
| 81 | + | |
| 82 | + def _query_nominatim(self, params: dict) -> tuple[float, float] | None: | |
| 83 | + """Une requête Nominatim, throttlée.""" | |
| 84 | + wait = REQUEST_DELAY - (time.time() - self._last) | |
| 85 | + if wait > 0: | |
| 86 | + time.sleep(wait) | |
| 87 | + try: | |
| 88 | + resp = self.session.get(NOMINATIM_URL, params={ | |
| 89 | + "format": "jsonv2", "limit": 1, "countrycodes": "ca", **params, | |
| 90 | + }, timeout=20) | |
| 91 | + self._last = time.time() | |
| 92 | + resp.raise_for_status() | |
| 93 | + hits = resp.json() | |
| 94 | + except Exception: | |
| 95 | + self._last = time.time() | |
| 96 | + return None | |
| 97 | + if not hits: | |
| 98 | + return None | |
| 99 | + try: | |
| 100 | + return float(hits[0]["lat"]), float(hits[0]["lon"]) | |
| 101 | + except (KeyError, ValueError): | |
| 102 | + return None | |
| 103 | + | |
| 104 | + def _query_adresses_quebec(self, address: str, city: str) -> tuple[float, float] | None: | |
| 105 | + """Repli : géocodeur officiel Adresses Québec (MERN, ArcGIS).""" | |
| 106 | + premier = self._clean(address).split(",")[0].strip() | |
| 107 | + ville = (city or "Québec").strip() | |
| 108 | + wait = REQUEST_DELAY - (time.time() - self._last) | |
| 109 | + if wait > 0: | |
| 110 | + time.sleep(wait) | |
| 111 | + try: | |
| 112 | + resp = self.session.get(AQ_URL, params={ | |
| 113 | + "SingleLine": f"{premier}, {ville}", | |
| 114 | + "f": "json", "outSR": 4326, "maxLocations": 1, | |
| 115 | + }, timeout=20) | |
| 116 | + self._last = time.time() | |
| 117 | + resp.raise_for_status() | |
| 118 | + cands = resp.json().get("candidates") or [] | |
| 119 | + except Exception: | |
| 120 | + self._last = time.time() | |
| 121 | + return None | |
| 122 | + if not cands or cands[0].get("score", 0) < AQ_MIN_SCORE: | |
| 123 | + return None | |
| 124 | + loc = cands[0].get("location") or {} | |
| 125 | + try: | |
| 126 | + return float(loc["y"]), float(loc["x"]) | |
| 127 | + except (KeyError, ValueError): | |
| 128 | + return None | |
| 129 | + | |
| 130 | + def _attempts(self, address: str, city: str) -> list[dict]: | |
| 131 | + """Stratégies de requête, de la plus précise à la moins précise.""" | |
| 132 | + ville = (city or "Québec").strip() | |
| 133 | + commun = {"city": ville, "state": "Québec", "country": "Canada"} | |
| 134 | + tries: list[dict] = [] | |
| 135 | + premier = self._clean(address).split(",")[0].strip() if address else "" | |
| 136 | + if premier: | |
| 137 | + m = re.match(r"^(\d+)[,\s]+(.{4,})$", premier) | |
| 138 | + if m: | |
| 139 | + tries.append({"street": f"{m.group(1)} {m.group(2)}", **commun}) | |
| 140 | + tries.append({"q": f"{premier}, {ville}, Québec, Canada"}) | |
| 141 | + if m: | |
| 142 | + tries.append({"street": m.group(2), **commun}) | |
| 143 | + else: | |
| 144 | + # sans adresse : centroïde de ville (suffisant pour la carte) | |
| 145 | + tries.append(commun) | |
| 146 | + return tries | |
| 147 | + | |
| 148 | + def resolve(self, address: str, city: str) -> tuple[float, float] | None: | |
| 149 | + """Lieu -> (lat, lng), via cache puis Nominatim/AQ. None si introuvable.""" | |
| 150 | + key = norm_key(address, city) | |
| 151 | + if not key: | |
| 152 | + return None | |
| 153 | + row = self.con.execute( | |
| 154 | + "SELECT lat, lng, failed, ts FROM geocode_cache WHERE address=?", | |
| 155 | + (key,)).fetchone() | |
| 156 | + if row is not None: | |
| 157 | + if not row["failed"]: | |
| 158 | + return (row["lat"], row["lng"]) | |
| 159 | + if time.time() - (row["ts"] or 0) < RETRY_FAILED_AFTER: | |
| 160 | + return None # échec récent : ne pas marteler l'API | |
| 161 | + | |
| 162 | + coords = None | |
| 163 | + provider = "nominatim" | |
| 164 | + for params in self._attempts(address, city): | |
| 165 | + c = self._query_nominatim(params) | |
| 166 | + if c is not None and _in_bbox(*c): | |
| 167 | + coords = c | |
| 168 | + break | |
| 169 | + if coords is None and address: | |
| 170 | + c = self._query_adresses_quebec(address, city) | |
| 171 | + if c is not None and _in_bbox(*c): | |
| 172 | + coords = c | |
| 173 | + provider = "adresses_quebec" | |
| 174 | + ok = coords is not None | |
| 175 | + self.con.execute( | |
| 176 | + "INSERT INTO geocode_cache (address, lat, lng, provider, failed, ts)" | |
| 177 | + " VALUES (?,?,?,?,?,?)" | |
| 178 | + " ON CONFLICT(address) DO UPDATE SET lat=excluded.lat," | |
| 179 | + " lng=excluded.lng, provider=excluded.provider," | |
| 180 | + " failed=excluded.failed, ts=excluded.ts", | |
| 181 | + (key, coords[0] if ok else None, coords[1] if ok else None, | |
| 182 | + provider, 0 if ok else 1, time.time())) | |
| 183 | + self.con.commit() | |
| 184 | + return coords if ok else None | |
| 185 | + | |
| 186 | + | |
| 187 | +def run(limit: int | None = None) -> dict: | |
| 188 | + """Géocode les offres actives sans coordonnées, par lieu unique. | |
| 189 | + | |
| 190 | + `limit` borne le nombre de NOUVELLES requêtes réseau (les hits de cache | |
| 191 | + sont gratuits et toujours appliqués). | |
| 192 | + """ | |
| 193 | + con = db.connect() | |
| 194 | + geo = Geocoder(con) | |
| 195 | + rows = con.execute( | |
| 196 | + """SELECT uid, address, city FROM jobs | |
| 197 | + WHERE active=1 AND lat IS NULL AND geocode_failed=0 | |
| 198 | + AND (address<>'' OR city<>'') ORDER BY city, address""").fetchall() | |
| 199 | + | |
| 200 | + # regrouper par lieu (adresse normalisée, ou ville) | |
| 201 | + groupes: dict[str, list] = {} | |
| 202 | + for r in rows: | |
| 203 | + groupes.setdefault(norm_key(r["address"], r["city"]), []).append(r) | |
| 204 | + | |
| 205 | + done = failed = requests_made = 0 | |
| 206 | + for key, members in groupes.items(): | |
| 207 | + if not key: | |
| 208 | + continue | |
| 209 | + cached = con.execute( | |
| 210 | + "SELECT failed FROM geocode_cache WHERE address=?", (key,)).fetchone() | |
| 211 | + if cached is None: | |
| 212 | + if limit is not None and requests_made >= limit: | |
| 213 | + continue | |
| 214 | + requests_made += 1 | |
| 215 | + coords = geo.resolve(members[0]["address"], members[0]["city"]) | |
| 216 | + if coords: | |
| 217 | + for r in members: | |
| 218 | + con.execute("UPDATE jobs SET lat=?, lng=? WHERE uid=?", | |
| 219 | + (coords[0], coords[1], r["uid"])) | |
| 220 | + done += len(members) | |
| 221 | + else: | |
| 222 | + # introuvable/hors zone : flag pour révision, jamais de coordonnées bidon | |
| 223 | + for r in members: | |
| 224 | + con.execute("UPDATE jobs SET geocode_failed=1 WHERE uid=?", | |
| 225 | + (r["uid"],)) | |
| 226 | + failed += len(members) | |
| 227 | + con.commit() | |
| 228 | + | |
| 229 | + con.close() | |
| 230 | + stats = {"geocoded": done, "failed": failed, | |
| 231 | + "unique_places": len(groupes), "api_requests": requests_made} | |
| 232 | + print(f"[job-ka] geocode {stats}") | |
| 233 | + return stats | |
| 234 | + | |
| 235 | + | |
| 236 | +def run_batch(limit: int | None = None) -> dict: | |
| 237 | + """Géocodage EN LOT : les offres AVEC adresse passent par Adresses Québec | |
| 238 | + (geocodeAddresses, lots de 200) ; celles avec seulement une ville passent | |
| 239 | + par le mode 1-par-1 (centroïdes de ville — peu nombreuses et vite en cache).""" | |
| 240 | + import json as _json | |
| 241 | + | |
| 242 | + con = db.connect() | |
| 243 | + geo = Geocoder(con) | |
| 244 | + rows = con.execute( | |
| 245 | + """SELECT uid, address, city FROM jobs | |
| 246 | + WHERE active=1 AND lat IS NULL AND geocode_failed=0 | |
| 247 | + AND address IS NOT NULL AND address<>'' ORDER BY address""").fetchall() | |
| 248 | + uniq: dict[str, dict] = {} | |
| 249 | + for r in rows: | |
| 250 | + k = norm_key(r["address"], r["city"]) | |
| 251 | + if not k or k.startswith("ville "): | |
| 252 | + continue | |
| 253 | + u = uniq.setdefault(k, {"address": r["address"], "city": r["city"], | |
| 254 | + "members": []}) | |
| 255 | + u["members"].append(r["uid"]) | |
| 256 | + pending = [] | |
| 257 | + for k, u in uniq.items(): | |
| 258 | + c = con.execute("SELECT lat,lng,failed FROM geocode_cache WHERE address=?", | |
| 259 | + (k,)).fetchone() | |
| 260 | + if c is not None and not c["failed"]: | |
| 261 | + for uid in u["members"]: | |
| 262 | + con.execute("UPDATE jobs SET lat=?, lng=? WHERE uid=?", | |
| 263 | + (c["lat"], c["lng"], uid)) | |
| 264 | + continue | |
| 265 | + if c is not None and c["failed"]: | |
| 266 | + continue | |
| 267 | + pending.append((k, u)) | |
| 268 | + con.commit() | |
| 269 | + if limit is not None: | |
| 270 | + pending = pending[:limit] | |
| 271 | + | |
| 272 | + session = requests.Session() | |
| 273 | + session.headers["User-Agent"] = USER_AGENT | |
| 274 | + done = failed = 0 | |
| 275 | + for i in range(0, len(pending), BATCH_SIZE): | |
| 276 | + chunk = pending[i:i + BATCH_SIZE] | |
| 277 | + records = {"records": [ | |
| 278 | + {"attributes": {"OBJECTID": j, | |
| 279 | + "SingleLine": f"{geo._clean(u['address']).split(',')[0].strip()}, " | |
| 280 | + f"{(u['city'] or 'Québec').strip()}"}} | |
| 281 | + for j, (_k, u) in enumerate(chunk)]} | |
| 282 | + try: | |
| 283 | + resp = session.post(AQ_BATCH_URL, data={ | |
| 284 | + "addresses": _json.dumps(records, ensure_ascii=False), | |
| 285 | + "f": "json", "outSR": 4326}, timeout=90) | |
| 286 | + locs = resp.json().get("locations", []) | |
| 287 | + except Exception as e: | |
| 288 | + print(f"[job-ka] geocode-batch lot {i//BATCH_SIZE} ERREUR: {str(e)[:80]}") | |
| 289 | + time.sleep(1.0) | |
| 290 | + continue | |
| 291 | + by_id = {l["attributes"].get("ResultID"): l for l in locs} | |
| 292 | + for j, (k, u) in enumerate(chunk): | |
| 293 | + loc = by_id.get(j) | |
| 294 | + coords = None | |
| 295 | + if loc and loc["attributes"].get("Score", 0) >= AQ_MIN_SCORE: | |
| 296 | + lc = loc.get("location") or {} | |
| 297 | + try: | |
| 298 | + cand = (float(lc["y"]), float(lc["x"])) | |
| 299 | + if _in_bbox(*cand): | |
| 300 | + coords = cand | |
| 301 | + except (KeyError, ValueError, TypeError): | |
| 302 | + coords = None | |
| 303 | + if coords: | |
| 304 | + for uid in u["members"]: | |
| 305 | + con.execute("UPDATE jobs SET lat=?, lng=? WHERE uid=?", | |
| 306 | + (coords[0], coords[1], uid)) | |
| 307 | + con.execute( | |
| 308 | + "INSERT INTO geocode_cache (address,lat,lng,provider,failed,ts)" | |
| 309 | + " VALUES (?,?,?,?,0,?) ON CONFLICT(address) DO UPDATE SET" | |
| 310 | + " lat=excluded.lat, lng=excluded.lng, provider=excluded.provider," | |
| 311 | + " failed=0, ts=excluded.ts", | |
| 312 | + (k, coords[0], coords[1], "adresses_quebec_batch", time.time())) | |
| 313 | + done += len(u["members"]) | |
| 314 | + else: | |
| 315 | + # l'adresse a échoué : retomber sur le centroïde de ville plutôt | |
| 316 | + # que d'abandonner (l'offre reste cartographiable) | |
| 317 | + cv = geo.resolve("", u["city"]) | |
| 318 | + if cv: | |
| 319 | + for uid in u["members"]: | |
| 320 | + con.execute("UPDATE jobs SET lat=?, lng=? WHERE uid=?", | |
| 321 | + (cv[0], cv[1], uid)) | |
| 322 | + done += len(u["members"]) | |
| 323 | + else: | |
| 324 | + for uid in u["members"]: | |
| 325 | + con.execute("UPDATE jobs SET geocode_failed=1 WHERE uid=?", | |
| 326 | + (uid,)) | |
| 327 | + failed += len(u["members"]) | |
| 328 | + con.execute( | |
| 329 | + "INSERT INTO geocode_cache (address,lat,lng,provider,failed,ts)" | |
| 330 | + " VALUES (?,?,?,?,1,?) ON CONFLICT(address) DO UPDATE SET" | |
| 331 | + " failed=1, ts=excluded.ts", | |
| 332 | + (k, None, None, "adresses_quebec_batch", time.time())) | |
| 333 | + con.commit() | |
| 334 | + print(f"[job-ka] geocode-batch {i+len(chunk)}/{len(pending)} " | |
| 335 | + f"(résolues {done}, échecs {failed})") | |
| 336 | + | |
| 337 | + con.close() | |
| 338 | + # offres sans adresse : centroïdes de ville en mode 1-par-1 (cache partagé) | |
| 339 | + stats_villes = run() | |
| 340 | + stats = {"geocoded": done + stats_villes["geocoded"], | |
| 341 | + "failed": failed + stats_villes["failed"], | |
| 342 | + "batches": (len(pending) + BATCH_SIZE - 1) // BATCH_SIZE} | |
| 343 | + print(f"[job-ka] geocode-batch {stats}") | |
| 344 | + return stats | |
added
jobka/ingest.py
+96 −0
@@ -0,0 +1,96 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : jobka/ingest.py | |
| 6 | +# Rôle : Pipeline d'ingestion — exécute les connecteurs et synchronise la | |
| 7 | +# base (ajouts / mises à jour / retraits) = contenu toujours à jour | |
| 8 | +# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | +# ============================================================================= | |
| 10 | +from __future__ import annotations | |
| 11 | + | |
| 12 | +import sys | |
| 13 | +import time | |
| 14 | +import traceback | |
| 15 | + | |
| 16 | +from . import db | |
| 17 | +from .connectors import CONNECTORS | |
| 18 | + | |
| 19 | + | |
| 20 | +def run(sources: list[str] | None = None) -> list[dict]: | |
| 21 | + """Exécute l'ingestion pour toutes les sources (ou celles demandées).""" | |
| 22 | + con = db.connect() | |
| 23 | + results = [] | |
| 24 | + targets = sources or list(CONNECTORS.keys()) | |
| 25 | + for sid in targets: | |
| 26 | + cls = CONNECTORS.get(sid) | |
| 27 | + if cls is None: | |
| 28 | + print(f"[job-ka] connecteur inconnu : {sid}", file=sys.stderr) | |
| 29 | + continue | |
| 30 | + t0 = time.time() | |
| 31 | + print(f"[job-ka] sync {sid} ...") | |
| 32 | + try: | |
| 33 | + postings = cls().fetch() | |
| 34 | + finalized, dropped = [], 0 | |
| 35 | + for job in postings: | |
| 36 | + try: | |
| 37 | + finalized.append(job.finalize()) | |
| 38 | + except Exception: # une offre malformée ne bloque pas la source | |
| 39 | + dropped += 1 | |
| 40 | + stats = db.sync_source(con, sid, finalized) | |
| 41 | + stats["seconds"] = round(time.time() - t0, 1) | |
| 42 | + if dropped: | |
| 43 | + stats["dropped"] = dropped | |
| 44 | + if stats.get("alert"): | |
| 45 | + print(f"[job-ka] ⚠ ALERTE {sid} : {stats['alert']}") | |
| 46 | + print(f"[job-ka] {stats}") | |
| 47 | + results.append(stats) | |
| 48 | + except Exception as exc: # robustesse : une source ne bloque pas les autres | |
| 49 | + db.log_failure(con, sid, f"{exc}") | |
| 50 | + traceback.print_exc() | |
| 51 | + results.append({"source": sid, "error": str(exc)}) | |
| 52 | + # offres dont la date limite est dépassée : retirées même si encore en ligne | |
| 53 | + try: | |
| 54 | + expired = db.expire_past_deadline(con) | |
| 55 | + if expired: | |
| 56 | + print(f"[job-ka] date limite dépassée : {expired} offre(s) retirée(s)") | |
| 57 | + except Exception: | |
| 58 | + pass | |
| 59 | + # statistiques du planificateur SQLite (requêtes bbox de la carte) | |
| 60 | + try: | |
| 61 | + con.execute("PRAGMA optimize") | |
| 62 | + except Exception: | |
| 63 | + pass | |
| 64 | + con.close() | |
| 65 | + # déduplication inter-sources : aussi après un sync manuel (chez Lou·Ka | |
| 66 | + # elle ne tournait que dans watch(), ce qui laissait dup_of périmé) | |
| 67 | + try: | |
| 68 | + from . import dedup | |
| 69 | + stats = dedup.run() | |
| 70 | + print(f"[job-ka] dedup: {stats}") | |
| 71 | + except Exception as exc: | |
| 72 | + print(f"[job-ka] dedup: erreur non bloquante: {exc}", file=sys.stderr) | |
| 73 | + return results | |
| 74 | + | |
| 75 | + | |
| 76 | +def watch(interval_seconds: int = 3600) -> None: | |
| 77 | + """Boucle de rafraîchissement périodique (pseudo-webhook par sondage).""" | |
| 78 | + while True: | |
| 79 | + run() | |
| 80 | + try: # géocoder les nouveaux lieux de travail EN LOT (Adresses Québec) | |
| 81 | + from . import geocode | |
| 82 | + geocode.run_batch() | |
| 83 | + except Exception as exc: | |
| 84 | + print(f"[job-ka] geocode: erreur non bloquante: {exc}", file=sys.stderr) | |
| 85 | + try: # re-dédupliquer APRÈS géocodage (coordonnées = signal additionnel) | |
| 86 | + from . import dedup | |
| 87 | + stats = dedup.run() | |
| 88 | + print(f"[job-ka] dedup: {stats}") | |
| 89 | + except Exception as exc: | |
| 90 | + print(f"[job-ka] dedup: erreur non bloquante: {exc}", file=sys.stderr) | |
| 91 | + print(f"[job-ka] prochaine synchronisation dans {interval_seconds}s") | |
| 92 | + time.sleep(interval_seconds) | |
| 93 | + | |
| 94 | + | |
| 95 | +if __name__ == "__main__": | |
| 96 | + run(sys.argv[1:] or None) | |
added
jobka/normalize.py
+480 −0
@@ -0,0 +1,480 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : jobka/normalize.py | |
| 6 | +# Rôle : Normalisation commune des offres (salaires, dates, mode/type | |
| 7 | +# d'emploi, lieu, catégorie) — jamais de valeur inventée | |
| 8 | +# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | +# ============================================================================= | |
| 10 | +"""Couche de normalisation commune appelée par JobPosting.finalize(). | |
| 11 | + | |
| 12 | +Philosophie héritée de Lou·Ka : parseurs déterministes (regex, tables de | |
| 13 | +mots-clés FR/EN), bornes de plausibilité, et surtout — une valeur absente | |
| 14 | +reste absente (None / chaîne vide), on ne devine jamais. | |
| 15 | +""" | |
| 16 | +from __future__ import annotations | |
| 17 | + | |
| 18 | +import datetime as _dt | |
| 19 | +import re | |
| 20 | +import unicodedata | |
| 21 | + | |
| 22 | +__all__ = [ | |
| 23 | + "strip_accents", "clean_text", "clean_html", "parse_salary", | |
| 24 | + "salary_to_yearly", "salary_to_hourly", "parse_date", "parse_work_mode", | |
| 25 | + "parse_employment_type", "parse_location", "canonical_city", | |
| 26 | + "is_quebec_location", "categorize", | |
| 27 | +] | |
| 28 | + | |
| 29 | + | |
| 30 | +# --------------------------------------------------------------------------- | |
| 31 | +# Texte | |
| 32 | +# --------------------------------------------------------------------------- | |
| 33 | + | |
| 34 | +def strip_accents(s: str) -> str: | |
| 35 | + return "".join(c for c in unicodedata.normalize("NFD", s) | |
| 36 | + if unicodedata.category(c) != "Mn") | |
| 37 | + | |
| 38 | + | |
| 39 | +def _key(s: str) -> str: | |
| 40 | + """Minuscules sans accents — pour le matching de mots-clés.""" | |
| 41 | + return strip_accents((s or "").lower()) | |
| 42 | + | |
| 43 | + | |
| 44 | +def clean_text(s: str) -> str: | |
| 45 | + """Espaces insécables -> espace, blancs réduits.""" | |
| 46 | + s = (s or "").replace("\xa0", " ").replace(" ", " ").replace(" ", " ") | |
| 47 | + return re.sub(r"[ \t]+", " ", s).strip() | |
| 48 | + | |
| 49 | + | |
| 50 | +_BLOCK_TAGS = re.compile( | |
| 51 | + r"</?(?:p|div|br|li|ul|ol|h[1-6]|tr|table|section|article)[^>]*>", re.I) | |
| 52 | + | |
| 53 | + | |
| 54 | +def clean_html(html: str) -> str: | |
| 55 | + """HTML -> texte lisible : balises de bloc = sauts de ligne, le reste retiré. | |
| 56 | + | |
| 57 | + Suffisant pour les descriptions d'offres (les connecteurs qui ont besoin | |
| 58 | + d'un vrai parseur utilisent BeautifulSoup eux-mêmes). | |
| 59 | + """ | |
| 60 | + if not html: | |
| 61 | + return "" | |
| 62 | + import html as _html | |
| 63 | + s = _html.unescape(html) | |
| 64 | + s = re.sub(r"<(script|style)[^>]*>.*?</\1>", " ", s, flags=re.I | re.S) | |
| 65 | + s = _BLOCK_TAGS.sub("\n", s) | |
| 66 | + s = re.sub(r"<[^>]+>", " ", s) | |
| 67 | + s = s.replace("\xa0", " ").replace(" ", " ") | |
| 68 | + s = re.sub(r"[ \t]+", " ", s) | |
| 69 | + s = re.sub(r" ?\n ?", "\n", s) | |
| 70 | + return re.sub(r"\n{3,}", "\n\n", s).strip() | |
| 71 | + | |
| 72 | + | |
| 73 | +# --------------------------------------------------------------------------- | |
| 74 | +# Salaires — transparence salariale = différenciateur clé de Job·Ka | |
| 75 | +# --------------------------------------------------------------------------- | |
| 76 | + | |
| 77 | +# Bornes de plausibilité par unité (CAD). Hors bornes = on jette (pas de | |
| 78 | +# salaire inventé, pas de « 1 $/h » venu d'un parsing raté). | |
| 79 | +_SALARY_BOUNDS = { | |
| 80 | + "hour": (14.0, 250.0), | |
| 81 | + "day": (100.0, 2000.0), | |
| 82 | + "week": (500.0, 10000.0), | |
| 83 | + "biweek": (1000.0, 20000.0), | |
| 84 | + "month": (1800.0, 45000.0), | |
| 85 | + "year": (20000.0, 600000.0), | |
| 86 | +} | |
| 87 | + | |
| 88 | +# Heures/an de référence pour les conversions (semaine de 40 h). | |
| 89 | +_HOURS_PER_YEAR = 2080.0 | |
| 90 | + | |
| 91 | +_UNIT_PATTERNS = [ | |
| 92 | + ("hour", r"(?:/|par|per|an?)\s*(?:h\b|hr\b|hre\b|heure|hour)|horaire|hourly"), | |
| 93 | + ("year", r"(?:/|par|per|an?)\s*(?:an\b|année|annee|year|yr\b)|annuel|annual|yearly|per\s+annum"), | |
| 94 | + ("month", r"(?:/|par|per)\s*(?:mois|month)|mensuel|monthly"), | |
| 95 | + ("biweek", r"(?:aux|par|per)\s*(?:2|deux|two)\s*semaines|bi-?weekly|quinzaine"), | |
| 96 | + ("week", r"(?:/|par|per)\s*(?:sem\b|semaine|week|wk\b)|hebdomadaire|weekly"), | |
| 97 | + ("day", r"(?:/|par|per)\s*(?:jour|day)|journalier|daily"), | |
| 98 | +] | |
| 99 | + | |
| 100 | +_NUM_RE = re.compile(r"\d{1,3}(?:[ ,]\d{3})+(?:[.,]\d{1,2})?|\d+(?:[.,]\d{1,2})?[kK]?") | |
| 101 | + | |
| 102 | + | |
| 103 | +def _parse_number(tok: str) -> float | None: | |
| 104 | + tok = tok.strip() | |
| 105 | + k = tok.lower().endswith("k") | |
| 106 | + if k: | |
| 107 | + tok = tok[:-1] | |
| 108 | + # « 52,000 » (milliers anglais) vs « 52000,00 » / « 25,50 » (décimales) | |
| 109 | + tok = tok.replace(" ", " ").replace(" ", " ") | |
| 110 | + if re.fullmatch(r"\d{1,3}(?:[ ,]\d{3})+(?:[.,]\d{1,2})?", tok): | |
| 111 | + # séparateur de milliers : retirer espaces/virgules de groupe | |
| 112 | + tok = re.sub(r"[ ,](?=\d{3}\b)", "", tok) | |
| 113 | + tok = tok.replace(",", ".").replace(" ", "") | |
| 114 | + try: | |
| 115 | + v = float(tok) | |
| 116 | + except ValueError: | |
| 117 | + return None | |
| 118 | + return v * 1000 if k else v | |
| 119 | + | |
| 120 | + | |
| 121 | +def parse_salary(raw: str) -> tuple[float | None, float | None, str | None]: | |
| 122 | + """Texte de salaire -> (min, max, unité) ou (None, None, None). | |
| 123 | + | |
| 124 | + Exige un signe monétaire ($, CAD, dollars) OU une unité explicite proche | |
| 125 | + des nombres — sans quoi « 35 heures/semaine » deviendrait un salaire. | |
| 126 | + """ | |
| 127 | + s = clean_text(raw) | |
| 128 | + if not s: | |
| 129 | + return (None, None, None) | |
| 130 | + key = _key(s) | |
| 131 | + | |
| 132 | + unit = None | |
| 133 | + for u, pat in _UNIT_PATTERNS: | |
| 134 | + if re.search(pat, key): | |
| 135 | + unit = u | |
| 136 | + break | |
| 137 | + | |
| 138 | + has_money = bool(re.search(r"\$|cad\b|dollar", key)) | |
| 139 | + if not has_money and unit is None: | |
| 140 | + return (None, None, None) | |
| 141 | + | |
| 142 | + nums = [_parse_number(m.group(0)) for m in _NUM_RE.finditer(s)] | |
| 143 | + nums = [n for n in nums if n is not None] | |
| 144 | + if not nums: | |
| 145 | + return (None, None, None) | |
| 146 | + | |
| 147 | + # Sans unité explicite : inférer par l'ordre de grandeur (avec $ obligatoire) | |
| 148 | + if unit is None: | |
| 149 | + probe = max(nums) | |
| 150 | + if probe < 300: | |
| 151 | + unit = "hour" | |
| 152 | + elif probe >= 18000: | |
| 153 | + unit = "year" | |
| 154 | + else: | |
| 155 | + return (None, None, None) # zone ambiguë : ne pas deviner | |
| 156 | + | |
| 157 | + lo, hi = _SALARY_BOUNDS[unit] | |
| 158 | + vals = sorted(n for n in nums if lo <= n <= hi) | |
| 159 | + if not vals: | |
| 160 | + return (None, None, None) | |
| 161 | + return (vals[0], vals[-1] if len(vals) > 1 else vals[0], unit) | |
| 162 | + | |
| 163 | + | |
| 164 | +_TO_YEAR = {"hour": _HOURS_PER_YEAR, "day": 260.0, "week": 52.0, | |
| 165 | + "biweek": 26.0, "month": 12.0, "year": 1.0} | |
| 166 | + | |
| 167 | + | |
| 168 | +def salary_to_yearly(value: float | None, unit: str | None) -> float | None: | |
| 169 | + if value is None or unit not in _TO_YEAR: | |
| 170 | + return None | |
| 171 | + return round(value * _TO_YEAR[unit], 0) | |
| 172 | + | |
| 173 | + | |
| 174 | +def salary_to_hourly(value: float | None, unit: str | None) -> float | None: | |
| 175 | + y = salary_to_yearly(value, unit) | |
| 176 | + return round(y / _HOURS_PER_YEAR, 2) if y is not None else None | |
| 177 | + | |
| 178 | + | |
| 179 | +# --------------------------------------------------------------------------- | |
| 180 | +# Dates — ISO 8601, jamais de date inventée | |
| 181 | +# --------------------------------------------------------------------------- | |
| 182 | + | |
| 183 | +_MONTHS = { | |
| 184 | + "janvier": 1, "jan": 1, "january": 1, | |
| 185 | + "fevrier": 2, "fev": 2, "february": 2, "feb": 2, | |
| 186 | + "mars": 3, "mar": 3, "march": 3, | |
| 187 | + "avril": 4, "avr": 4, "april": 4, "apr": 4, | |
| 188 | + "mai": 5, "may": 5, | |
| 189 | + "juin": 6, "june": 6, "jun": 6, | |
| 190 | + "juillet": 7, "juil": 7, "july": 7, "jul": 7, | |
| 191 | + "aout": 8, "august": 8, "aug": 8, | |
| 192 | + "septembre": 9, "sept": 9, "sep": 9, "september": 9, | |
| 193 | + "octobre": 10, "oct": 10, "october": 10, | |
| 194 | + "novembre": 11, "nov": 11, "november": 11, | |
| 195 | + "decembre": 12, "dec": 12, "december": 12, | |
| 196 | +} | |
| 197 | + | |
| 198 | +_REL_RE = re.compile( | |
| 199 | + r"(?:il y a|posted)\s+(\d+)\+?\s*(jour|day|semaine|week|mois|month)|" | |
| 200 | + r"(\d+)\+?\s*(jour|day|semaine|week|mois|month)s?\s+ago", re.I) | |
| 201 | +_TODAY_RE = re.compile(r"aujourd'?hui|today|posted today|date du jour", re.I) | |
| 202 | +_YESTERDAY_RE = re.compile(r"hier|yesterday", re.I) | |
| 203 | + | |
| 204 | + | |
| 205 | +def parse_date(raw, today: _dt.date | None = None) -> str | None: | |
| 206 | + """Valeur de date hétéroclite -> ISO « YYYY-MM-DD », ou None. | |
| 207 | + | |
| 208 | + Accepte : ISO (+ horodatages), epoch secondes/millisecondes, formats | |
| 209 | + textuels FR/EN (« 1er juillet 2026 », « July 1st, 2026 »), et les dates | |
| 210 | + relatives (« il y a 3 jours », « Posted 30+ Days Ago »). | |
| 211 | + """ | |
| 212 | + if raw is None: | |
| 213 | + return None | |
| 214 | + today = today or _dt.date.today() | |
| 215 | + | |
| 216 | + if isinstance(raw, (int, float)): # epoch (Lever : millisecondes) | |
| 217 | + ts = float(raw) | |
| 218 | + if ts > 1e12: | |
| 219 | + ts /= 1000.0 | |
| 220 | + if 1e9 <= ts <= 4e9: | |
| 221 | + return _dt.date.fromtimestamp(ts).isoformat() | |
| 222 | + return None | |
| 223 | + | |
| 224 | + s = clean_text(str(raw)) | |
| 225 | + if not s: | |
| 226 | + return None | |
| 227 | + key = _key(s) | |
| 228 | + | |
| 229 | + m = re.search(r"(\d{4})-(\d{2})-(\d{2})", s) # ISO (avec ou sans heure) | |
| 230 | + if m: | |
| 231 | + try: | |
| 232 | + return _dt.date(int(m.group(1)), int(m.group(2)), int(m.group(3))).isoformat() | |
| 233 | + except ValueError: | |
| 234 | + return None | |
| 235 | + | |
| 236 | + if _TODAY_RE.search(key): | |
| 237 | + return today.isoformat() | |
| 238 | + if _YESTERDAY_RE.search(key): | |
| 239 | + return (today - _dt.timedelta(days=1)).isoformat() | |
| 240 | + | |
| 241 | + m = _REL_RE.search(key) | |
| 242 | + if m: | |
| 243 | + n = int(m.group(1) or m.group(3)) | |
| 244 | + unit = (m.group(2) or m.group(4) or "").lower() | |
| 245 | + days = n * (7 if unit.startswith(("sem", "week")) | |
| 246 | + else 30 if unit.startswith(("moi", "month")) else 1) | |
| 247 | + return (today - _dt.timedelta(days=days)).isoformat() | |
| 248 | + | |
| 249 | + # « 1er juillet 2026 » / « July 1st, 2026 » / « déc. 2026 » | |
| 250 | + m = re.search(r"(?:(\d{1,2})(?:er|e|st|nd|rd|th)?\s+)?([a-z]{3,9})\.?,?\s+(?:(\d{1,2})(?:er|st|nd|rd|th)?,?\s+)?(\d{4})", key) | |
| 251 | + if m: | |
| 252 | + mois = _MONTHS.get(m.group(2)) | |
| 253 | + if mois: | |
| 254 | + jour = int(m.group(1) or m.group(3) or 1) | |
| 255 | + try: | |
| 256 | + return _dt.date(int(m.group(4)), mois, jour).isoformat() | |
| 257 | + except ValueError: | |
| 258 | + return None | |
| 259 | + | |
| 260 | + m = re.fullmatch(r"(\d{1,2})/(\d{1,2})/(\d{4})", s) # jj/mm/aaaa (usage QC) | |
| 261 | + if m: | |
| 262 | + d, mo, y = int(m.group(1)), int(m.group(2)), int(m.group(3)) | |
| 263 | + if mo > 12 and d <= 12: # en fait mm/jj/aaaa | |
| 264 | + d, mo = mo, d | |
| 265 | + try: | |
| 266 | + return _dt.date(y, mo, d).isoformat() | |
| 267 | + except ValueError: | |
| 268 | + return None | |
| 269 | + return None | |
| 270 | + | |
| 271 | + | |
| 272 | +# --------------------------------------------------------------------------- | |
| 273 | +# Mode de travail / type d'emploi | |
| 274 | +# --------------------------------------------------------------------------- | |
| 275 | + | |
| 276 | +_MODE_RULES = [ | |
| 277 | + ("teletravail", r"100\s*%\s*(?:teletravail|remote)|full(?:y)?[ -]remote|" | |
| 278 | + r"teletravail|travail a distance|remote(?:\b|-first)|a distance"), | |
| 279 | + ("hybride", r"hybrid|hybride|flexible?\s*(?:work|travail)"), | |
| 280 | + ("presentiel", r"presentiel|sur (?:place|site)|on[- ]?site|in[- ]?(?:office|person)"), | |
| 281 | +] | |
| 282 | + | |
| 283 | + | |
| 284 | +def parse_work_mode(raw: str) -> str | None: | |
| 285 | + key = _key(raw) | |
| 286 | + if not key: | |
| 287 | + return None | |
| 288 | + # « hybride » prime sur les mentions accessoires de télétravail | |
| 289 | + if re.search(_MODE_RULES[1][1], key): | |
| 290 | + return "hybride" | |
| 291 | + for mode, pat in _MODE_RULES: | |
| 292 | + if re.search(pat, key): | |
| 293 | + return mode | |
| 294 | + return None | |
| 295 | + | |
| 296 | + | |
| 297 | +_TYPE_RULES = [ | |
| 298 | + ("stage", r"\bstage\b|stagiaire|intern(?:ship)?\b|co-?op\b|alternance"), | |
| 299 | + ("saisonnier", r"saisonn|seasonal"), | |
| 300 | + ("contractuel", r"contrat\b|contract(?:uel|or)?\b|temporaire|temporary|terme|" | |
| 301 | + r"fixed[- ]term|duree determinee|pigiste|freelance|consultant"), | |
| 302 | + ("temps_partiel", r"temps[- ]partiel|part[- ]?time|partiel\b"), | |
| 303 | + ("temps_plein", r"temps[- ]plein|full[- ]?time|permanent|regulier|" | |
| 304 | + r"regular\b|duree indeterminee"), | |
| 305 | +] | |
| 306 | + | |
| 307 | + | |
| 308 | +def parse_employment_type(raw: str) -> str | None: | |
| 309 | + key = _key(raw) | |
| 310 | + if not key: | |
| 311 | + return None | |
| 312 | + for typ, pat in _TYPE_RULES: | |
| 313 | + if re.search(pat, key): | |
| 314 | + return typ | |
| 315 | + return None | |
| 316 | + | |
| 317 | + | |
| 318 | +# --------------------------------------------------------------------------- | |
| 319 | +# Lieu — extraction ville/région + garde-fou « offre au Québec » | |
| 320 | +# --------------------------------------------------------------------------- | |
| 321 | + | |
| 322 | +# Villes québécoises fréquentes dans les libellés d'offres (clé sans accents). | |
| 323 | +# Sert à la fois au parsing et au filtre is_quebec_location. | |
| 324 | +_QC_CITIES = { | |
| 325 | + "montreal": "Montréal", "quebec": "Québec", "ville de quebec": "Québec", | |
| 326 | + "quebec city": "Québec", "laval": "Laval", "gatineau": "Gatineau", | |
| 327 | + "longueuil": "Longueuil", "sherbrooke": "Sherbrooke", | |
| 328 | + "saguenay": "Saguenay", "chicoutimi": "Saguenay", | |
| 329 | + "levis": "Lévis", "trois-rivieres": "Trois-Rivières", | |
| 330 | + "trois rivieres": "Trois-Rivières", "terrebonne": "Terrebonne", | |
| 331 | + "brossard": "Brossard", "repentigny": "Repentigny", | |
| 332 | + "boucherville": "Boucherville", "saint-jerome": "Saint-Jérôme", | |
| 333 | + "drummondville": "Drummondville", "saint-jean-sur-richelieu": | |
| 334 | + "Saint-Jean-sur-Richelieu", "granby": "Granby", "shawinigan": "Shawinigan", | |
| 335 | + "joliette": "Joliette", "victoriaville": "Victoriaville", | |
| 336 | + "rimouski": "Rimouski", "rouyn-noranda": "Rouyn-Noranda", | |
| 337 | + "baie-comeau": "Baie-Comeau", "sept-iles": "Sept-Îles", | |
| 338 | + "val-d'or": "Val-d'Or", "val d'or": "Val-d'Or", "alma": "Alma", | |
| 339 | + "sainte-foy": "Québec", "anjou": "Montréal", "dorval": "Dorval", | |
| 340 | + "boisbriand": "Boisbriand", "blainville": "Blainville", | |
| 341 | + "mirabel": "Mirabel", "saint-hyacinthe": "Saint-Hyacinthe", | |
| 342 | + "beloeil": "Belœil", "chateauguay": "Châteauguay", "magog": "Magog", | |
| 343 | + "thetford mines": "Thetford Mines", "saint-georges": "Saint-Georges", | |
| 344 | + "riviere-du-loup": "Rivière-du-Loup", "matane": "Matane", | |
| 345 | + "sorel-tracy": "Sorel-Tracy", "varennes": "Varennes", | |
| 346 | + "vaudreuil-dorion": "Vaudreuil-Dorion", "salaberry-de-valleyfield": | |
| 347 | + "Salaberry-de-Valleyfield", "kirkland": "Kirkland", | |
| 348 | + "pointe-claire": "Pointe-Claire", "saint-laurent": "Montréal", | |
| 349 | + "ville saint-laurent": "Montréal", "verdun": "Montréal", | |
| 350 | + "westmount": "Westmount", "candiac": "Candiac", "la prairie": "La Prairie", | |
| 351 | + "mont-tremblant": "Mont-Tremblant", "saint-bruno": "Saint-Bruno-de-Montarville", | |
| 352 | + "bromont": "Bromont", "cowansville": "Cowansville", | |
| 353 | + "saint-augustin-de-desmaures": "Saint-Augustin-de-Desmaures", | |
| 354 | + "l'ancienne-lorette": "L'Ancienne-Lorette", | |
| 355 | +} | |
| 356 | + | |
| 357 | +_QC_MARKERS = re.compile( | |
| 358 | + r"\bqc\b|\bquebec\b|\bqu[e]bec, canada\b|province de quebec", re.I) | |
| 359 | +_POSTAL_RE = re.compile(r"\b([GHJ]\d[A-Z])\s*(\d[A-Z]\d)\b", re.I) # G/H/J = Québec | |
| 360 | + | |
| 361 | + | |
| 362 | +def canonical_city(raw: str) -> str: | |
| 363 | + """Canonicalise un nom de ville (« Montreal » -> « Montréal », | |
| 364 | + « Quebec City » -> « Québec »). Inconnu : retourne le texte nettoyé.""" | |
| 365 | + s = clean_text(raw) | |
| 366 | + if not s: | |
| 367 | + return "" | |
| 368 | + key = _key(s) | |
| 369 | + exact = _QC_CITIES.get(key) | |
| 370 | + if exact: | |
| 371 | + return exact | |
| 372 | + for ck, city in _QC_CITIES.items(): | |
| 373 | + if re.fullmatch(re.escape(ck), key): | |
| 374 | + return city | |
| 375 | + return s | |
| 376 | + | |
| 377 | + | |
| 378 | +def parse_location(raw: str) -> dict: | |
| 379 | + """Libellé de lieu -> {city, region, postal_code} (champs possiblement vides). | |
| 380 | + | |
| 381 | + Ex. « Montréal, QC, Canada », « Ville de Québec, Québec », « Remote - QC ». | |
| 382 | + """ | |
| 383 | + s = clean_text(raw) | |
| 384 | + out = {"city": "", "region": "", "postal_code": ""} | |
| 385 | + if not s: | |
| 386 | + return out | |
| 387 | + m = _POSTAL_RE.search(s) | |
| 388 | + if m: | |
| 389 | + out["postal_code"] = f"{m.group(1).upper()} {m.group(2).upper()}" | |
| 390 | + key = _key(s) | |
| 391 | + for ck, city in _QC_CITIES.items(): | |
| 392 | + if re.search(rf"(?<![a-z]){re.escape(ck)}(?![a-z])", key): | |
| 393 | + out["city"] = city | |
| 394 | + break | |
| 395 | + if _QC_MARKERS.search(key) or out["city"] or ( | |
| 396 | + out["postal_code"] and out["postal_code"][0] in "GHJ"): | |
| 397 | + out["region"] = "Québec" | |
| 398 | + if not out["city"]: | |
| 399 | + # premier segment avant la virgule, si ce n'est pas un mot générique | |
| 400 | + seg = clean_text(s.split(",")[0]) | |
| 401 | + if seg and len(seg) <= 40 and not re.search( | |
| 402 | + r"remote|teletravail|canada|multiple|various|partout|" | |
| 403 | + r"\d+\s+locations?|\d+\s+lieux", | |
| 404 | + _key(seg)): | |
| 405 | + out["city"] = seg | |
| 406 | + return out | |
| 407 | + | |
| 408 | + | |
| 409 | +def is_quebec_location(raw: str) -> bool: | |
| 410 | + """Vrai si le libellé pointe vers le Québec (filtre des ATS pancanadiens).""" | |
| 411 | + key = _key(clean_text(raw)) | |
| 412 | + if not key: | |
| 413 | + return False | |
| 414 | + if _QC_MARKERS.search(key): | |
| 415 | + return True | |
| 416 | + m = _POSTAL_RE.search(raw) | |
| 417 | + if m and m.group(1)[0].upper() in "GHJ": | |
| 418 | + return True | |
| 419 | + return any(re.search(rf"(?<![a-z]){re.escape(ck)}(?![a-z])", key) | |
| 420 | + for ck in _QC_CITIES) | |
| 421 | + | |
| 422 | + | |
| 423 | +# --------------------------------------------------------------------------- | |
| 424 | +# Catégorisation — taxonomie interne déterministe (mots-clés FR/EN) | |
| 425 | +# --------------------------------------------------------------------------- | |
| 426 | + | |
| 427 | +_CATEGORY_RULES = [ | |
| 428 | + ("TI", r"developpe|developer|logiciel|software|devops|donnees|data\b|" | |
| 429 | + r"informatique|programmeur|programmer|cybersecurit|cloud|fullstack|" | |
| 430 | + r"full[- ]stack|backend|frontend|sre\b|qa\b|scrum|infrastructure ti|" | |
| 431 | + r"intelligence artificielle|machine learning|\bia\b|\bai\b|analyste ti"), | |
| 432 | + ("Santé", r"infirmier|infirmiere|nurse|medecin|pharmac|dentaire|sante\b|" | |
| 433 | + r"prepose aux beneficiaires|\bpab\b|physiotherap|ergotherap|" | |
| 434 | + r"psycholog|travailleur social|clinique|medical"), | |
| 435 | + ("Ingénierie", r"ingenieur|engineer(?!ing manager, software)|genie\b|" | |
| 436 | + r"mecanique du batiment|electrique|civil\b|structurel"), | |
| 437 | + ("Construction", r"construction|charpentier|menuisier|plombier|electricien|" | |
| 438 | + r"contremaitre|chantier|grutier|briqueteur|couvreur|paysagiste"), | |
| 439 | + ("Finance", r"comptab|account(?:ant|ing)|finance|financier|paie\b|payroll|audit|" | |
| 440 | + r"fiscalite|tresorerie|actuaire|actuarial|credit\b|placement"), | |
| 441 | + ("Éducation", r"enseignant|professeur|teacher|educat|tuteur|formateur|" | |
| 442 | + r"pedagog|garderie|cpe\b"), | |
| 443 | + ("Juridique", r"avocat|juriste|notaire|parajuriste|paralegal|legal counsel|" | |
| 444 | + r"conformite|compliance"), | |
| 445 | + ("RH", r"ressources humaines|\brh\b|recrut|talent|human resources|\bhr\b|" | |
| 446 | + r"acquisition de talents"), | |
| 447 | + ("Marketing", r"marketing|communication|contenu|content|marque|brand|" | |
| 448 | + r"medias sociaux|social media|seo\b|publicite|graphiste|designer"), | |
| 449 | + ("Ventes", r"vente|ventes|sales|representant|account (?:executive|manager)|" | |
| 450 | + r"developpement des affaires|business development|conseiller commercial"), | |
| 451 | + ("Service à la clientèle", r"service a la clientele|customer (?:service|success|" | |
| 452 | + r"support)|centre d'appels|call cent"), | |
| 453 | + ("Transport", r"chauffeur|camionneur|driver|livreur|logistique|logistics|" | |
| 454 | + r"transport|entrepot|warehouse|cariste|repartiteur"), | |
| 455 | + ("Production", r"production|manufactur|usine|operateur|machiniste|soudeur|" | |
| 456 | + r"welder|assembleur|journalier|emballage|maintenance industrielle"), | |
| 457 | + ("Restauration", r"cuisinier|chef\b|serveur|barista|restauration|plongeur|" | |
| 458 | + r"boulanger|patissier|traiteur|banquet"), | |
| 459 | + ("Commerce de détail", r"commis|caissier|retail|magasin|boutique|" | |
| 460 | + r"marchandiseur|gerant de magasin"), | |
| 461 | + ("Administration", r"adjoint|administratif|secretaire|receptionniste|" | |
| 462 | + r"coordonnat|commis de bureau|office (?:manager|clerk)"), | |
| 463 | +] | |
| 464 | + | |
| 465 | + | |
| 466 | +def categorize(title: str, description: str = "") -> str: | |
| 467 | + """Titre -> catégorie interne (repli : début de description), ou "". | |
| 468 | + | |
| 469 | + Le titre est essayé SEUL d'abord : la description commence souvent par le | |
| 470 | + boilerplate de l'entreprise (« leader du manufacturier… ») qui fausserait | |
| 471 | + la catégorie du poste. | |
| 472 | + """ | |
| 473 | + for texte in (title, description[:300]): | |
| 474 | + key = _key(texte or "") | |
| 475 | + if not key.strip(): | |
| 476 | + continue | |
| 477 | + for cat, pat in _CATEGORY_RULES: | |
| 478 | + if re.search(pat, key): | |
| 479 | + return cat | |
| 480 | + return "" | |
added
jobka/schema.py
+167 −0
@@ -0,0 +1,167 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : jobka/schema.py | |
| 6 | +# Rôle : Modèle de données standardisé (JobPosting) + normalisation centrale | |
| 7 | +# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 8 | +# ============================================================================= | |
| 9 | +"""Schéma standard d'une offre d'emploi (JobPosting) et normalisation. | |
| 10 | + | |
| 11 | +Chaque connecteur, peu importe l'employeur ou l'ATS source, doit produire des | |
| 12 | +objets `JobPosting` conformes à ce schéma. La méthode `finalize()` applique | |
| 13 | +ensuite la couche de normalisation commune (jobka/normalize.py) : dates ISO, | |
| 14 | +salaires numériques + conversions $/h et $/an, mode de travail, type d'emploi, | |
| 15 | +catégorie… — les connecteurs restent simples et remplissent les champs bruts. | |
| 16 | +""" | |
| 17 | +from __future__ import annotations | |
| 18 | + | |
| 19 | +import hashlib | |
| 20 | +import json | |
| 21 | +from dataclasses import dataclass, field, asdict | |
| 22 | + | |
| 23 | +from .normalize import ( # ré-exportés pour les connecteurs | |
| 24 | + canonical_city, | |
| 25 | + categorize, | |
| 26 | + clean_html, | |
| 27 | + clean_text, | |
| 28 | + is_quebec_location, | |
| 29 | + parse_date, | |
| 30 | + parse_employment_type, | |
| 31 | + parse_location, | |
| 32 | + parse_salary, | |
| 33 | + parse_work_mode, | |
| 34 | + salary_to_hourly, | |
| 35 | + salary_to_yearly, | |
| 36 | + strip_accents, | |
| 37 | +) | |
| 38 | + | |
| 39 | +__all__ = [ | |
| 40 | + "JobPosting", "categorize", "clean_html", "clean_text", | |
| 41 | + "is_quebec_location", "parse_date", "parse_employment_type", | |
| 42 | + "parse_location", "parse_salary", "parse_work_mode", "strip_accents", | |
| 43 | +] | |
| 44 | + | |
| 45 | + | |
| 46 | +@dataclass | |
| 47 | +class JobPosting: | |
| 48 | + """Offre d'emploi standardisée Job·Ka.""" | |
| 49 | + | |
| 50 | + source: str # id du connecteur (voir data/sources.json) | |
| 51 | + external_id: str # identifiant chez l'employeur/ATS | |
| 52 | + url: str # lien direct vers l'offre — zéro boîte noire | |
| 53 | + employer: str = "" # nom de l'entreprise | |
| 54 | + title: str = "" # titre du poste | |
| 55 | + description: str = "" # texte complet (HTML nettoyé) | |
| 56 | + address: str = "" # adresse du lieu de travail si disponible | |
| 57 | + city: str = "" # Montréal, Québec, Sherbrooke… | |
| 58 | + region: str = "" # région administrative / « Québec » | |
| 59 | + postal_code: str = "" | |
| 60 | + location_label: str = "" # libellé de lieu original (brut) | |
| 61 | + work_mode: str | None = None # presentiel | hybride | teletravail | |
| 62 | + employment_type: str | None = None # temps_plein | temps_partiel | | |
| 63 | + # contractuel | stage | saisonnier | |
| 64 | + salary_min: float | None = None # nombres, jamais des strings | |
| 65 | + salary_max: float | None = None | |
| 66 | + salary_unit: str | None = None # hour | day | week | biweek | month | year | |
| 67 | + salary_label: str = "" # texte original (ex. « 25 $ à 30 $/h ») | |
| 68 | + benefits: list[str] = field(default_factory=list) | |
| 69 | + requirements: dict = field(default_factory=dict) # scolarité, expérience, langues | |
| 70 | + date_posted: str | None = None # ISO 8601 | |
| 71 | + date_deadline: str | None = None # ISO 8601 (date limite pour postuler) | |
| 72 | + category: str = "" # taxonomie interne (TI, Santé, …) | |
| 73 | + ats: str = "" # workday | lever | greenhouse | custom… | |
| 74 | + details: dict = field(default_factory=dict) # champs structurés (sparse) | |
| 75 | + lat: float | None = None | |
| 76 | + lng: float | None = None | |
| 77 | + | |
| 78 | + @property | |
| 79 | + def uid(self) -> str: | |
| 80 | + return f"{self.source}:{self.external_id}" | |
| 81 | + | |
| 82 | + def content_hash(self) -> str: | |
| 83 | + """Hash du contenu pour la détection de changements (pseudo-webhook).""" | |
| 84 | + payload = asdict(self) | |
| 85 | + blob = json.dumps(payload, sort_keys=True, ensure_ascii=False) | |
| 86 | + return hashlib.sha256(blob.encode("utf-8")).hexdigest() | |
| 87 | + | |
| 88 | + # Conversions dérivées — calculées à la volée, PAS dans content_hash | |
| 89 | + # (elles découlent de salary_min/max/unit, les stocker dans le hash ne | |
| 90 | + # ferait que dupliquer l'information). | |
| 91 | + def salary_year_min(self) -> float | None: | |
| 92 | + return salary_to_yearly(self.salary_min, self.salary_unit) | |
| 93 | + | |
| 94 | + def salary_year_max(self) -> float | None: | |
| 95 | + return salary_to_yearly(self.salary_max, self.salary_unit) | |
| 96 | + | |
| 97 | + def salary_hour_min(self) -> float | None: | |
| 98 | + return salary_to_hourly(self.salary_min, self.salary_unit) | |
| 99 | + | |
| 100 | + def salary_hour_max(self) -> float | None: | |
| 101 | + return salary_to_hourly(self.salary_max, self.salary_unit) | |
| 102 | + | |
| 103 | + def finalize(self) -> "JobPosting": | |
| 104 | + """Applique la normalisation commune. Appelé par le pipeline d'ingestion. | |
| 105 | + | |
| 106 | + Idempotent ; ne remplace jamais une valeur explicite du connecteur. | |
| 107 | + """ | |
| 108 | + self.title = clean_text(self.title) | |
| 109 | + self.employer = clean_text(self.employer) | |
| 110 | + self.description = clean_text(self.description) if "\n" not in self.description \ | |
| 111 | + else self.description.strip() | |
| 112 | + | |
| 113 | + # lieu : compléter ville/région/code postal depuis le libellé brut, | |
| 114 | + # puis canonicaliser la ville (« Montreal » -> « Montréal ») | |
| 115 | + if self.location_label and not (self.city and self.region): | |
| 116 | + loc = parse_location(self.location_label) | |
| 117 | + self.city = self.city or loc["city"] | |
| 118 | + self.region = self.region or loc["region"] | |
| 119 | + self.postal_code = self.postal_code or loc["postal_code"] | |
| 120 | + self.city = canonical_city(self.city) | |
| 121 | + if self.city and not self.region and is_quebec_location(self.city): | |
| 122 | + self.region = "Québec" | |
| 123 | + | |
| 124 | + # salaire : parser le libellé si le connecteur n'a pas de valeurs numériques | |
| 125 | + if self.salary_min is None and self.salary_label: | |
| 126 | + lo, hi, unit = parse_salary(self.salary_label) | |
| 127 | + self.salary_min, self.salary_max, self.salary_unit = lo, hi, unit | |
| 128 | + if self.salary_min is None and self.description: | |
| 129 | + # chercher une mention salariale dans la description (fréquent au QC) | |
| 130 | + import re | |
| 131 | + m = re.search(r"[^.\n]{0,60}\d[^.\n]{0,40}\$[^.\n]{0,40}", self.description) | |
| 132 | + if m: | |
| 133 | + lo, hi, unit = parse_salary(m.group(0)) | |
| 134 | + if lo is not None: | |
| 135 | + self.salary_min, self.salary_max, self.salary_unit = lo, hi, unit | |
| 136 | + self.salary_label = self.salary_label or clean_text(m.group(0)) | |
| 137 | + | |
| 138 | + # dates : accepter epoch/textuel/relatif -> ISO | |
| 139 | + if self.date_posted is not None: | |
| 140 | + self.date_posted = parse_date(self.date_posted) | |
| 141 | + if self.date_deadline is not None: | |
| 142 | + self.date_deadline = parse_date(self.date_deadline) | |
| 143 | + | |
| 144 | + # mode de travail / type d'emploi : depuis le libellé de lieu, le titre | |
| 145 | + # puis la description — première réponse trouvée, jamais de défaut | |
| 146 | + if self.work_mode is None: | |
| 147 | + for texte in (self.location_label, self.title, self.description[:500]): | |
| 148 | + self.work_mode = parse_work_mode(texte) | |
| 149 | + if self.work_mode: | |
| 150 | + break | |
| 151 | + if self.employment_type is None: | |
| 152 | + for texte in (self.details.get("employment_label", ""), self.title, | |
| 153 | + self.description[:500]): | |
| 154 | + self.employment_type = parse_employment_type(str(texte)) | |
| 155 | + if self.employment_type: | |
| 156 | + break | |
| 157 | + | |
| 158 | + if not self.category: | |
| 159 | + self.category = categorize(self.title, self.description) | |
| 160 | + | |
| 161 | + # coordonnées fournies par la source : rejeter tout point hors de la | |
| 162 | + # province (lat/lng inversés, 0/0, coquilles) — le géocodeur prendra | |
| 163 | + # le relais sur l'adresse/la ville | |
| 164 | + if self.lat is not None and self.lng is not None: | |
| 165 | + if not (44.5 <= self.lat <= 63.0 and -80.0 <= self.lng <= -56.0): | |
| 166 | + self.lat = self.lng = None | |
| 167 | + return self | |
added
jobka/web.py
+338 −0
@@ -0,0 +1,338 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : jobka/web.py | |
| 6 | +# Rôle : API FastAPI (JSON) + healthcheck + service du frontend (frontend/dist) | |
| 7 | +# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 8 | +# ============================================================================= | |
| 9 | +from __future__ import annotations | |
| 10 | + | |
| 11 | +import json | |
| 12 | +import threading | |
| 13 | +import time | |
| 14 | +from pathlib import Path | |
| 15 | + | |
| 16 | +from fastapi import BackgroundTasks, FastAPI, HTTPException, Query | |
| 17 | +from fastapi.middleware.cors import CORSMiddleware | |
| 18 | +from fastapi.middleware.gzip import GZipMiddleware | |
| 19 | +from fastapi.responses import FileResponse | |
| 20 | +from fastapi.staticfiles import StaticFiles | |
| 21 | + | |
| 22 | +from . import db, ingest | |
| 23 | + | |
| 24 | +ROOT = Path(__file__).resolve().parent.parent | |
| 25 | +SOURCES_PATH = ROOT / "data" / "sources.json" | |
| 26 | +FRONTEND_DIST = ROOT / "frontend" / "dist" | |
| 27 | + | |
| 28 | +app = FastAPI(title="Job-Ka API", version="1.0", | |
| 29 | + description="Agrégateur d'offres d'emploi — employeurs québécois, " | |
| 30 | + "directement à la source") | |
| 31 | +app.add_middleware(CORSMiddleware, allow_origins=["*"], | |
| 32 | + allow_methods=["*"], allow_headers=["*"]) | |
| 33 | +app.add_middleware(GZipMiddleware, minimum_size=1000) | |
| 34 | + | |
| 35 | +_sync_lock = threading.Lock() | |
| 36 | + | |
| 37 | + | |
| 38 | +def _row_to_dict(row) -> dict: | |
| 39 | + d = dict(row) | |
| 40 | + d["benefits"] = json.loads(d.get("benefits") or "[]") | |
| 41 | + d["requirements"] = json.loads(d.get("requirements") or "{}") | |
| 42 | + d["details"] = json.loads(d.get("details") or "{}") | |
| 43 | + if d.get("dup_sources"): | |
| 44 | + try: | |
| 45 | + d["dup_sources"] = json.loads(d["dup_sources"]) | |
| 46 | + except (ValueError, TypeError): | |
| 47 | + d["dup_sources"] = [] | |
| 48 | + return d | |
| 49 | + | |
| 50 | + | |
| 51 | +@app.get("/health") | |
| 52 | +def health(): | |
| 53 | + """Healthcheck de déploiement : BD accessible + fraîcheur des syncs.""" | |
| 54 | + try: | |
| 55 | + con = db.connect() | |
| 56 | + total = con.execute( | |
| 57 | + "SELECT COUNT(*) c FROM jobs WHERE active=1").fetchone()["c"] | |
| 58 | + last = con.execute( | |
| 59 | + "SELECT MAX(ts) ts FROM sync_log WHERE ok=1").fetchone()["ts"] | |
| 60 | + con.close() | |
| 61 | + except Exception as exc: | |
| 62 | + raise HTTPException(500, f"BD inaccessible : {exc}") | |
| 63 | + return {"status": "ok", "active_jobs": total, | |
| 64 | + "last_sync_age_s": round(time.time() - last) if last else None} | |
| 65 | + | |
| 66 | + | |
| 67 | +@app.get("/api/jobs") | |
| 68 | +def list_jobs( | |
| 69 | + city: str | None = None, | |
| 70 | + region: str | None = None, | |
| 71 | + category: str | None = None, | |
| 72 | + employer: str | None = None, | |
| 73 | + source: str | None = None, | |
| 74 | + work_mode: str | None = None, | |
| 75 | + employment_type: str | None = None, | |
| 76 | + salary_min: float | None = None, # $/an (converti côté serveur) | |
| 77 | + with_salary: int | None = None, # 1 = transparence salariale seulement | |
| 78 | + posted_after: str | None = None, # ISO : offres publiées depuis… | |
| 79 | + q: str | None = None, | |
| 80 | + active: int = 1, | |
| 81 | + sort: str = "recent", # recent | salary | |
| 82 | + limit: int = Query(100, le=2000), | |
| 83 | + offset: int = 0, | |
| 84 | +): | |
| 85 | + con = db.connect() | |
| 86 | + sql = "SELECT * FROM jobs WHERE dup_of IS NULL" # doublons masqués | |
| 87 | + args: list = [] | |
| 88 | + if active in (0, 1): | |
| 89 | + sql += " AND active=?"; args.append(active) | |
| 90 | + if city: | |
| 91 | + sql += " AND city=?"; args.append(city) | |
| 92 | + if region: | |
| 93 | + sql += " AND region=?"; args.append(region) | |
| 94 | + if category: | |
| 95 | + sql += " AND category=?"; args.append(category) | |
| 96 | + if employer: | |
| 97 | + sql += " AND employer LIKE ?"; args.append(f"%{employer}%") | |
| 98 | + if source: | |
| 99 | + sql += " AND source=?"; args.append(source) | |
| 100 | + if work_mode: | |
| 101 | + sql += " AND work_mode=?"; args.append(work_mode) | |
| 102 | + if employment_type: | |
| 103 | + sql += " AND employment_type=?"; args.append(employment_type) | |
| 104 | + if salary_min is not None: | |
| 105 | + sql += (" AND salary_year_max IS NOT NULL AND salary_year_max>=?") | |
| 106 | + args.append(salary_min) | |
| 107 | + if with_salary == 1: | |
| 108 | + sql += " AND salary_min IS NOT NULL" | |
| 109 | + if posted_after: | |
| 110 | + sql += " AND date_posted IS NOT NULL AND date_posted>=?" | |
| 111 | + args.append(posted_after) | |
| 112 | + if q: | |
| 113 | + sql += " AND (title LIKE ? OR employer LIKE ? OR description LIKE ?)" | |
| 114 | + args += [f"%{q}%"] * 3 | |
| 115 | + total = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"] | |
| 116 | + if sort == "salary": | |
| 117 | + sql += " ORDER BY salary_year_max IS NULL, salary_year_max DESC" | |
| 118 | + else: | |
| 119 | + sql += " ORDER BY date_posted IS NULL, date_posted DESC, first_seen DESC" | |
| 120 | + sql += " LIMIT ? OFFSET ?" | |
| 121 | + args += [limit, offset] | |
| 122 | + rows = [] | |
| 123 | + for r in con.execute(sql, args).fetchall(): | |
| 124 | + d = _row_to_dict(r) | |
| 125 | + d.pop("description", None) # liste allégée : la fiche a le texte complet | |
| 126 | + rows.append(d) | |
| 127 | + con.close() | |
| 128 | + return {"total": total, "count": len(rows), "jobs": rows} | |
| 129 | + | |
| 130 | + | |
| 131 | +@app.get("/api/jobs.geojson") | |
| 132 | +def jobs_geojson( | |
| 133 | + city: str | None = None, | |
| 134 | + category: str | None = None, | |
| 135 | + work_mode: str | None = None, | |
| 136 | + employment_type: str | None = None, | |
| 137 | + salary_min: float | None = None, | |
| 138 | + with_salary: int | None = None, | |
| 139 | + q: str | None = None, | |
| 140 | + bbox: str | None = None, | |
| 141 | + limit: int = Query(3000, le=8000), | |
| 142 | +): | |
| 143 | + """Offres géolocalisées au format GeoJSON (mêmes filtres que /api/jobs). | |
| 144 | + | |
| 145 | + `bbox=ouest,sud,est,nord` restreint au rectangle visible (idx_jobs_geo). | |
| 146 | + Membres additionnels : `totalGeocoded` / `totalMatching` pour afficher | |
| 147 | + « N résultats sans position sur la carte ». | |
| 148 | + """ | |
| 149 | + con = db.connect() | |
| 150 | + sql = ("SELECT uid, title, employer, city, category, work_mode," | |
| 151 | + " employment_type, salary_min, salary_max, salary_unit," | |
| 152 | + " salary_year_min, salary_year_max, date_posted, source, lat, lng" | |
| 153 | + " FROM jobs WHERE active=1 AND dup_of IS NULL" | |
| 154 | + " AND lat IS NOT NULL AND lng IS NOT NULL") | |
| 155 | + args: list = [] | |
| 156 | + if bbox: | |
| 157 | + try: | |
| 158 | + west, south, east, north = (float(v) for v in bbox.split(",")) | |
| 159 | + except ValueError: | |
| 160 | + raise HTTPException(400, "bbox attendu : ouest,sud,est,nord") | |
| 161 | + sql += " AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?" | |
| 162 | + args += [south, north, west, east] | |
| 163 | + if city: | |
| 164 | + sql += " AND city=?"; args.append(city) | |
| 165 | + if category: | |
| 166 | + sql += " AND category=?"; args.append(category) | |
| 167 | + if work_mode: | |
| 168 | + sql += " AND work_mode=?"; args.append(work_mode) | |
| 169 | + if employment_type: | |
| 170 | + sql += " AND employment_type=?"; args.append(employment_type) | |
| 171 | + if salary_min is not None: | |
| 172 | + sql += " AND salary_year_max IS NOT NULL AND salary_year_max>=?" | |
| 173 | + args.append(salary_min) | |
| 174 | + if with_salary == 1: | |
| 175 | + sql += " AND salary_min IS NOT NULL" | |
| 176 | + if q: | |
| 177 | + sql += " AND (title LIKE ? OR employer LIKE ?)" | |
| 178 | + args += [f"%{q}%"] * 2 | |
| 179 | + | |
| 180 | + total_geo = con.execute(f"SELECT COUNT(*) c FROM ({sql})", args).fetchone()["c"] | |
| 181 | + sql_all = sql.replace(" AND lat IS NOT NULL AND lng IS NOT NULL", "") | |
| 182 | + args_all = list(args) | |
| 183 | + if bbox: | |
| 184 | + sql_all = sql_all.replace(" AND lat BETWEEN ? AND ? AND lng BETWEEN ? AND ?", "") | |
| 185 | + del args_all[0:4] | |
| 186 | + total_all = con.execute(f"SELECT COUNT(*) c FROM ({sql_all})", args_all).fetchone()["c"] | |
| 187 | + | |
| 188 | + sql += " ORDER BY date_posted IS NULL, date_posted DESC LIMIT ?" | |
| 189 | + args.append(limit) | |
| 190 | + | |
| 191 | + features = [] | |
| 192 | + for r in con.execute(sql, args).fetchall(): | |
| 193 | + features.append({ | |
| 194 | + "type": "Feature", | |
| 195 | + "geometry": {"type": "Point", "coordinates": [r["lng"], r["lat"]]}, | |
| 196 | + "properties": { | |
| 197 | + "uid": r["uid"], "title": r["title"], "employer": r["employer"], | |
| 198 | + "city": r["city"], "category": r["category"], | |
| 199 | + "work_mode": r["work_mode"], | |
| 200 | + "employment_type": r["employment_type"], | |
| 201 | + "salary_min": r["salary_min"], "salary_max": r["salary_max"], | |
| 202 | + "salary_unit": r["salary_unit"], | |
| 203 | + "salary_year_min": r["salary_year_min"], | |
| 204 | + "salary_year_max": r["salary_year_max"], | |
| 205 | + "date_posted": r["date_posted"], "source": r["source"], | |
| 206 | + }, | |
| 207 | + }) | |
| 208 | + con.close() | |
| 209 | + return {"type": "FeatureCollection", "features": features, | |
| 210 | + "totalGeocoded": total_geo, "totalMatching": total_all} | |
| 211 | + | |
| 212 | + | |
| 213 | +@app.get("/api/jobs/{uid}") | |
| 214 | +def get_job(uid: str): | |
| 215 | + con = db.connect() | |
| 216 | + row = con.execute("SELECT * FROM jobs WHERE uid=?", (uid,)).fetchone() | |
| 217 | + d = None | |
| 218 | + if row is not None: | |
| 219 | + d = _row_to_dict(row) | |
| 220 | + d["salary_history"] = [dict(r) for r in con.execute( | |
| 221 | + "SELECT ts, s_min, s_max, unit FROM salary_log WHERE uid=?" | |
| 222 | + " ORDER BY ts DESC LIMIT 6", (uid,)).fetchall()] | |
| 223 | + con.close() | |
| 224 | + if d is None: | |
| 225 | + raise HTTPException(404, "Offre introuvable") | |
| 226 | + return d | |
| 227 | + | |
| 228 | + | |
| 229 | +@app.get("/api/facets") | |
| 230 | +def facets(city: str | None = None): | |
| 231 | + """Valeurs distinctes pour construire les filtres du frontend.""" | |
| 232 | + con = db.connect() | |
| 233 | + base = " FROM jobs WHERE active=1 AND dup_of IS NULL" | |
| 234 | + out = { | |
| 235 | + "cities": [r["city"] for r in con.execute( | |
| 236 | + f"SELECT DISTINCT city{base} AND city<>'' ORDER BY city")], | |
| 237 | + "categories": [dict(r) for r in con.execute( | |
| 238 | + f"SELECT category, COUNT(*) n{base} AND category<>''" | |
| 239 | + " GROUP BY category ORDER BY n DESC")], | |
| 240 | + "employers": [dict(r) for r in con.execute( | |
| 241 | + f"SELECT employer, COUNT(*) n{base} AND employer<>''" | |
| 242 | + " GROUP BY employer ORDER BY n DESC LIMIT 100")], | |
| 243 | + "work_modes": [r["work_mode"] for r in con.execute( | |
| 244 | + f"SELECT DISTINCT work_mode{base} AND work_mode IS NOT NULL" | |
| 245 | + " ORDER BY work_mode")], | |
| 246 | + "employment_types": [r["employment_type"] for r in con.execute( | |
| 247 | + f"SELECT DISTINCT employment_type{base}" | |
| 248 | + " AND employment_type IS NOT NULL ORDER BY employment_type")], | |
| 249 | + "sources": [dict(r) for r in con.execute( | |
| 250 | + f"SELECT source, COUNT(*) n{base} GROUP BY source ORDER BY n DESC")], | |
| 251 | + } | |
| 252 | + con.close() | |
| 253 | + return out | |
| 254 | + | |
| 255 | + | |
| 256 | +@app.get("/api/sources") | |
| 257 | +def sources(): | |
| 258 | + registry = json.loads(SOURCES_PATH.read_text(encoding="utf-8"))["sources"] | |
| 259 | + con = db.connect() | |
| 260 | + counts = {r["source"]: r["n"] for r in con.execute( | |
| 261 | + "SELECT source, COUNT(*) n FROM jobs WHERE active=1 GROUP BY source")} | |
| 262 | + last = {r["source"]: r["ts"] for r in con.execute( | |
| 263 | + "SELECT source, MAX(ts) ts FROM sync_log WHERE ok=1 GROUP BY source")} | |
| 264 | + con.close() | |
| 265 | + for s in registry: | |
| 266 | + s["active_jobs"] = counts.get(s["id"], 0) | |
| 267 | + s["last_sync"] = last.get(s["id"]) | |
| 268 | + return {"sources": registry} | |
| 269 | + | |
| 270 | + | |
| 271 | +@app.get("/api/stats") | |
| 272 | +def stats(): | |
| 273 | + con = db.connect() | |
| 274 | + row = con.execute( | |
| 275 | + """SELECT COUNT(*) total, | |
| 276 | + COUNT(DISTINCT employer) employers, | |
| 277 | + COUNT(DISTINCT source) sources, | |
| 278 | + SUM(CASE WHEN salary_min IS NOT NULL THEN 1 ELSE 0 END) with_salary, | |
| 279 | + AVG(salary_year_min) avg_salary_year, | |
| 280 | + SUM(CASE WHEN work_mode='teletravail' THEN 1 ELSE 0 END) remote, | |
| 281 | + SUM(CASE WHEN lat IS NOT NULL THEN 1 ELSE 0 END) geocoded | |
| 282 | + FROM jobs WHERE active=1 AND dup_of IS NULL""").fetchone() | |
| 283 | + cities = [dict(r) for r in con.execute( | |
| 284 | + """SELECT city, COUNT(*) n FROM jobs | |
| 285 | + WHERE active=1 AND dup_of IS NULL AND city<>'' | |
| 286 | + GROUP BY city ORDER BY n DESC LIMIT 15""")] | |
| 287 | + categories = [dict(r) for r in con.execute( | |
| 288 | + """SELECT category, COUNT(*) n FROM jobs | |
| 289 | + WHERE active=1 AND dup_of IS NULL AND category<>'' | |
| 290 | + GROUP BY category ORDER BY n DESC""")] | |
| 291 | + log = [dict(r) for r in con.execute( | |
| 292 | + "SELECT * FROM sync_log ORDER BY ts DESC LIMIT 20")] | |
| 293 | + con.close() | |
| 294 | + return {**dict(row), "top_cities": cities, "categories": categories, | |
| 295 | + "recent_syncs": log} | |
| 296 | + | |
| 297 | + | |
| 298 | +@app.post("/api/sync") | |
| 299 | +def trigger_sync(background: BackgroundTasks, source: str | None = None): | |
| 300 | + """Déclenche une synchronisation (équivalent d'un webhook entrant).""" | |
| 301 | + def _job(): | |
| 302 | + with _sync_lock: | |
| 303 | + ingest.run([source] if source else None) | |
| 304 | + background.add_task(_job) | |
| 305 | + return {"status": "démarré", "source": source or "toutes"} | |
| 306 | + | |
| 307 | + | |
| 308 | +# --- Frontend (build Vite) --------------------------------------------------- | |
| 309 | +if FRONTEND_DIST.exists(): | |
| 310 | + app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets") | |
| 311 | + | |
| 312 | + @app.middleware("http") | |
| 313 | + async def _cache_headers(request, call_next): | |
| 314 | + """Bundles hachés (/assets/…) immuables ; index.html TOUJOURS revalidé — | |
| 315 | + sinon les navigateurs gardent une vieille version après un déploiement.""" | |
| 316 | + resp = await call_next(request) | |
| 317 | + path = request.url.path | |
| 318 | + if path.startswith("/assets/"): | |
| 319 | + resp.headers["Cache-Control"] = "public, max-age=31536000, immutable" | |
| 320 | + elif "text/html" in (resp.headers.get("content-type") or ""): | |
| 321 | + resp.headers["Cache-Control"] = "no-cache" | |
| 322 | + return resp | |
| 323 | + | |
| 324 | + # routes rendues uniquement côté client — tout autre chemin inconnu renvoie | |
| 325 | + # index.html avec un statut 404 (pas de soft-404 pour les moteurs) | |
| 326 | + _CLIENT_ROUTES = {"carte", "sources", "stats", "confidentialite", "conditions"} | |
| 327 | + _CLIENT_PREFIXES = ("emploi/",) | |
| 328 | + | |
| 329 | + @app.get("/{full_path:path}") | |
| 330 | + def spa(full_path: str): | |
| 331 | + target = FRONTEND_DIST / full_path | |
| 332 | + if full_path and target.is_file(): | |
| 333 | + return FileResponse(target) | |
| 334 | + known = (full_path == "" or full_path in _CLIENT_ROUTES | |
| 335 | + or any(full_path.startswith(p) for p in _CLIENT_PREFIXES)) | |
| 336 | + return FileResponse(FRONTEND_DIST / "index.html", | |
| 337 | + status_code=200 if known else 404, | |
| 338 | + headers={"Cache-Control": "no-cache"}) | |
added
requirements.txt
+12 −0
@@ -0,0 +1,12 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : requirements.txt | |
| 6 | +# Rôle : Dépendances Python du backend | |
| 7 | +# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 8 | +# ============================================================================= | |
| 9 | +fastapi>=0.110 | |
| 10 | +uvicorn>=0.29 | |
| 11 | +requests>=2.31 | |
| 12 | +beautifulsoup4>=4.12 | |
added
run.py
+65 −0
@@ -0,0 +1,65 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# ============================================================================= | |
| 3 | +# Job·Ka — Groupe KA | |
| 4 | +# Auteur : Simon-Pierre Boucher | |
| 5 | +# Contact : contact@spboucher.ai | |
| 6 | +# Fichier : run.py | |
| 7 | +# Rôle : Point d'entrée — sync, watch, serve, geocode, record | |
| 8 | +# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | +# ============================================================================= | |
| 10 | +"""Utilisation : | |
| 11 | + python run.py sync [source ...] # synchronise les offres | |
| 12 | + python run.py watch [minutes] # synchronise en boucle (défaut 60 min) | |
| 13 | + python run.py serve [port] # démarre l'API + le frontend (défaut 8096) | |
| 14 | + python run.py geocode [n] # géocode les offres sans coordonnées | |
| 15 | + python run.py record <source ...> # enregistre les fixtures de test d'une source | |
| 16 | +""" | |
| 17 | +from __future__ import annotations | |
| 18 | + | |
| 19 | +import os | |
| 20 | +import sys | |
| 21 | +from pathlib import Path | |
| 22 | + | |
| 23 | +# Charger .env (SCRAPFLY_API_KEY, etc.) sans dépendance externe | |
| 24 | +_env = Path(__file__).parent / ".env" | |
| 25 | +if _env.exists(): | |
| 26 | + for line in _env.read_text().splitlines(): | |
| 27 | + line = line.strip() | |
| 28 | + if line and not line.startswith("#") and "=" in line: | |
| 29 | + k, _, v = line.partition("=") | |
| 30 | + os.environ.setdefault(k.strip(), v.strip()) | |
| 31 | + | |
| 32 | + | |
| 33 | +def main() -> None: | |
| 34 | + cmd = sys.argv[1] if len(sys.argv) > 1 else "serve" | |
| 35 | + if cmd == "sync": | |
| 36 | + from jobka import ingest | |
| 37 | + ingest.run(sys.argv[2:] or None) | |
| 38 | + elif cmd == "watch": | |
| 39 | + from jobka import ingest | |
| 40 | + minutes = int(sys.argv[2]) if len(sys.argv) > 2 else 60 | |
| 41 | + ingest.watch(minutes * 60) | |
| 42 | + elif cmd == "geocode": | |
| 43 | + from jobka import geocode | |
| 44 | + limit = int(sys.argv[2]) if len(sys.argv) > 2 else None | |
| 45 | + geocode.run_batch(limit) | |
| 46 | + elif cmd == "record": | |
| 47 | + from jobka import fixtures | |
| 48 | + from jobka.connectors import CONNECTORS | |
| 49 | + targets = sys.argv[2:] or sorted(CONNECTORS) | |
| 50 | + for sid in targets: | |
| 51 | + try: | |
| 52 | + print(f"[job-ka] record {sid} ... {fixtures.record(sid)}") | |
| 53 | + except Exception as exc: | |
| 54 | + print(f"[job-ka] record {sid} ÉCHEC : {exc}", file=sys.stderr) | |
| 55 | + elif cmd == "serve": | |
| 56 | + import uvicorn | |
| 57 | + port = int(sys.argv[2]) if len(sys.argv) > 2 else 8096 | |
| 58 | + uvicorn.run("jobka.web:app", host="0.0.0.0", port=port) | |
| 59 | + else: | |
| 60 | + print(__doc__) | |
| 61 | + sys.exit(1) | |
| 62 | + | |
| 63 | + | |
| 64 | +if __name__ == "__main__": | |
| 65 | + main() | |
added
scripts/check_headers.py
+76 −0
@@ -0,0 +1,76 @@ | ||
| 1 | +#!/usr/bin/env python3 | |
| 2 | +# ============================================================================= | |
| 3 | +# Job·Ka — Groupe KA | |
| 4 | +# Auteur : Simon-Pierre Boucher | |
| 5 | +# Contact : contact@spboucher.ai | |
| 6 | +# Fichier : scripts/check_headers.py | |
| 7 | +# Rôle : Vérification de l'en-tête d'auteur (règle nº 1) — utilisé par le | |
| 8 | +# hook pre-commit : rejette tout fichier stagé sans en-tête | |
| 9 | +# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 10 | +# ============================================================================= | |
| 11 | +"""Vérifie que chaque fichier stagé porte l'en-tête d'auteur du projet. | |
| 12 | + | |
| 13 | +Usage : | |
| 14 | + python3 scripts/check_headers.py <fichier ...> # fichiers explicites | |
| 15 | + python3 scripts/check_headers.py --staged # fichiers stagés (hook) | |
| 16 | +""" | |
| 17 | +from __future__ import annotations | |
| 18 | + | |
| 19 | +import subprocess | |
| 20 | +import sys | |
| 21 | +from pathlib import Path | |
| 22 | + | |
| 23 | +# Extensions soumises à la règle nº 1 (code, scripts, configs, docs, tests) | |
| 24 | +CHECKED_EXTS = {".py", ".ts", ".tsx", ".js", ".jsx", ".css", ".html", ".md", | |
| 25 | + ".sh", ".txt", ".yml", ".yaml", ".toml"} | |
| 26 | +# Fichiers exemptés : données, verrous, artefacts générés | |
| 27 | +EXEMPT_PARTS = {"node_modules", "dist", ".venv", "fixtures", "data", | |
| 28 | + "__pycache__", ".git"} | |
| 29 | +EXEMPT_NAMES = {"package-lock.json", "LICENSE"} | |
| 30 | + | |
| 31 | +REQUIRED = ("Job·Ka — Groupe KA", "Simon-Pierre Boucher", "contact@spboucher.ai") | |
| 32 | + | |
| 33 | + | |
| 34 | +def needs_header(path: Path) -> bool: | |
| 35 | + if path.name in EXEMPT_NAMES: | |
| 36 | + return False | |
| 37 | + if set(path.parts) & EXEMPT_PARTS: | |
| 38 | + return False | |
| 39 | + return path.suffix.lower() in CHECKED_EXTS | |
| 40 | + | |
| 41 | + | |
| 42 | +def has_header(path: Path) -> bool: | |
| 43 | + try: | |
| 44 | + head = path.read_text(encoding="utf-8", errors="ignore")[:1500] | |
| 45 | + except OSError: | |
| 46 | + return False | |
| 47 | + return all(tok in head for tok in REQUIRED) | |
| 48 | + | |
| 49 | + | |
| 50 | +def staged_files() -> list[Path]: | |
| 51 | + out = subprocess.run( | |
| 52 | + ["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"], | |
| 53 | + capture_output=True, text=True, check=True).stdout | |
| 54 | + return [Path(l) for l in out.splitlines() if l.strip()] | |
| 55 | + | |
| 56 | + | |
| 57 | +def main() -> int: | |
| 58 | + if len(sys.argv) > 1 and sys.argv[1] == "--staged": | |
| 59 | + targets = staged_files() | |
| 60 | + else: | |
| 61 | + targets = [Path(a) for a in sys.argv[1:]] | |
| 62 | + bad = [p for p in targets | |
| 63 | + if p.exists() and needs_header(p) and not has_header(p)] | |
| 64 | + if bad: | |
| 65 | + print("✗ En-tête d'auteur manquant (règle nº 1 du CLAUDE.md) :", | |
| 66 | + file=sys.stderr) | |
| 67 | + for p in bad: | |
| 68 | + print(f" {p}", file=sys.stderr) | |
| 69 | + print(" -> ajouter le bloc « Job·Ka — Groupe KA / Auteur : " | |
| 70 | + "Simon-Pierre Boucher / contact@spboucher.ai »", file=sys.stderr) | |
| 71 | + return 1 | |
| 72 | + return 0 | |
| 73 | + | |
| 74 | + | |
| 75 | +if __name__ == "__main__": | |
| 76 | + sys.exit(main()) | |
added
tests/conftest.py
+12 −0
@@ -0,0 +1,12 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : tests/conftest.py | |
| 6 | +# Rôle : Configuration pytest — racine du dépôt dans sys.path | |
| 7 | +# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 8 | +# ============================================================================= | |
| 9 | +import sys | |
| 10 | +from pathlib import Path | |
| 11 | + | |
| 12 | +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) | |
added
tests/fixtures/mistplay/b53675f2801b6632ebba.json
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +[{"additionalPlain":"Why Mistplay?\n \nWe strive to make our work environment as inviting and fun as possible! Working at Mistplay is coupled with a whole array of perks that we've adopted virtually and in-person: Team Lunches, game nights, company-wide events, and so much more. Our culture is deeply rooted in growth and upheld by a team of smart, dynamic, and enthusiastic people. We utilize data to constantly learn, improve, and adapt. We foster an environment where everyone is encouraged to share their ideas, push boundaries, take calculated risks, and witness their visions come to life.\n","additional":"<div>\n<div><strong>Why Mistplay?</strong></div>\n<div> </div>\n<div>We strive to make our work environment as inviting and fun as possible! Working at Mistplay is coupled with a whole array of perks that we've adopted virtually and in-person: Team Lunches, game nights, company-wide events, and so much more. Our culture is deeply rooted in growth and upheld by a team of smart, dynamic, and enthusiastic people. We utilize data to constantly learn, improve, and adapt. We foster an environment where everyone is encouraged to share their ideas, push boundaries, take calculated risks, and witness their visions come to life.</div>\n</div>","categories":{"commitment":"Temps Plein / Full Time","department":"APAC","location":"Seoul","team":"South Korea","allLocations":["Seoul"]},"createdAt":1783444832279,"descriptionPlain":"Mistplay is the category creator and leader in the 'play-and-earn' mobile loyalty space, built to reward gamers for playing.\nLoyaltyPlay is our B2B monetization platform, a powerful Gaming Reward Hub designed for mobile app Publishers. We partner with app owners (e.g., utility, commerce, or entertainment apps) to help them unlock a new, consistent revenue stream and boost user engagement. By integrating LoyaltyPlay, publishers can offer their users rewards (in-app currency) for installing and playing mobile games from our vast advertiser network. This creates a deep engagement loop and a powerful monetization channel for the publisher.\n\n\nWe are seeking a dynamic and result-oriented Business Developer to join our team. The successful candidate will be responsible for identifying new business opportunities, building and maintaining client relationships, and contributing to the overall growth and success of the company.\nWhat will you do?\nIdentify and develop new business opportunities through networking, market research, and lead generation\nBuild and maintain strong relationships with clients and stakeholders\nDevelop and deliver presentations and proposals to potential clients\nCollaborate with the marketing and product teams to align business strategies\nTrack and report on business development activities and results\nNegotiate contracts and close deals to achieve sales targets\nWhat you'll bring \n2+ years of experience in Business Development, specifically in establishing and managing partnerships within offerwall rewarded industry\nStrong negotiation and deal-closing abilities, with a track record of successfully securing and managing partnerships.\nAbility to work cross functionally with Marketing, Product and Ops teams\nProven experience in business development or sales\nExcellent verbal and written communication skills in English and Korean.\nExcellent communicator and creative thinker, with an ability to use data to inform all decisions\nVery fast learner (and addicted to learn), reliable person with unstoppable can-do-attitude\nStrong analytical and problem-solving abilities\n \n","description":"<div>\n<p>Mistplay is the category creator and leader in the 'play-and-earn' mobile loyalty space, built to reward gamers for playing.</p>\n<p>LoyaltyPlay is our B2B monetization platform, a powerful Gaming Reward Hub designed for mobile app Publishers. We partner with app owners (e.g., utility, commerce, or entertainment apps) to help them unlock a new, consistent revenue stream and boost user engagement. By integrating LoyaltyPlay, publishers can offer their users rewards (in-app currency) for installing and playing mobile games from our vast advertiser network. This creates a deep engagement loop and a powerful monetization channel for the publisher.</p>\n</div><div><br></div><p>We are seeking a dynamic and result-oriented Business Developer to join our team. The successful candidate will be responsible for identifying new business opportunities, building and maintaining client relationships, and contributing to the overall growth and success of the company.</p>\n<h3><strong>What will you do?</strong></h3>\n<ul style=\"list-style-type: disc;\">\n<li>\n<p>Identify and develop new business opportunities through networking, market research, and lead generation</p>\n</li>\n<li>\n<p>Build and maintain strong relationships with clients and stakeholders</p>\n</li>\n<li>\n<p>Develop and deliver presentations and proposals to potential clients</p>\n</li>\n<li>\n<p>Collaborate with the marketing and product teams to align business strategies</p>\n</li>\n<li>\n<p>Track and report on business development activities and results</p>\n</li>\n<li>\n<p>Negotiate contracts and close deals to achieve sales targets</p>\n</li>\n</ul>\n<h3><strong>What you'll bring </strong></h3>\n<ul style=\"list-style-type: disc;\">\n<li>\n<p>2+ years of experience in Business Development, specifically in establishing and managing partnerships within offerwall rewarded industry</p>\n</li>\n<li>\n<p>Strong negotiation and deal-closing abilities, with a track record of successfully securing and managing partnerships.</p>\n</li>\n<li>\n<p>Ability to work cross functionally with Marketing, Product and Ops teams</p>\n</li>\n<li>\n<p>Proven experience in business development or sales</p>\n</li>\n<li>\n<p>Excellent verbal and written communication skills in English and Korean.</p>\n</li>\n<li>\n<p>Excellent communicator and creative thinker, with an ability to use data to inform all decisions</p>\n</li>\n<li>\n<p>Very fast learner (and addicted to learn), reliable person with unstoppable can-do-attitude</p>\n</li>\n<li>\n<p>Strong analytical and problem-solving abilities</p>\n</li>\n</ul>\n<p> </p>","id":"1f2e3029-4089-453a-a09d-fa9dcf7b79b8","lists":[],"text":"Business Development Manager, Korean speaker","country":"KR","workplaceType":"remote","opening":"<div>\n<p>Mistplay is the category creator and leader in the 'play-and-earn' mobile loyalty space, built to reward gamers for playing.</p>\n<p>LoyaltyPlay is our B2B monetization platform, a powerful Gaming Reward Hub designed for mobile app Publishers. We partner with app owners (e.g., utility, commerce, or entertainment apps) to help them unlock a new, consistent revenue stream and boost user engagement. By integrating LoyaltyPlay, publishers can offer their users rewards (in-app currency) for installing and playing mobile games from our vast advertiser network. This creates a deep engagement loop and a powerful monetization channel for the publisher.</p>\n</div>","openingPlain":"Mistplay is the category creator and leader in the 'play-and-earn' mobile loyalty space, built to reward gamers for playing.\nLoyaltyPlay is our B2B monetization platform, a powerful Gaming Reward Hub designed for mobile app Publishers. We partner with app owners (e.g., utility, commerce, or entertainment apps) to help them unlock a new, consistent revenue stream and boost user engagement. By integrating LoyaltyPlay, publishers can offer their users rewards (in-app currency) for installing and playing mobile games from our vast advertiser network. This creates a deep engagement loop and a powerful monetization channel for the publisher.\n","descriptionBody":"<p>We are seeking a dynamic and result-oriented Business Developer to join our team. The successful candidate will be responsible for identifying new business opportunities, building and maintaining client relationships, and contributing to the overall growth and success of the company.</p>\n<h3><strong>What will you do?</strong></h3>\n<ul style=\"list-style-type: disc;\">\n<li>\n<p>Identify and develop new business opportunities through networking, market research, and lead generation</p>\n</li>\n<li>\n<p>Build and maintain strong relationships with clients and stakeholders</p>\n</li>\n<li>\n<p>Develop and deliver presentations and proposals to potential clients</p>\n</li>\n<li>\n<p>Collaborate with the marketing and product teams to align business strategies</p>\n</li>\n<li>\n<p>Track and report on business development activities and results</p>\n</li>\n<li>\n<p>Negotiate contracts and close deals to achieve sales targets</p>\n</li>\n</ul>\n<h3><strong>What you'll bring </strong></h3>\n<ul style=\"list-style-type: disc;\">\n<li>\n<p>2+ years of experience in Business Development, specifically in establishing and managing partnerships within offerwall rewarded industry</p>\n</li>\n<li>\n<p>Strong negotiation and deal-closing abilities, with a track record of successfully securing and managing partnerships.</p>\n</li>\n<li>\n<p>Ability to work cross functionally with Marketing, Product and Ops teams</p>\n</li>\n<li>\n<p>Proven experience in business development or sales</p>\n</li>\n<li>\n<p>Excellent verbal and written communication skills in English and Korean.</p>\n</li>\n<li>\n<p>Excellent communicator and creative thinker, with an ability to use data to inform all decisions</p>\n</li>\n<li>\n<p>Very fast learner (and addicted to learn), reliable person with unstoppable can-do-attitude</p>\n</li>\n<li>\n<p>Strong analytical and problem-solving abilities</p>\n</li>\n</ul>\n<p> </p>","descriptionBodyPlain":"We are seeking a dynamic and result-oriented Business Developer to join our team. The successful candidate will be responsible for identifying new business opportunities, building and maintaining client relationships, and contributing to the overall growth and success of the company.\nWhat will you do?\nIdentify and develop new business opportunities through networking, market research, and lead generation\nBuild and maintain strong relationships with clients and stakeholders\nDevelop and deliver presentations and proposals to potential clients\nCollaborate with the marketing and product teams to align business strategies\nTrack and report on business development activities and results\nNegotiate contracts and close deals to achieve sales targets\nWhat you'll bring \n2+ years of experience in Business Development, specifically in establishing and managing partnerships within offerwall rewarded industry\nStrong negotiation and deal-closing abilities, with a track record of successfully securing and managing partnerships.\nAbility to work cross functionally with Marketing, Product and Ops teams\nProven experience in business development or sales\nExcellent verbal and written communication skills in English and Korean.\nExcellent communicator and creative thinker, with an ability to use data to inform all decisions\nVery fast learner (and addicted to learn), reliable person with unstoppable can-do-attitude\nStrong analytical and problem-solving abilities\n \n","hostedUrl":"https://jobs.lever.co/mistplay/1f2e3029-4089-453a-a09d-fa9dcf7b79b8","applyUrl":"https://jobs.lever.co/mistplay/1f2e3029-4089-453a-a09d-fa9dcf7b79b8/apply"},{"additionalPlain":"*Nous remercions tous(tes) les candidat(e)s. Le genre masculin a été utilisé dans le but d'alléger le texte. Nous souscrivons au principe de l’équité en matière d’emploi.\n\nPourquoi choisir Mistplay ?\n\nNous faisons tout pour rendre notre environnement de travail aussi accueillant et plaisant que possible ! Un poste chez Mistplay s’accompagne de toute une série d'avantages que nous proposons en mode virtuel ou présentiel : déjeuners d'équipe, soirées jeux, événements à l'échelle de l'entreprise, et bien plus encore.\nNotre culture est profondément ancrée dans la croissance et soutenue par une équipe de personnes intelligentes, dynamiques et enthousiastes. Nous utilisons les données pour apprendre, améliorer et adapter en permanence. Nous favorisons un environnement dans lequel chacun est encouragé à partager ses idées, à repousser les limites, à prendre des risques calculés et à voir ses visions se concrétiser.\n\nWhy Mistplay?\n\nWe strive to make our work environment as inviting and fun as possible! Working at Mistplay is coupled with a whole array of perks that we've adopted virtually and in-person: Team Lunches, game nights, company-wide events, and so much more. Our culture is deeply rooted in growth and upheld by a team of smart, dynamic, and enthusiastic people. We utilize data to constantly learn, improve, and adapt. We foster an environment where everyone is encouraged to share their ideas, push boundaries, take calculated risks, and witness their visions come to life.\n","additional":"<div><i style=\"font-size: 16px\">*Nous remercions tous(tes) les candidat(e)s. Le genre masculin a été utilisé dans le but d'alléger le texte. Nous souscrivons au principe de l’équité en matière d’emploi.</i></div><div><br></div><div><b style=\"font-size: 11pt\">Pourquoi choisir Mistplay ?</b></div><div><br></div><div><span style=\"font-size: 11pt\">Nous faisons tout pour rendre notre environnement de travail aussi accueillant et plaisant que possible ! Un poste chez Mistplay s’accompagne de toute une série d'avantages que nous proposons en mode virtuel ou présentiel : déjeuners d'équipe, soirées jeux, événements à l'échelle de l'entreprise, et bien plus encore.</span></div><div><span style=\"font-size: 11pt\">Notre culture est profondément ancrée dans la croissance et soutenue par une équipe de personnes intelligentes, dynamiques et enthousiastes. Nous utilisons les données pour apprendre, améliorer et adapter en permanence. Nous favorisons un environnement dans lequel chacun est encouragé à partager ses idées, à repousser les limites, à prendre des risques calculés et à voir ses visions se concrétiser.</span></div><div><br></div><div><b style=\"font-size: 11pt\">Why Mistplay?</b></div><div><br></div><div><span style=\"font-size: 11pt\">We strive to make our work environment as inviting and fun as possible! Working at Mistplay is coupled with a whole array of perks that we've adopted virtually and in-person: Team Lunches, game nights, company-wide events, and so much more.</span> <span style=\"font-size: 11pt\">Our culture is deeply rooted in growth and upheld by a team of smart, dynamic, and enthusiastic people. We utilize data to constantly learn, improve, and adapt. We foster an environment where everyone is encouraged to share their ideas, push boundaries, take calculated risks, and witness their visions come to life.</span></div>","categories":{"commitment":"Temps Plein / Full Time","department":"Produit / Product","location":"Montreal","team":"Support","allLocations":["Montreal","Toronto"]},"createdAt":1785264776344,"descriptionPlain":"Mistplay est l'application de fidélité n°1 pour les joueurs mobiles. Notre communauté de millions de joueurs mobiles engagés utilise Mistplay pour découvrir de nouveaux jeux et gagner des récompenses. Les joueurs sont récompensés pour le temps et l'argent qu'ils consacrent aux jeux et peuvent échanger ces récompenses contre des cartes cadeaux. Mistplay a pour mission d'être le meilleur moyen de jouer à des jeux mobiles pour tous, partout dans le monde ! Téléchargez Mistplay sur le Google Play Store ici et suivez-nous sur Instagram, Twitter et Facebook.\n\n📍 Veuillez noter : Au Canada 🇨🇦, Mistplay suit un modèle hybride de 2 jours/semaine en bureau à Toronto (400 University Ave) & Montréal (1001 Blvd. Robert-Bourassa) \n\nMistplay is the #1 loyalty app for mobile gamers. Our community of millions of engaged mobile gamers come to Mistplay to discover new games to play and earn rewards. Gamers are rewarded for their time and money spent within the games and can redeem those rewards for gift cards. Mistplay is on a mission to be the best way to play mobile games for everyone everywhere! Download Mistplay on the Google Play Store here and follow us on Instagram, Twitter and Facebook.\n\n📍 Please Note: In Canada 🇨🇦, Mistplay follows a 2 days/week in-office hybrid model in Toronto (400 University Ave) & Montreal (1001 Blvd. Robert-Bourassa) \n\n\nRejoins l'équipe d'assistance de Mistplay en tant que Spécialiste de l'Expérience Client ! Notre objectif principal est de satisfaire les besoins et les désirs de nos utilisateurs, et nous nous efforçons constamment de dépasser leurs attentes. Si tu adores élaborer des stratégies de communication et dialoguer avec les gens, alors ce poste est fait pour toi !\nTes missions chez Mistplay:\n\nAssurer l'assistance quotidienne aux utilisateurs de Mistplay par le biais d'un système de gestion des tickets par courriel (Zendesk).\nApporter une assistance personnalisée aux utilisateurs tout en assurant des réponses rapides qui s'alignent sur les KPI cibles de notre équipe.\nRépondre aux commentaires formulés sur le Google Play Store et sur les réseaux sociaux afin de garantir la satisfaction des utilisateurs et d'obtenir des notes élevées.\nProduire des rapports hebdomadaires sur les tickets, les avis des utilisateurs et leurs opinions sur les réseaux sociaux.\nRésoudre les problèmes techniques et étudier les profils des joueurs à l'aide d'outils Back-end.\nSe tenir au fait des procédures d'assistance les plus récentes, des problèmes dynamiques et des mises à jour de produits.\nFaire remonter à l'équipe de développement les problèmes récemment signalés et les demandes de feedback des utilisateurs.\nRédiger et assurer le suivi des rapports sur les bogues techniques relatifs aux problèmes récurrents.\nAgir en tant que représentant sur des projets produits pointus et fournir une visibilité à l'équipe d'assistance.\nDéfendre fermement les intérêts des joueurs.\n\nProfil recherché:\nTu possèdes de 1 à 3 ans d'expérience dans le domaine des applications, des jeux mobiles ou d'autres environnements d'assistance similaires.\nTu sais résoudre les problèmes et interpréter les données de manière intuitive.\nTu as la capacité d'utiliser avec assurance divers outils et plateformes en parallèle.\nTu es autonome, motivée et capable de respecter les délais.\nTu possèdes de réelles aptitudes relationnelles et tu sais faire preuve d'empathie dans tes rapports avec les autres.\nTu as de l'expérience en matière de rédaction technique et de production de rapports.\nTu possèdes de bonnes connaissances en informatique.\nTu as de solides compétences en communication.\nTu maîtrises parfaitement l'anglais et le français à l'écrit puisque tu seras en contact avec des clients anglophones.\nLa connaissance d'autres langues, telles que l'allemand ou le coréen, est un plus.\nUne expérience de Zendesk ou d'Amazon DynamoDB est un atout supplémentaire.\nPour usage interne seulement, ce poste est de niveau P1.\nCe que nous t’offrons :\n• Régime d'assurance généreux ;\n• 4 semaines de vacances par an ;\n• 1 semaine de bien-être chaque année (fermeture de l'entreprise) ;\n• Jours de congé individuels/pour maladie ;\n• Contribution employeur au REER collectif à hauteur de 100 % (dans la limite de 5% par an) ;\n• Allocation annuelle pour le fitness/la remise en forme ;\n• Allocation mensuelle pour l'utilisation des transports en commun ou pour la connexion Internet chez vous ;\n• Allocation pour l'apprentissage et le développement.\n\nLes avantages ci-dessus sont spécifiques aux employés canadiens. Des offres équivalentes sont disponibles pour les employés d'autres pays.\nPenses-tu que ce poste et cette équipe pourraient être faits pour toi ? Postule dès maintenant pour démarrer ta prochaine aventure professionnelle !\nCustomer Support Specialist \nJoin Mistplay's Support Team as our next Customer Support Specialist ! Our main goal is to satisfy the needs and wants of our users, and we constantly strive to exceed their expectations. If you enjoy strategizing communication and engaging with people, then this is the job for you! \nWhat you’ll do at Misplay\nProvide daily support to Mistplay’s users through an email ticketing system (Zendesk)\nProvide personalized support to users while ensuring timely responses that align with our team’s target KPIs\nReply to Google Play Store reviews and social media comments to ensure user satisfaction and high ratings\nCreate weekly reports on tickets, reviews, and social sentiment from users\nTroubleshoot technical issues and investigate player profiles with back-end tools\nStay up to date on our latest support procedures, dynamic issues, and product updates\nEscalate newly reported issues and user feedback requests to the development team \nBe able to solve 100+ tickets per day\nCreate and track technical bug reports regarding recurring issues\nAct as a representative on specialized product projects and provide visibility to the Support Team\nBe a strong player advocate\n \nWhat you’ll bring to Mistplay\n1-3 years of experience in applications, mobile games, or similar support environments\nIntuitive problem solving and interpreting data\nAbility to use various tools and platforms in parallel confidently \nSelf-motivated autonomous, and able to meet deadlines\nEffective people skills and empathy when dealing with others\nExperience with technical writing and reporting\nProficient computer skills\nStrong communication skills\nHigh proficiency in written English\nSkill with other languages such as French, German, Korean + Japanese are a plus\nExperience with Zendesk or Amazon DynamoDB is an added advantage\nKnowledge of application development is an added advantage\nFor internal use only, this is a P1 level position.\n \nWhy Mistplay?\nWe strive to make our work environment as inviting and fun as possible! Working at Mistplay is coupled with a whole array of perks that we've adopted virtually and in-person: Team Lunches, game nights, company-wide events, and so much more.\nOur culture is deeply rooted in growth and upheld by a team of smart, dynamic, and enthusiastic people. We utilize data to constantly learn, improve, and adapt. We foster an environment where everyone is encouraged to share their ideas, push boundaries, take calculated risks, and witness their visions come to life.\nWhat we Offer\nGenerous Insurance Plan\n4 weeks of vacation per year\nPersonal/Sick days\nRRSP with 100% Employer Matching\nAnnual Fitness Allocation\nMonthly Public Transit or Home Internet Allocation\nLearning & Development Allocation\nBi-Annual Performance & Salary Reviews\nThe above benefits are specific to Canadian employees. Equivalent offerings available for employees in other locations.\nDo you feel like this could be the role and team for you? Apply now to start your next professional adventure!\n","description":"<div><span style=\"font-size: 15px\">Mistplay est l'application de fidélité n°1 pour les joueurs mobiles. Notre communauté de millions de joueurs mobiles engagés utilise Mistplay pour découvrir de nouveaux jeux et gagner des récompenses. Les joueurs sont récompensés pour le temps et l'argent qu'ils consacrent aux jeux et peuvent échanger ces récompenses contre des cartes cadeaux. Mistplay a pour mission d'être le meilleur moyen de jouer à des jeux mobiles pour tous, partout dans le monde ! Téléchargez Mistplay sur le Google Play Store </span><a style=\"font-size: 15px\" href=\"https://play.google.com/store/apps/details?id=com.mistplay.mistplay&hl=en_CA&gl=US\">ici</a><span style=\"font-size: 15px\"> et suivez-nous sur </span><a style=\"font-size: 15px\" href=\"https://www.instagram.com/mistplay/?hl=en\">Instagram</a><span style=\"font-size: 15px\">, </span><a style=\"font-size: 15px\" href=\"https://twitter.com/mistplayapp?ref_src=twsrc%5Egoogle%7Ctwcamp%5Eserp%7Ctwgr%5Eauthor\">Twitter</a><span style=\"font-size: 15px\"> et </span><a style=\"font-size: 15px\" href=\"https://www.facebook.com/mistplayapp/\">Facebook</a><span style=\"font-size: 15px\">.</span></div><div><br></div><div>📍 <b><u>Veuillez noter :</u></b> Au Canada 🇨🇦, Mistplay suit un modèle hybride de 2 jours/semaine en bureau à Toronto (400 University Ave) & Montréal (1001 Blvd. Robert-Bourassa) </div><div><br></div><div><span style=\"font-size: 15px\">Mistplay is the #1 loyalty app for mobile gamers. Our community of millions of engaged mobile gamers come to Mistplay to discover new games to play and earn rewards. Gamers are rewarded for their time and money spent within the games and can redeem those rewards for gift cards. Mistplay is on a mission to be the best way to play mobile games for everyone everywhere! Download Mistplay on the Google Play Store </span><a style=\"font-size: 15px\" href=\"https://play.google.com/store/apps/details?id=com.mistplay.mistplay&hl=en_CA&gl=US\">here</a><span style=\"font-size: 15px\"> and follow us on </span><a style=\"font-size: 15px\" href=\"https://www.instagram.com/mistplay/?hl=en\">Instagram</a><span style=\"font-size: 15px\">, </span><a style=\"font-size: 15px\" href=\"https://twitter.com/mistplayapp?ref_src=twsrc%5Egoogle%7Ctwcamp%5Eserp%7Ctwgr%5Eauthor\">Twitter</a><span style=\"font-size: 15px\"> and </span><a style=\"font-size: 15px\" href=\"https://www.facebook.com/mistplayapp/\">Facebook</a><span style=\"font-size: 15px\">.</span></div><div><br></div><div><b>📍 <u>Please Note:</u></b> In Canada 🇨🇦, Mistplay follows a 2 days/week in-office hybrid model in Toronto (400 University Ave) & Montreal (1001 Blvd. Robert-Bourassa) </div><div><br></div><div>\n<p>Rejoins l'équipe d'assistance de Mistplay en tant que Spécialiste de l'Expérience Client ! Notre objectif principal est de satisfaire les besoins et les désirs de nos utilisateurs, et nous nous efforçons constamment de dépasser leurs attentes. Si tu adores élaborer des stratégies de communication et dialoguer avec les gens, alors ce poste est fait pour toi !</p>\n<p><strong>Tes missions chez Mistplay:</strong><strong><br><br></strong></p>\n<ul>\n<li>\n<p>Assurer l'assistance quotidienne aux utilisateurs de Mistplay par le biais d'un système de gestion des tickets par courriel (Zendesk).</p>\n</li>\n<li>\n<p>Apporter une assistance personnalisée aux utilisateurs tout en assurant des réponses rapides qui s'alignent sur les KPI cibles de notre équipe.</p>\n</li>\n<li>\n<p>Répondre aux commentaires formulés sur le Google Play Store et sur les réseaux sociaux afin de garantir la satisfaction des utilisateurs et d'obtenir des notes élevées.</p>\n</li>\n<li>\n<p>Produire des rapports hebdomadaires sur les tickets, les avis des utilisateurs et leurs opinions sur les réseaux sociaux.</p>\n</li>\n<li>\n<p>Résoudre les problèmes techniques et étudier les profils des joueurs à l'aide d'outils Back-end.</p>\n</li>\n<li>\n<p>Se tenir au fait des procédures d'assistance les plus récentes, des problèmes dynamiques et des mises à jour de produits.</p>\n</li>\n<li>\n<p>Faire remonter à l'équipe de développement les problèmes récemment signalés et les demandes de feedback des utilisateurs.</p>\n</li>\n<li>\n<p>Rédiger et assurer le suivi des rapports sur les bogues techniques relatifs aux problèmes récurrents.</p>\n</li>\n<li>\n<p>Agir en tant que représentant sur des projets produits pointus et fournir une visibilité à l'équipe d'assistance.</p>\n</li>\n<li>\n<p>Défendre fermement les intérêts des joueurs.</p>\n</li>\n</ul>\n<p><br><strong>Profil recherché:</strong></p>\n<ul>\n<li>\n<p>Tu possèdes de 1 à 3 ans d'expérience dans le domaine des applications, des jeux mobiles ou d'autres environnements d'assistance similaires.</p>\n</li>\n<li>\n<p>Tu sais résoudre les problèmes et interpréter les données de manière intuitive.</p>\n</li>\n<li>\n<p>Tu as la capacité d'utiliser avec assurance divers outils et plateformes en parallèle.</p>\n</li>\n<li>\n<p>Tu es autonome, motivée et capable de respecter les délais.</p>\n</li>\n<li>\n<p>Tu possèdes de réelles aptitudes relationnelles et tu sais faire preuve d'empathie dans tes rapports avec les autres.</p>\n</li>\n<li>\n<p>Tu as de l'expérience en matière de rédaction technique et de production de rapports.</p>\n</li>\n<li>\n<p>Tu possèdes de bonnes connaissances en informatique.</p>\n</li>\n<li>\n<p>Tu as de solides compétences en communication.</p>\n</li>\n<li>\n<p>Tu maîtrises parfaitement l'anglais et le français à l'écrit puisque tu seras en contact avec des clients anglophones.</p>\n</li>\n<li>\n<p>La connaissance d'autres langues, telles que l'allemand ou le coréen, est un plus.</p>\n</li>\n<li>\n<p>Une expérience de Zendesk ou d'Amazon DynamoDB est un atout supplémentaire.</p>\n</li>\n<li>\n<p>Pour usage interne seulement, ce poste est de niveau P1.</p>\n</li>\n</ul>\n<p><strong>Ce que nous t’offrons :</strong></p>\n<p>• Régime d'assurance généreux ;</p>\n<p>• 4 semaines de vacances par an ;</p>\n<p>• 1 semaine de bien-être chaque année (fermeture de l'entreprise) ;</p>\n<p>• Jours de congé individuels/pour maladie ;</p>\n<p>• Contribution employeur au REER collectif à hauteur de 100 % (dans la limite de 5% par an) ;</p>\n<p>• Allocation annuelle pour le fitness/la remise en forme ;</p>\n<p>• Allocation mensuelle pour l'utilisation des transports en commun ou pour la connexion Internet chez vous ;</p>\n<p>• Allocation pour l'apprentissage et le développement.<br><br>Les avantages ci-dessus sont spécifiques aux employés canadiens. Des offres équivalentes sont disponibles pour les employés d'autres pays.</p>\n<p>Penses-tu que ce poste et cette équipe pourraient être faits pour toi ? Postule dès maintenant pour démarrer ta prochaine aventure professionnelle !</p>\n<h3 style=\"text-align: center;\"><strong>Customer Support Specialist </strong></h3>\n<p>Join Mistplay's Support Team as our next <strong>Customer Support Specialist</strong> ! Our main goal is to satisfy the needs and wants of our users, and we constantly strive to exceed their expectations. If you enjoy strategizing communication and engaging with people, then this is the job for you! </p>\n<p><strong>What you’ll do at Misplay</strong></p>\n<ul>\n<li>\n<p>Provide daily support to Mistplay’s users through an email ticketing system (Zendesk)</p>\n</li>\n<li>\n<p>Provide personalized support to users while ensuring timely responses that align with our team’s target KPIs</p>\n</li>\n<li>\n<p>Reply to Google Play Store reviews and social media comments to ensure user satisfaction and high ratings</p>\n</li>\n<li>\n<p>Create weekly reports on tickets, reviews, and social sentiment from users</p>\n</li>\n<li>\n<p>Troubleshoot technical issues and investigate player profiles with back-end tools</p>\n</li>\n<li>\n<p>Stay up to date on our latest support procedures, dynamic issues, and product updates</p>\n</li>\n<li>\n<p>Escalate newly reported issues and user feedback requests to the development team </p>\n</li>\n<li>\n<p>Be able to solve 100+ tickets per day</p>\n</li>\n<li>\n<p>Create and track technical bug reports regarding recurring issues</p>\n</li>\n<li>\n<p>Act as a representative on specialized product projects and provide visibility to the Support Team</p>\n</li>\n<li>\n<p>Be a strong player advocate</p>\n</li>\n</ul>\n<p> </p>\n<p><strong>What you’ll bring to Mistplay</strong></p>\n<ul>\n<li>\n<p>1-3 years of experience in applications, mobile games, or similar support environments</p>\n</li>\n<li>\n<p>Intuitive problem solving and interpreting data</p>\n</li>\n<li>\n<p>Ability to use various tools and platforms in parallel confidently </p>\n</li>\n<li>\n<p>Self-motivated autonomous, and able to meet deadlines</p>\n</li>\n<li>\n<p>Effective people skills and empathy when dealing with others</p>\n</li>\n<li>\n<p>Experience with technical writing and reporting</p>\n</li>\n<li>\n<p>Proficient computer skills</p>\n</li>\n<li>\n<p>Strong communication skills</p>\n</li>\n<li>\n<p>High proficiency in written English</p>\n</li>\n<li>\n<p>Skill with other languages such as French, German, Korean + Japanese are a plus</p>\n</li>\n<li>\n<p>Experience with Zendesk or Amazon DynamoDB is an added advantage</p>\n</li>\n<li>\n<p>Knowledge of application development is an added advantage</p>\n</li>\n<li>\n<p>For internal use only, this is a P1 level position.</p>\n</li>\n</ul>\n<p> </p>\n<p><strong>Why Mistplay?</strong></p>\n<p>We strive to make our work environment as inviting and fun as possible! Working at Mistplay is coupled with a whole array of perks that we've adopted virtually and in-person: Team Lunches, game nights, company-wide events, and so much more.</p>\n<p>Our culture is deeply rooted in growth and upheld by a team of smart, dynamic, and enthusiastic people. We utilize data to constantly learn, improve, and adapt. We foster an environment where everyone is encouraged to share their ideas, push boundaries, take calculated risks, and witness their visions come to life.</p>\n<p><strong>What we Offer</strong></p>\n<ul>\n<li>\n<p>Generous Insurance Plan</p>\n</li>\n<li>\n<p>4 weeks of vacation per year</p>\n</li>\n<li>\n<p>Personal/Sick days</p>\n</li>\n<li>\n<p>RRSP with 100% Employer Matching</p>\n</li>\n<li>\n<p>Annual Fitness Allocation</p>\n</li>\n<li>\n<p>Monthly Public Transit or Home Internet Allocation</p>\n</li>\n<li>\n<p>Learning & Development Allocation</p>\n</li>\n<li>\n<p>Bi-Annual Performance & Salary Reviews</p>\n</li>\n</ul>\n<p>The above benefits are specific to Canadian employees. Equivalent offerings available for employees in other locations.</p>\n<p>Do you feel like this could be the role and team for you? Apply now to start your next professional adventure!</p>\n</div>","id":"50f40ccd-a45a-4290-919f-1295a08207da","lists":[],"text":"Customer Support Specialist // Spécialiste de l'Expérience Client","country":"CA","workplaceType":"hybrid","opening":"<div><span style=\"font-size: 15px\">Mistplay est l'application de fidélité n°1 pour les joueurs mobiles. Notre communauté de millions de joueurs mobiles engagés utilise Mistplay pour découvrir de nouveaux jeux et gagner des récompenses. Les joueurs sont récompensés pour le temps et l'argent qu'ils consacrent aux jeux et peuvent échanger ces récompenses contre des cartes cadeaux. Mistplay a pour mission d'être le meilleur moyen de jouer à des jeux mobiles pour tous, partout dans le monde ! Téléchargez Mistplay sur le Google Play Store </span><a href=\"https://play.google.com/store/apps/details?id=com.mistplay.mistplay&hl=en_CA&gl=US\" style=\"font-size: 15px\">ici</a><span style=\"font-size: 15px\"> et suivez-nous sur </span><a href=\"https://www.instagram.com/mistplay/?hl=en\" style=\"font-size: 15px\">Instagram</a><span style=\"font-size: 15px\">, </span><a href=\"https://twitter.com/mistplayapp?ref_src=twsrc%5Egoogle%7Ctwcamp%5Eserp%7Ctwgr%5Eauthor\" style=\"font-size: 15px\">Twitter</a><span style=\"font-size: 15px\"> et </span><a href=\"https://www.facebook.com/mistplayapp/\" style=\"font-size: 15px\">Facebook</a><span style=\"font-size: 15px\">.</span></div><div><br></div><div>📍 <b><u>Veuillez noter :</u></b> Au Canada 🇨🇦, Mistplay suit un modèle hybride de 2 jours/semaine en bureau à Toronto (400 University Ave) & Montréal (1001 Blvd. Robert-Bourassa) </div><div><br></div><div><span style=\"font-size: 15px\">Mistplay is the #1 loyalty app for mobile gamers. Our community of millions of engaged mobile gamers come to Mistplay to discover new games to play and earn rewards. Gamers are rewarded for their time and money spent within the games and can redeem those rewards for gift cards. Mistplay is on a mission to be the best way to play mobile games for everyone everywhere! Download Mistplay on the Google Play Store </span><a href=\"https://play.google.com/store/apps/details?id=com.mistplay.mistplay&hl=en_CA&gl=US\" style=\"font-size: 15px\">here</a><span style=\"font-size: 15px\"> and follow us on </span><a href=\"https://www.instagram.com/mistplay/?hl=en\" style=\"font-size: 15px\">Instagram</a><span style=\"font-size: 15px\">, </span><a href=\"https://twitter.com/mistplayapp?ref_src=twsrc%5Egoogle%7Ctwcamp%5Eserp%7Ctwgr%5Eauthor\" style=\"font-size: 15px\">Twitter</a><span style=\"font-size: 15px\"> and </span><a href=\"https://www.facebook.com/mistplayapp/\" style=\"font-size: 15px\">Facebook</a><span style=\"font-size: 15px\">.</span></div><div><br></div><div><b>📍 <u>Please Note:</u></b> In Canada 🇨🇦, Mistplay follows a 2 days/week in-office hybrid model in Toronto (400 University Ave) & Montreal (1001 Blvd. Robert-Bourassa) </div>","openingPlain":"Mistplay est l'application de fidélité n°1 pour les joueurs mobiles. Notre communauté de millions de joueurs mobiles engagés utilise Mistplay pour découvrir de nouveaux jeux et gagner des récompenses. Les joueurs sont récompensés pour le temps et l'argent qu'ils consacrent aux jeux et peuvent échanger ces récompenses contre des cartes cadeaux. Mistplay a pour mission d'être le meilleur moyen de jouer à des jeux mobiles pour tous, partout dans le monde ! Téléchargez Mistplay sur le Google Play Store ici et suivez-nous sur Instagram, Twitter et Facebook.\n\n📍 Veuillez noter : Au Canada 🇨🇦, Mistplay suit un modèle hybride de 2 jours/semaine en bureau à Toronto (400 University Ave) & Montréal (1001 Blvd. Robert-Bourassa) \n\nMistplay is the #1 loyalty app for mobile gamers. Our community of millions of engaged mobile gamers come to Mistplay to discover new games to play and earn rewards. Gamers are rewarded for their time and money spent within the games and can redeem those rewards for gift cards. Mistplay is on a mission to be the best way to play mobile games for everyone everywhere! Download Mistplay on the Google Play Store here and follow us on Instagram, Twitter and Facebook.\n\n📍 Please Note: In Canada 🇨🇦, Mistplay follows a 2 days/week in-office hybrid model in Toronto (400 University Ave) & Montreal (1001 Blvd. Robert-Bourassa) \n","descriptionBody":"<div>\n<p>Rejoins l'équipe d'assistance de Mistplay en tant que Spécialiste de l'Expérience Client ! Notre objectif principal est de satisfaire les besoins et les désirs de nos utilisateurs, et nous nous efforçons constamment de dépasser leurs attentes. Si tu adores élaborer des stratégies de communication et dialoguer avec les gens, alors ce poste est fait pour toi !</p>\n<p><strong>Tes missions chez Mistplay:</strong><strong><br><br></strong></p>\n<ul>\n<li>\n<p>Assurer l'assistance quotidienne aux utilisateurs de Mistplay par le biais d'un système de gestion des tickets par courriel (Zendesk).</p>\n</li>\n<li>\n<p>Apporter une assistance personnalisée aux utilisateurs tout en assurant des réponses rapides qui s'alignent sur les KPI cibles de notre équipe.</p>\n</li>\n<li>\n<p>Répondre aux commentaires formulés sur le Google Play Store et sur les réseaux sociaux afin de garantir la satisfaction des utilisateurs et d'obtenir des notes élevées.</p>\n</li>\n<li>\n<p>Produire des rapports hebdomadaires sur les tickets, les avis des utilisateurs et leurs opinions sur les réseaux sociaux.</p>\n</li>\n<li>\n<p>Résoudre les problèmes techniques et étudier les profils des joueurs à l'aide d'outils Back-end.</p>\n</li>\n<li>\n<p>Se tenir au fait des procédures d'assistance les plus récentes, des problèmes dynamiques et des mises à jour de produits.</p>\n</li>\n<li>\n<p>Faire remonter à l'équipe de développement les problèmes récemment signalés et les demandes de feedback des utilisateurs.</p>\n</li>\n<li>\n<p>Rédiger et assurer le suivi des rapports sur les bogues techniques relatifs aux problèmes récurrents.</p>\n</li>\n<li>\n<p>Agir en tant que représentant sur des projets produits pointus et fournir une visibilité à l'équipe d'assistance.</p>\n</li>\n<li>\n<p>Défendre fermement les intérêts des joueurs.</p>\n</li>\n</ul>\n<p><br><strong>Profil recherché:</strong></p>\n<ul>\n<li>\n<p>Tu possèdes de 1 à 3 ans d'expérience dans le domaine des applications, des jeux mobiles ou d'autres environnements d'assistance similaires.</p>\n</li>\n<li>\n<p>Tu sais résoudre les problèmes et interpréter les données de manière intuitive.</p>\n</li>\n<li>\n<p>Tu as la capacité d'utiliser avec assurance divers outils et plateformes en parallèle.</p>\n</li>\n<li>\n<p>Tu es autonome, motivée et capable de respecter les délais.</p>\n</li>\n<li>\n<p>Tu possèdes de réelles aptitudes relationnelles et tu sais faire preuve d'empathie dans tes rapports avec les autres.</p>\n</li>\n<li>\n<p>Tu as de l'expérience en matière de rédaction technique et de production de rapports.</p>\n</li>\n<li>\n<p>Tu possèdes de bonnes connaissances en informatique.</p>\n</li>\n<li>\n<p>Tu as de solides compétences en communication.</p>\n</li>\n<li>\n<p>Tu maîtrises parfaitement l'anglais et le français à l'écrit puisque tu seras en contact avec des clients anglophones.</p>\n</li>\n<li>\n<p>La connaissance d'autres langues, telles que l'allemand ou le coréen, est un plus.</p>\n</li>\n<li>\n<p>Une expérience de Zendesk ou d'Amazon DynamoDB est un atout supplémentaire.</p>\n</li>\n<li>\n<p>Pour usage interne seulement, ce poste est de niveau P1.</p>\n</li>\n</ul>\n<p><strong>Ce que nous t’offrons :</strong></p>\n<p>• Régime d'assurance généreux ;</p>\n<p>• 4 semaines de vacances par an ;</p>\n<p>• 1 semaine de bien-être chaque année (fermeture de l'entreprise) ;</p>\n<p>• Jours de congé individuels/pour maladie ;</p>\n<p>• Contribution employeur au REER collectif à hauteur de 100 % (dans la limite de 5% par an) ;</p>\n<p>• Allocation annuelle pour le fitness/la remise en forme ;</p>\n<p>• Allocation mensuelle pour l'utilisation des transports en commun ou pour la connexion Internet chez vous ;</p>\n<p>• Allocation pour l'apprentissage et le développement.<br><br>Les avantages ci-dessus sont spécifiques aux employés canadiens. Des offres équivalentes sont disponibles pour les employés d'autres pays.</p>\n<p>Penses-tu que ce poste et cette équipe pourraient être faits pour toi ? Postule dès maintenant pour démarrer ta prochaine aventure professionnelle !</p>\n<h3 style=\"text-align: center;\"><strong>Customer Support Specialist </strong></h3>\n<p>Join Mistplay's Support Team as our next <strong>Customer Support Specialist</strong> ! Our main goal is to satisfy the needs and wants of our users, and we constantly strive to exceed their expectations. If you enjoy strategizing communication and engaging with people, then this is the job for you! </p>\n<p><strong>What you’ll do at Misplay</strong></p>\n<ul>\n<li>\n<p>Provide daily support to Mistplay’s users through an email ticketing system (Zendesk)</p>\n</li>\n<li>\n<p>Provide personalized support to users while ensuring timely responses that align with our team’s target KPIs</p>\n</li>\n<li>\n<p>Reply to Google Play Store reviews and social media comments to ensure user satisfaction and high ratings</p>\n</li>\n<li>\n<p>Create weekly reports on tickets, reviews, and social sentiment from users</p>\n</li>\n<li>\n<p>Troubleshoot technical issues and investigate player profiles with back-end tools</p>\n</li>\n<li>\n<p>Stay up to date on our latest support procedures, dynamic issues, and product updates</p>\n</li>\n<li>\n<p>Escalate newly reported issues and user feedback requests to the development team </p>\n</li>\n<li>\n<p>Be able to solve 100+ tickets per day</p>\n</li>\n<li>\n<p>Create and track technical bug reports regarding recurring issues</p>\n</li>\n<li>\n<p>Act as a representative on specialized product projects and provide visibility to the Support Team</p>\n</li>\n<li>\n<p>Be a strong player advocate</p>\n</li>\n</ul>\n<p> </p>\n<p><strong>What you’ll bring to Mistplay</strong></p>\n<ul>\n<li>\n<p>1-3 years of experience in applications, mobile games, or similar support environments</p>\n</li>\n<li>\n<p>Intuitive problem solving and interpreting data</p>\n</li>\n<li>\n<p>Ability to use various tools and platforms in parallel confidently </p>\n</li>\n<li>\n<p>Self-motivated autonomous, and able to meet deadlines</p>\n</li>\n<li>\n<p>Effective people skills and empathy when dealing with others</p>\n</li>\n<li>\n<p>Experience with technical writing and reporting</p>\n</li>\n<li>\n<p>Proficient computer skills</p>\n</li>\n<li>\n<p>Strong communication skills</p>\n</li>\n<li>\n<p>High proficiency in written English</p>\n</li>\n<li>\n<p>Skill with other languages such as French, German, Korean + Japanese are a plus</p>\n</li>\n<li>\n<p>Experience with Zendesk or Amazon DynamoDB is an added advantage</p>\n</li>\n<li>\n<p>Knowledge of application development is an added advantage</p>\n</li>\n<li>\n<p>For internal use only, this is a P1 level position.</p>\n</li>\n</ul>\n<p> </p>\n<p><strong>Why Mistplay?</strong></p>\n<p>We strive to make our work environment as inviting and fun as possible! Working at Mistplay is coupled with a whole array of perks that we've adopted virtually and in-person: Team Lunches, game nights, company-wide events, and so much more.</p>\n<p>Our culture is deeply rooted in growth and upheld by a team of smart, dynamic, and enthusiastic people. We utilize data to constantly learn, improve, and adapt. We foster an environment where everyone is encouraged to share their ideas, push boundaries, take calculated risks, and witness their visions come to life.</p>\n<p><strong>What we Offer</strong></p>\n<ul>\n<li>\n<p>Generous Insurance Plan</p>\n</li>\n<li>\n<p>4 weeks of vacation per year</p>\n</li>\n<li>\n<p>Personal/Sick days</p>\n</li>\n<li>\n<p>RRSP with 100% Employer Matching</p>\n</li>\n<li>\n<p>Annual Fitness Allocation</p>\n</li>\n<li>\n<p>Monthly Public Transit or Home Internet Allocation</p>\n</li>\n<li>\n<p>Learning & Development Allocation</p>\n</li>\n<li>\n<p>Bi-Annual Performance & Salary Reviews</p>\n</li>\n</ul>\n<p>The above benefits are specific to Canadian employees. Equivalent offerings available for employees in other locations.</p>\n<p>Do you feel like this could be the role and team for you? Apply now to start your next professional adventure!</p>\n</div>","descriptionBodyPlain":"Rejoins l'équipe d'assistance de Mistplay en tant que Spécialiste de l'Expérience Client ! Notre objectif principal est de satisfaire les besoins et les désirs de nos utilisateurs, et nous nous efforçons constamment de dépasser leurs attentes. Si tu adores élaborer des stratégies de communication et dialoguer avec les gens, alors ce poste est fait pour toi !\nTes missions chez Mistplay:\n\nAssurer l'assistance quotidienne aux utilisateurs de Mistplay par le biais d'un système de gestion des tickets par courriel (Zendesk).\nApporter une assistance personnalisée aux utilisateurs tout en assurant des réponses rapides qui s'alignent sur les KPI cibles de notre équipe.\nRépondre aux commentaires formulés sur le Google Play Store et sur les réseaux sociaux afin de garantir la satisfaction des utilisateurs et d'obtenir des notes élevées.\nProduire des rapports hebdomadaires sur les tickets, les avis des utilisateurs et leurs opinions sur les réseaux sociaux.\nRésoudre les problèmes techniques et étudier les profils des joueurs à l'aide d'outils Back-end.\nSe tenir au fait des procédures d'assistance les plus récentes, des problèmes dynamiques et des mises à jour de produits.\nFaire remonter à l'équipe de développement les problèmes récemment signalés et les demandes de feedback des utilisateurs.\nRédiger et assurer le suivi des rapports sur les bogues techniques relatifs aux problèmes récurrents.\nAgir en tant que représentant sur des projets produits pointus et fournir une visibilité à l'équipe d'assistance.\nDéfendre fermement les intérêts des joueurs.\n\nProfil recherché:\nTu possèdes de 1 à 3 ans d'expérience dans le domaine des applications, des jeux mobiles ou d'autres environnements d'assistance similaires.\nTu sais résoudre les problèmes et interpréter les données de manière intuitive.\nTu as la capacité d'utiliser avec assurance divers outils et plateformes en parallèle.\nTu es autonome, motivée et capable de respecter les délais.\nTu possèdes de réelles aptitudes relationnelles et tu sais faire preuve d'empathie dans tes rapports avec les autres.\nTu as de l'expérience en matière de rédaction technique et de production de rapports.\nTu possèdes de bonnes connaissances en informatique.\nTu as de solides compétences en communication.\nTu maîtrises parfaitement l'anglais et le français à l'écrit puisque tu seras en contact avec des clients anglophones.\nLa connaissance d'autres langues, telles que l'allemand ou le coréen, est un plus.\nUne expérience de Zendesk ou d'Amazon DynamoDB est un atout supplémentaire.\nPour usage interne seulement, ce poste est de niveau P1.\nCe que nous t’offrons :\n• Régime d'assurance généreux ;\n• 4 semaines de vacances par an ;\n• 1 semaine de bien-être chaque année (fermeture de l'entreprise) ;\n• Jours de congé individuels/pour maladie ;\n• Contribution employeur au REER collectif à hauteur de 100 % (dans la limite de 5% par an) ;\n• Allocation annuelle pour le fitness/la remise en forme ;\n• Allocation mensuelle pour l'utilisation des transports en commun ou pour la connexion Internet chez vous ;\n• Allocation pour l'apprentissage et le développement.\n\nLes avantages ci-dessus sont spécifiques aux employés canadiens. Des offres équivalentes sont disponibles pour les employés d'autres pays.\nPenses-tu que ce poste et cette équipe pourraient être faits pour toi ? Postule dès maintenant pour démarrer ta prochaine aventure professionnelle !\nCustomer Support Specialist \nJoin Mistplay's Support Team as our next Customer Support Specialist ! Our main goal is to satisfy the needs and wants of our users, and we constantly strive to exceed their expectations. If you enjoy strategizing communication and engaging with people, then this is the job for you! \nWhat you’ll do at Misplay\nProvide daily support to Mistplay’s users through an email ticketing system (Zendesk)\nProvide personalized support to users while ensuring timely responses that align with our team’s target KPIs\nReply to Google Play Store reviews and social media comments to ensure user satisfaction and high ratings\nCreate weekly reports on tickets, reviews, and social sentiment from users\nTroubleshoot technical issues and investigate player profiles with back-end tools\nStay up to date on our latest support procedures, dynamic issues, and product updates\nEscalate newly reported issues and user feedback requests to the development team \nBe able to solve 100+ tickets per day\nCreate and track technical bug reports regarding recurring issues\nAct as a representative on specialized product projects and provide visibility to the Support Team\nBe a strong player advocate\n \nWhat you’ll bring to Mistplay\n1-3 years of experience in applications, mobile games, or similar support environments\nIntuitive problem solving and interpreting data\nAbility to use various tools and platforms in parallel confidently \nSelf-motivated autonomous, and able to meet deadlines\nEffective people skills and empathy when dealing with others\nExperience with technical writing and reporting\nProficient computer skills\nStrong communication skills\nHigh proficiency in written English\nSkill with other languages such as French, German, Korean + Japanese are a plus\nExperience with Zendesk or Amazon DynamoDB is an added advantage\nKnowledge of application development is an added advantage\nFor internal use only, this is a P1 level position.\n \nWhy Mistplay?\nWe strive to make our work environment as inviting and fun as possible! Working at Mistplay is coupled with a whole array of perks that we've adopted virtually and in-person: Team Lunches, game nights, company-wide events, and so much more.\nOur culture is deeply rooted in growth and upheld by a team of smart, dynamic, and enthusiastic people. We utilize data to constantly learn, improve, and adapt. We foster an environment where everyone is encouraged to share their ideas, push boundaries, take calculated risks, and witness their visions come to life.\nWhat we Offer\nGenerous Insurance Plan\n4 weeks of vacation per year\nPersonal/Sick days\nRRSP with 100% Employer Matching\nAnnual Fitness Allocation\nMonthly Public Transit or Home Internet Allocation\nLearning & Development Allocation\nBi-Annual Performance & Salary Reviews\nThe above benefits are specific to Canadian employees. Equivalent offerings available for employees in other locations.\nDo you feel like this could be the role and team for you? Apply now to start your next professional adventure!\n","hostedUrl":"https://jobs.lever.co/mistplay/50f40ccd-a45a-4290-919f-1295a08207da","applyUrl":"https://jobs.lever.co/mistplay/50f40ccd-a45a-4290-919f-1295a08207da/apply"},{"additionalPlain":"Why Mistplay?\n \nWe strive to make our work environment as inviting and fun as possible! Working at Mistplay is coupled with a whole array of perks that we've adopted virtually and in-person: Team Lunches, game nights, company-wide events, and so much more. Our culture is deeply rooted in growth and upheld by a team of smart, dynamic, and enthusiastic people. We utilize data to constantly learn, improve, and adapt. We foster an environment where everyone is encouraged to share their ideas, push boundaries, take calculated risks, and witness their visions come to life.\n","additional":"<div>\n<div><strong>Why Mistplay?</strong></div>\n<div> </div>\n<div>We strive to make our work environment as inviting and fun as possible! Working at Mistplay is coupled with a whole array of perks that we've adopted virtually and in-person: Team Lunches, game nights, company-wide events, and so much more. Our culture is deeply rooted in growth and upheld by a team of smart, dynamic, and enthusiastic people. We utilize data to constantly learn, improve, and adapt. We foster an environment where everyone is encouraged to share their ideas, push boundaries, take calculated risks, and witness their visions come to life.</div>\n</div>","categories":{"commitment":"Temps Plein / Full Time","department":"LoyaltyPlay","location":"Tokyo","team":"LoyaltyPlay","allLocations":["Tokyo"]},"createdAt":1782391879059,"descriptionPlain":"Mistplay is the category creator and leader in the 'play-and-earn' mobile loyalty space, built to reward gamers for playing.\nLoyaltyPlay is our B2B monetization platform, a powerful Gaming Reward Hub designed for mobile app Publishers. We partner with app owners (e.g., utility, commerce, or entertainment apps) to help them unlock a new, consistent revenue stream and boost user engagement. By integrating LoyaltyPlay, publishers can offer their users rewards (in-app currency) for installing and playing mobile games from our vast advertiser network. This creates a deep engagement loop and a powerful monetization channel for the publisher.\n\n\n*Japanese Description is below\nAs a Publisher Manager with Mistplay, you will be responsible for maximizing publisher and advertiser performance through data analysis, campaign optimization, business consulting, and revenue growth initiatives.\nWhat you'll do at Mistplay\nAnalyze publisher and campaign performance data\nEvaluate advertising effectiveness and user behavior\nDevelop and execute optimization strategies\nManage advertising campaigns\nSupport and consult clients\nDrive revenue and performance improvements\nWhat you'll bring to Mistplay\nStrong PC and analytical skills\nLogical and data-driven mindset\nInterest in digital marketing and performance optimization\nPreferred Experience\nDigital marketing\nPerformance marketing / ad operations\nConsulting\nData analytics\nExperience working in international companies\nIdeal Candidate\nStrong analytical thinker\nComfortable working with data and KPIs\nSelf-starter with ownership mentality\nAmbitious and eager to grow in a startup environment\n \n \nポジション概要\nメディアおよび広告主の収益最大化を目的として、データ分析、広告運用、改善提案、クライアントコンサルティングを担当します。\n主な業務内容\nメディアのデータ分析\n広告効果およびユーザー行動分析\nパフォーマンス改善提案\n広告運用\nクライアント対応\n売上・収益最大化施策の立案および実行\n必須要件\nPCスキル\n論理的思考力\nデータ分析への興味\n歓迎要件\nWebマーケティング経験\nWeb広告運用経験\nコンサルティング経験\nデータ分析経験\n外資系企業での勤務経験\n求める人物像\n数字に強い方\n自ら課題を発見し解決できる方\n高い成長意欲を持つ方\nスタートアップ環境を楽しめる方\n","description":"<div>\n<p>Mistplay is the category creator and leader in the 'play-and-earn' mobile loyalty space, built to reward gamers for playing.</p>\n<p>LoyaltyPlay is our B2B monetization platform, a powerful Gaming Reward Hub designed for mobile app Publishers. We partner with app owners (e.g., utility, commerce, or entertainment apps) to help them unlock a new, consistent revenue stream and boost user engagement. By integrating LoyaltyPlay, publishers can offer their users rewards (in-app currency) for installing and playing mobile games from our vast advertiser network. This creates a deep engagement loop and a powerful monetization channel for the publisher.</p>\n</div><div><br></div><div>\n<p><strong>*Japanese Description is below</strong></p>\n<p>As a Publisher Manager with Mistplay, you will be responsible for maximizing publisher and advertiser performance through data analysis, campaign optimization, business consulting, and revenue growth initiatives.</p>\n<h3><strong>What you'll do at Mistplay</strong></h3>\n<ul>\n<li>\n<p>Analyze publisher and campaign performance data</p>\n</li>\n<li>\n<p>Evaluate advertising effectiveness and user behavior</p>\n</li>\n<li>\n<p>Develop and execute optimization strategies</p>\n</li>\n<li>\n<p>Manage advertising campaigns</p>\n</li>\n<li>\n<p>Support and consult clients</p>\n</li>\n<li>\n<p>Drive revenue and performance improvements</p>\n</li>\n</ul>\n<h3><strong>What you'll bring to Mistplay</strong></h3>\n<ul>\n<li>\n<p>Strong PC and analytical skills</p>\n</li>\n<li>\n<p>Logical and data-driven mindset</p>\n</li>\n<li>\n<p>Interest in digital marketing and performance optimization</p>\n</li>\n</ul>\n<h3><strong>Preferred Experience</strong></h3>\n<ul>\n<li>\n<p>Digital marketing</p>\n</li>\n<li>\n<p>Performance marketing / ad operations</p>\n</li>\n<li>\n<p>Consulting</p>\n</li>\n<li>\n<p>Data analytics</p>\n</li>\n<li>\n<p>Experience working in international companies</p>\n</li>\n</ul>\n<h3><strong>Ideal Candidate</strong></h3>\n<ul>\n<li>\n<p>Strong analytical thinker</p>\n</li>\n<li>\n<p>Comfortable working with data and KPIs</p>\n</li>\n<li>\n<p>Self-starter with ownership mentality</p>\n</li>\n<li>\n<p>Ambitious and eager to grow in a startup environment</p>\n</li>\n</ul>\n<div> </div>\n<div> </div>\n<div>\n<h3><strong>ポジション概要</strong></h3>\n<p>メディアおよび広告主の収益最大化を目的として、データ分析、広告運用、改善提案、クライアントコンサルティングを担当します。</p>\n<h3><strong>主な業務内容</strong></h3>\n<ul>\n<li>\n<p>メディアのデータ分析</p>\n</li>\n<li>\n<p>広告効果およびユーザー行動分析</p>\n</li>\n<li>\n<p>パフォーマンス改善提案</p>\n</li>\n<li>\n<p>広告運用</p>\n</li>\n<li>\n<p>クライアント対応</p>\n</li>\n<li>\n<p>売上・収益最大化施策の立案および実行</p>\n</li>\n</ul>\n<h3><strong>必須要件</strong></h3>\n<ul>\n<li>\n<p>PCスキル</p>\n</li>\n<li>\n<p>論理的思考力</p>\n</li>\n<li>\n<p>データ分析への興味</p>\n</li>\n</ul>\n<h3><strong>歓迎要件</strong></h3>\n<ul>\n<li>\n<p>Webマーケティング経験</p>\n</li>\n<li>\n<p>Web広告運用経験</p>\n</li>\n<li>\n<p>コンサルティング経験</p>\n</li>\n<li>\n<p>データ分析経験</p>\n</li>\n<li>\n<p>外資系企業での勤務経験</p>\n</li>\n</ul>\n<h3><strong>求める人物像</strong></h3>\n<ul>\n<li>\n<p>数字に強い方</p>\n</li>\n<li>\n<p>自ら課題を発見し解決できる方</p>\n</li>\n<li>\n<p>高い成長意欲を持つ方</p>\n</li>\n<li>\n<p>スタートアップ環境を楽しめる方</p>\n</li>\n</ul>\n</div>\n</div>","id":"ddee6021-95b9-4c28-b54f-e889c02bff17","lists":[],"text":"Senior Account Manager, APAC","country":"JP","workplaceType":"hybrid","opening":"<div>\n<p>Mistplay is the category creator and leader in the 'play-and-earn' mobile loyalty space, built to reward gamers for playing.</p>\n<p>LoyaltyPlay is our B2B monetization platform, a powerful Gaming Reward Hub designed for mobile app Publishers. We partner with app owners (e.g., utility, commerce, or entertainment apps) to help them unlock a new, consistent revenue stream and boost user engagement. By integrating LoyaltyPlay, publishers can offer their users rewards (in-app currency) for installing and playing mobile games from our vast advertiser network. This creates a deep engagement loop and a powerful monetization channel for the publisher.</p>\n</div>","openingPlain":"Mistplay is the category creator and leader in the 'play-and-earn' mobile loyalty space, built to reward gamers for playing.\nLoyaltyPlay is our B2B monetization platform, a powerful Gaming Reward Hub designed for mobile app Publishers. We partner with app owners (e.g., utility, commerce, or entertainment apps) to help them unlock a new, consistent revenue stream and boost user engagement. By integrating LoyaltyPlay, publishers can offer their users rewards (in-app currency) for installing and playing mobile games from our vast advertiser network. This creates a deep engagement loop and a powerful monetization channel for the publisher.\n","descriptionBody":"<div>\n<p><strong>*Japanese Description is below</strong></p>\n<p>As a Publisher Manager with Mistplay, you will be responsible for maximizing publisher and advertiser performance through data analysis, campaign optimization, business consulting, and revenue growth initiatives.</p>\n<h3><strong>What you'll do at Mistplay</strong></h3>\n<ul>\n<li>\n<p>Analyze publisher and campaign performance data</p>\n</li>\n<li>\n<p>Evaluate advertising effectiveness and user behavior</p>\n</li>\n<li>\n<p>Develop and execute optimization strategies</p>\n</li>\n<li>\n<p>Manage advertising campaigns</p>\n</li>\n<li>\n<p>Support and consult clients</p>\n</li>\n<li>\n<p>Drive revenue and performance improvements</p>\n</li>\n</ul>\n<h3><strong>What you'll bring to Mistplay</strong></h3>\n<ul>\n<li>\n<p>Strong PC and analytical skills</p>\n</li>\n<li>\n<p>Logical and data-driven mindset</p>\n</li>\n<li>\n<p>Interest in digital marketing and performance optimization</p>\n</li>\n</ul>\n<h3><strong>Preferred Experience</strong></h3>\n<ul>\n<li>\n<p>Digital marketing</p>\n</li>\n<li>\n<p>Performance marketing / ad operations</p>\n</li>\n<li>\n<p>Consulting</p>\n</li>\n<li>\n<p>Data analytics</p>\n</li>\n<li>\n<p>Experience working in international companies</p>\n</li>\n</ul>\n<h3><strong>Ideal Candidate</strong></h3>\n<ul>\n<li>\n<p>Strong analytical thinker</p>\n</li>\n<li>\n<p>Comfortable working with data and KPIs</p>\n</li>\n<li>\n<p>Self-starter with ownership mentality</p>\n</li>\n<li>\n<p>Ambitious and eager to grow in a startup environment</p>\n</li>\n</ul>\n<div> </div>\n<div> </div>\n<div>\n<h3><strong>ポジション概要</strong></h3>\n<p>メディアおよび広告主の収益最大化を目的として、データ分析、広告運用、改善提案、クライアントコンサルティングを担当します。</p>\n<h3><strong>主な業務内容</strong></h3>\n<ul>\n<li>\n<p>メディアのデータ分析</p>\n</li>\n<li>\n<p>広告効果およびユーザー行動分析</p>\n</li>\n<li>\n<p>パフォーマンス改善提案</p>\n</li>\n<li>\n<p>広告運用</p>\n</li>\n<li>\n<p>クライアント対応</p>\n</li>\n<li>\n<p>売上・収益最大化施策の立案および実行</p>\n</li>\n</ul>\n<h3><strong>必須要件</strong></h3>\n<ul>\n<li>\n<p>PCスキル</p>\n</li>\n<li>\n<p>論理的思考力</p>\n</li>\n<li>\n<p>データ分析への興味</p>\n</li>\n</ul>\n<h3><strong>歓迎要件</strong></h3>\n<ul>\n<li>\n<p>Webマーケティング経験</p>\n</li>\n<li>\n<p>Web広告運用経験</p>\n</li>\n<li>\n<p>コンサルティング経験</p>\n</li>\n<li>\n<p>データ分析経験</p>\n</li>\n<li>\n<p>外資系企業での勤務経験</p>\n</li>\n</ul>\n<h3><strong>求める人物像</strong></h3>\n<ul>\n<li>\n<p>数字に強い方</p>\n</li>\n<li>\n<p>自ら課題を発見し解決できる方</p>\n</li>\n<li>\n<p>高い成長意欲を持つ方</p>\n</li>\n<li>\n<p>スタートアップ環境を楽しめる方</p>\n</li>\n</ul>\n</div>\n</div>","descriptionBodyPlain":"*Japanese Description is below\nAs a Publisher Manager with Mistplay, you will be responsible for maximizing publisher and advertiser performance through data analysis, campaign optimization, business consulting, and revenue growth initiatives.\nWhat you'll do at Mistplay\nAnalyze publisher and campaign performance data\nEvaluate advertising effectiveness and user behavior\nDevelop and execute optimization strategies\nManage advertising campaigns\nSupport and consult clients\nDrive revenue and performance improvements\nWhat you'll bring to Mistplay\nStrong PC and analytical skills\nLogical and data-driven mindset\nInterest in digital marketing and performance optimization\nPreferred Experience\nDigital marketing\nPerformance marketing / ad operations\nConsulting\nData analytics\nExperience working in international companies\nIdeal Candidate\nStrong analytical thinker\nComfortable working with data and KPIs\nSelf-starter with ownership mentality\nAmbitious and eager to grow in a startup environment\n \n \nポジション概要\nメディアおよび広告主の収益最大化を目的として、データ分析、広告運用、改善提案、クライアントコンサルティングを担当します。\n主な業務内容\nメディアのデータ分析\n広告効果およびユーザー行動分析\nパフォーマンス改善提案\n広告運用\nクライアント対応\n売上・収益最大化施策の立案および実行\n必須要件\nPCスキル\n論理的思考力\nデータ分析への興味\n歓迎要件\nWebマーケティング経験\nWeb広告運用経験\nコンサルティング経験\nデータ分析経験\n外資系企業での勤務経験\n求める人物像\n数字に強い方\n自ら課題を発見し解決できる方\n高い成長意欲を持つ方\nスタートアップ環境を楽しめる方\n","hostedUrl":"https://jobs.lever.co/mistplay/ddee6021-95b9-4c28-b54f-e889c02bff17","applyUrl":"https://jobs.lever.co/mistplay/ddee6021-95b9-4c28-b54f-e889c02bff17/apply"},{"additionalPlain":"*Nous remercions tous(tes) les candidat(e)s. Le genre masculin a été utilisé dans le but d'alléger le texte. Nous souscrivons au principe de l’équité en matière d’emploi.\n\nPourquoi choisir Mistplay ?\n\nNous faisons tout pour rendre notre environnement de travail aussi accueillant et plaisant que possible ! Un poste chez Mistplay s’accompagne de toute une série d'avantages que nous proposons en mode virtuel ou présentiel : déjeuners d'équipe, soirées jeux, événements à l'échelle de l'entreprise, et bien plus encore.\nNotre culture est profondément ancrée dans la croissance et soutenue par une équipe de personnes intelligentes, dynamiques et enthousiastes. Nous utilisons les données pour apprendre, améliorer et adapter en permanence. Nous favorisons un environnement dans lequel chacun est encouragé à partager ses idées, à repousser les limites, à prendre des risques calculés et à voir ses visions se concrétiser.\n\nWhy Mistplay?\n\nWe strive to make our work environment as inviting and fun as possible! Working at Mistplay is coupled with a whole array of perks that we've adopted virtually and in-person: Team Lunches, game nights, company-wide events, and so much more. Our culture is deeply rooted in growth and upheld by a team of smart, dynamic, and enthusiastic people. We utilize data to constantly learn, improve, and adapt. We foster an environment where everyone is encouraged to share their ideas, push boundaries, take calculated risks, and witness their visions come to life.\n","additional":"<div><i style=\"font-size: 16px\">*Nous remercions tous(tes) les candidat(e)s. Le genre masculin a été utilisé dans le but d'alléger le texte. Nous souscrivons au principe de l’équité en matière d’emploi.</i></div><div><br></div><div><b style=\"font-size: 11pt\">Pourquoi choisir Mistplay ?</b></div><div><br></div><div><span style=\"font-size: 11pt\">Nous faisons tout pour rendre notre environnement de travail aussi accueillant et plaisant que possible ! Un poste chez Mistplay s’accompagne de toute une série d'avantages que nous proposons en mode virtuel ou présentiel : déjeuners d'équipe, soirées jeux, événements à l'échelle de l'entreprise, et bien plus encore.</span></div><div><span style=\"font-size: 11pt\">Notre culture est profondément ancrée dans la croissance et soutenue par une équipe de personnes intelligentes, dynamiques et enthousiastes. Nous utilisons les données pour apprendre, améliorer et adapter en permanence. Nous favorisons un environnement dans lequel chacun est encouragé à partager ses idées, à repousser les limites, à prendre des risques calculés et à voir ses visions se concrétiser.</span></div><div><br></div><div><b style=\"font-size: 11pt\">Why Mistplay?</b></div><div><br></div><div><span style=\"font-size: 11pt\">We strive to make our work environment as inviting and fun as possible! Working at Mistplay is coupled with a whole array of perks that we've adopted virtually and in-person: Team Lunches, game nights, company-wide events, and so much more.</span> <span style=\"font-size: 11pt\">Our culture is deeply rooted in growth and upheld by a team of smart, dynamic, and enthusiastic people. We utilize data to constantly learn, improve, and adapt. We foster an environment where everyone is encouraged to share their ideas, push boundaries, take calculated risks, and witness their visions come to life.</span></div>","categories":{"department":"LoyaltyPlay","location":"Montreal","team":"LoyaltyPlay","allLocations":["Montreal","Toronto"]},"createdAt":1783970451452,"descriptionPlain":"Mistplay est l'application de fidélité n°1 pour les joueurs mobiles. Notre communauté de millions de joueurs mobiles engagés utilise Mistplay pour découvrir de nouveaux jeux et gagner des récompenses. Les joueurs sont récompensés pour le temps et l'argent qu'ils consacrent aux jeux et peuvent échanger ces récompenses contre des cartes cadeaux. Mistplay a pour mission d'être le meilleur moyen de jouer à des jeux mobiles pour tous, partout dans le monde ! Téléchargez Mistplay sur le Google Play Store ici et suivez-nous sur Instagram, Twitter et Facebook.\n\n📍 Veuillez noter : Au Canada 🇨🇦, Mistplay suit un modèle hybride de 2 jours/semaine en bureau à Toronto (400 University Ave) & Montréal (1001 Blvd. Robert-Bourassa) \n\nMistplay is the #1 loyalty app for mobile gamers. Our community of millions of engaged mobile gamers come to Mistplay to discover new games to play and earn rewards. Gamers are rewarded for their time and money spent within the games and can redeem those rewards for gift cards. Mistplay is on a mission to be the best way to play mobile games for everyone everywhere! Download Mistplay on the Google Play Store here and follow us on Instagram, Twitter and Facebook.\n\n📍 Please Note: In Canada 🇨🇦, Mistplay follows a 2 days/week in-office hybrid model in Toronto (400 University Ave) & Montreal (1001 Blvd. Robert-Bourassa) \n\n\nEnglish Description is Below ⬇️\nLe gestionnaire de la réussite client I (Senior Customer Success Manager I) au sein du département LoyaltyPlay de Mistplay jouera un rôle clé dans le succès et la croissance de nos partenaires de marque en agissant comme leur point de contact principal et en supervisant l'ensemble du cycle de vie client. Vous relèverez du directeur de la réussite client. Votre rôle sera d'assurer une intégration harmonieuse des marques et de fournir des recommandations stratégiques et proactives qui améliorent la satisfaction client et favorisent la fidélisation.\nVos responsabilités chez Mistplay\nAgir en tant que liaison principale entre les clients et les équipes internes pour assurer une communication claire et une exécution fluide des campagnes.\nInteragir directement avec les utilisateurs finaux et les équipes de support client pour dépanner, vérifier et transmettre les problèmes de support du programme.\nDévelopper et maintenir des plans de parcours client, en assurant une exécution rapide et précise de toutes les tâches.\nDiriger les appels clients, y compris l'intégration des clients, la formation, les agendas d'équipes interfonctionnelles et les revues de performance.\nGérer de manière proactive les problèmes clients, les signalements et les attentes pour maintenir un niveau élevé de satisfaction.\nÉtablir des relations solides avec les clients, en s'engageant activement pour évaluer leurs besoins et leur état de santé.\nConserver et faire croître les comptes clients.\nFournir des recommandations basées sur les données pour améliorer les stratégies marketing des clients et augmenter le potentiel de revenus.\nAnalyser les performances des campagnes, créer des rapports détaillés et présenter des insights exploitables aux responsables marketing.\nIdentifier les forces et les faiblesses des implantations clients et collaborer avec les équipes internes sur les solutions.\nDevenir un expert en la matière sur toutes les offres de LoyaltyPlay par Mistplay, en assurant une compréhension approfondie des produits et services.\nSuperviser le processus d'intégration de bout en bout pour les nouveaux clients, en assurant une intégration technique et opérationnelle fluide.\nGérer les outils opérationnels tels que HubSpot et JIRA pour suivre les activités, trier les tickets et transmettre les problèmes au besoin.\nAider à l'assurance qualité (AQ) des campagnes en arrière-plan, au dépannage et aux cas de support liés aux utilisateurs finaux.\nS'associer aux ventes, aux produits et aux autres membres de l'équipe de réussite client pour s'aligner sur les objectifs des clients et fournir des résultats exceptionnels.\nFournir des commentaires aux équipes internes pour améliorer les offres en fonction des retours clients.\nÉlaborer et partager les meilleures pratiques pour permettre une adoption réussie des solutions par les clients.\nCe que vous apporterez à Mistplay\nBaccalauréat en administration des affaires, marketing, communications ou dans un domaine connexe.\nPlus de 2 ans d'expérience en gestion de comptes, en réussite client ou dans un rôle similaire.\nCompréhension approfondie des stratégies et des outils de marketing numérique, avec une expertise spécifique dans les secteurs de la restauration, du commerce de détail et des dépanneurs.\nExcellentes compétences organisationnelles avec la capacité de gérer plusieurs priorités et échéances.\nMaîtrise des outils CRM (ex. HubSpot) et des systèmes de billetterie (ex. JIRA).\nEsprit analytique avec de l'expérience dans le reporting de performance et la prise de décision basée sur les données.\nCompétences exceptionnelles en communication et relations interpersonnelles, avec une attitude axée sur le client.\nCapacité avérée à travailler de manière indépendante dans un environnement 100 % à distance.\nUne expérience dans les secteurs de la technologie publicitaire (ad tech), du jeu vidéo, des plateformes de fidélisation, de la restauration ou des dépanneurs est préférable.\nExpertise technique en intégration, onboarding et analyse de données, avec un accent sur l'identification des opportunités de vente incitative (upsell).\nForte familiarité avec les processus d'assurance qualité des campagnes de marketing de fidélisation et les flux de travail de révision créative.\n \nSenior Customer Success Manager I (Contract) \nThe Senior Customer Success Manager I within Mistplay’s LoyaltyPlay Customer Success department will play a key role in driving the success and growth of our brand partners by acting as their primary point of contact and overseeing the entire customer lifecycle. You will be reporting to the Director, Customer Success. Your role will be to ensure seamless brand integrations and deliver proactive, strategic recommendations that enhance customer satisfaction and foster retention.\nWhat you’ll do at Mistplay\n•Act as the primary liaison between customers and internal teams to ensure clear communication and seamless execution of campaigns.\n•Interact directly with end-users and customer support teams to troubleshoot, verify, and escalate program support issues.\n•Develop and maintain customer flight plans, ensuring timely and accurate execution of all tasks.\n•Lead customer calls, including customer onboarding, training, cross-functional team agendas, and performance reviews.\n•Proactively manage customer issues, escalations, and expectations to maintain high customer satisfaction.\n•Build strong relationships with customers, actively engaging to assess their needs and health status.\n•Retain and grow customer accounts.\n•Provide data-driven recommendations to enhance client marketing strategies and increase revenue potential.\n•Analyze campaign performance, create detailed reports, and present actionable insights to marketing leads.\n•Identify strengths and weaknesses in customer implementations and collaborate with internal teams on solutions.\n•Become a SME on all LoyaltyPlay by Mistplay offerings, ensuring a deep understanding of products and services.\n•Oversee the end-to-end onboarding process for new customers, ensuring smooth technical and operational integration.\n•Manage operational tools like HubSpot and JIRA to track activities, triage tickets, and escalate issues as needed.\n•Assist in backend campaign QA, troubleshooting, and end-user support-related cases.\n•Partner with Sales, Product, and other Client Success team members to align on client goals and deliver outstanding results.\n•Provide feedback to internal teams to improve offerings based on customer input.\n•Build and share best practices to enable successful adoption of solutions by customers.\n \nWhat you’ll bring to Mistplay\n•Bachelor’s degree in Business, Marketing, Communications, or a related field.\n•2+ years of experience in account management, customer success, or a similar role.\n•Deep understanding of digital marketing strategies and tools, with specific expertise in the restaurant, retail, and convenience industries.\n•Excellent organizational skills with the ability to manage multiple priorities and deadlines.\n•Proficiency in CRM tools (e.g., HubSpot) and ticketing systems (e.g., JIRA).\n•Analytical mindset with experience in performance reporting and data-driven decision-making.\n•Exceptional communication and interpersonal skills, with a customer-first attitude.\n•Demonstrates proven ability to work independently in a 100% remote environment.\n•Experience in ad tech, gaming, loyalty platforms, restaurant, and/or convenience store industries is preferred.\n•Technical expertise in onboarding, integrations, and data analysis, with an emphasis on identifying upsell opportunities.\n•Strong familiarity with loyalty marketing campaign QA processes and creative review workflows.\n \n","description":"<div><span style=\"font-size: 15px\">Mistplay est l'application de fidélité n°1 pour les joueurs mobiles. Notre communauté de millions de joueurs mobiles engagés utilise Mistplay pour découvrir de nouveaux jeux et gagner des récompenses. Les joueurs sont récompensés pour le temps et l'argent qu'ils consacrent aux jeux et peuvent échanger ces récompenses contre des cartes cadeaux. Mistplay a pour mission d'être le meilleur moyen de jouer à des jeux mobiles pour tous, partout dans le monde ! Téléchargez Mistplay sur le Google Play Store </span><a style=\"font-size: 15px\" href=\"https://play.google.com/store/apps/details?id=com.mistplay.mistplay&hl=en_CA&gl=US\">ici</a><span style=\"font-size: 15px\"> et suivez-nous sur </span><a style=\"font-size: 15px\" href=\"https://www.instagram.com/mistplay/?hl=en\">Instagram</a><span style=\"font-size: 15px\">, </span><a style=\"font-size: 15px\" href=\"https://twitter.com/mistplayapp?ref_src=twsrc%5Egoogle%7Ctwcamp%5Eserp%7Ctwgr%5Eauthor\">Twitter</a><span style=\"font-size: 15px\"> et </span><a style=\"font-size: 15px\" href=\"https://www.facebook.com/mistplayapp/\">Facebook</a><span style=\"font-size: 15px\">.</span></div><div><br></div><div>📍 <b><u>Veuillez noter :</u></b> Au Canada 🇨🇦, Mistplay suit un modèle hybride de 2 jours/semaine en bureau à Toronto (400 University Ave) & Montréal (1001 Blvd. Robert-Bourassa) </div><div><br></div><div><span style=\"font-size: 15px\">Mistplay is the #1 loyalty app for mobile gamers. Our community of millions of engaged mobile gamers come to Mistplay to discover new games to play and earn rewards. Gamers are rewarded for their time and money spent within the games and can redeem those rewards for gift cards. Mistplay is on a mission to be the best way to play mobile games for everyone everywhere! Download Mistplay on the Google Play Store </span><a style=\"font-size: 15px\" href=\"https://play.google.com/store/apps/details?id=com.mistplay.mistplay&hl=en_CA&gl=US\">here</a><span style=\"font-size: 15px\"> and follow us on </span><a style=\"font-size: 15px\" href=\"https://www.instagram.com/mistplay/?hl=en\">Instagram</a><span style=\"font-size: 15px\">, </span><a style=\"font-size: 15px\" href=\"https://twitter.com/mistplayapp?ref_src=twsrc%5Egoogle%7Ctwcamp%5Eserp%7Ctwgr%5Eauthor\">Twitter</a><span style=\"font-size: 15px\"> and </span><a style=\"font-size: 15px\" href=\"https://www.facebook.com/mistplayapp/\">Facebook</a><span style=\"font-size: 15px\">.</span></div><div><br></div><div><b>📍 <u>Please Note:</u></b> In Canada 🇨🇦, Mistplay follows a 2 days/week in-office hybrid model in Toronto (400 University Ave) & Montreal (1001 Blvd. Robert-Bourassa) </div><div><br></div><p><strong>English Description is Below ⬇️</strong></p>\n<p>Le gestionnaire de la réussite client I (Senior Customer Success Manager I) au sein du département LoyaltyPlay de Mistplay jouera un rôle clé dans le succès et la croissance de nos partenaires de marque en agissant comme leur point de contact principal et en supervisant l'ensemble du cycle de vie client. Vous relèverez du directeur de la réussite client. Votre rôle sera d'assurer une intégration harmonieuse des marques et de fournir des recommandations stratégiques et proactives qui améliorent la satisfaction client et favorisent la fidélisation.</p>\n<h4>Vos responsabilités chez Mistplay</h4>\n<ul>\n<li>\n<p>Agir en tant que liaison principale entre les clients et les équipes internes pour assurer une communication claire et une exécution fluide des campagnes.</p>\n</li>\n<li>\n<p>Interagir directement avec les utilisateurs finaux et les équipes de support client pour dépanner, vérifier et transmettre les problèmes de support du programme.</p>\n</li>\n<li>\n<p>Développer et maintenir des plans de parcours client, en assurant une exécution rapide et précise de toutes les tâches.</p>\n</li>\n<li>\n<p>Diriger les appels clients, y compris l'intégration des clients, la formation, les agendas d'équipes interfonctionnelles et les revues de performance.</p>\n</li>\n<li>\n<p>Gérer de manière proactive les problèmes clients, les signalements et les attentes pour maintenir un niveau élevé de satisfaction.</p>\n</li>\n<li>\n<p>Établir des relations solides avec les clients, en s'engageant activement pour évaluer leurs besoins et leur état de santé.</p>\n</li>\n<li>\n<p>Conserver et faire croître les comptes clients.</p>\n</li>\n<li>\n<p>Fournir des recommandations basées sur les données pour améliorer les stratégies marketing des clients et augmenter le potentiel de revenus.</p>\n</li>\n<li>\n<p>Analyser les performances des campagnes, créer des rapports détaillés et présenter des insights exploitables aux responsables marketing.</p>\n</li>\n<li>\n<p>Identifier les forces et les faiblesses des implantations clients et collaborer avec les équipes internes sur les solutions.</p>\n</li>\n<li>\n<p>Devenir un expert en la matière sur toutes les offres de LoyaltyPlay par Mistplay, en assurant une compréhension approfondie des produits et services.</p>\n</li>\n<li>\n<p>Superviser le processus d'intégration de bout en bout pour les nouveaux clients, en assurant une intégration technique et opérationnelle fluide.</p>\n</li>\n<li>\n<p>Gérer les outils opérationnels tels que HubSpot et JIRA pour suivre les activités, trier les tickets et transmettre les problèmes au besoin.</p>\n</li>\n<li>\n<p>Aider à l'assurance qualité (AQ) des campagnes en arrière-plan, au dépannage et aux cas de support liés aux utilisateurs finaux.</p>\n</li>\n<li>\n<p>S'associer aux ventes, aux produits et aux autres membres de l'équipe de réussite client pour s'aligner sur les objectifs des clients et fournir des résultats exceptionnels.</p>\n</li>\n<li>\n<p>Fournir des commentaires aux équipes internes pour améliorer les offres en fonction des retours clients.</p>\n</li>\n<li>\n<p>Élaborer et partager les meilleures pratiques pour permettre une adoption réussie des solutions par les clients.</p>\n</li>\n</ul>\n<h4>Ce que vous apporterez à Mistplay</h4>\n<ul>\n<li>\n<p>Baccalauréat en administration des affaires, marketing, communications ou dans un domaine connexe.</p>\n</li>\n<li>\n<p>Plus de 2 ans d'expérience en gestion de comptes, en réussite client ou dans un rôle similaire.</p>\n</li>\n<li>\n<p>Compréhension approfondie des stratégies et des outils de marketing numérique, avec une expertise spécifique dans les secteurs de la restauration, du commerce de détail et des dépanneurs.</p>\n</li>\n<li>\n<p>Excellentes compétences organisationnelles avec la capacité de gérer plusieurs priorités et échéances.</p>\n</li>\n<li>\n<p>Maîtrise des outils CRM (ex. HubSpot) et des systèmes de billetterie (ex. JIRA).</p>\n</li>\n<li>\n<p>Esprit analytique avec de l'expérience dans le reporting de performance et la prise de décision basée sur les données.</p>\n</li>\n<li>\n<p>Compétences exceptionnelles en communication et relations interpersonnelles, avec une attitude axée sur le client.</p>\n</li>\n<li>\n<p>Capacité avérée à travailler de manière indépendante dans un environnement 100 % à distance.</p>\n</li>\n<li>\n<p>Une expérience dans les secteurs de la technologie publicitaire (ad tech), du jeu vidéo, des plateformes de fidélisation, de la restauration ou des dépanneurs est préférable.</p>\n</li>\n<li>\n<p>Expertise technique en intégration, onboarding et analyse de données, avec un accent sur l'identification des opportunités de vente incitative (upsell).</p>\n</li>\n<li>\n<p>Forte familiarité avec les processus d'assurance qualité des campagnes de marketing de fidélisation et les flux de travail de révision créative.</p>\n</li>\n</ul>\n<p> </p>\n<h4 style=\"text-align: center;\">Senior Customer Success Manager I (Contract) </h4>\n<div>\n<p>The Senior Customer Success Manager I within Mistplay’s LoyaltyPlay Customer Success department will play a key role in driving the success and growth of our brand partners by acting as their primary point of contact and overseeing the entire customer lifecycle. You will be reporting to the Director, Customer Success. Your role will be to ensure seamless brand integrations and deliver proactive, strategic recommendations that enhance customer satisfaction and foster retention.</p>\n<p><strong>What you’ll do at Mistplay</strong></p>\n<p>•Act as the primary liaison between customers and internal teams to ensure clear communication and seamless execution of campaigns.</p>\n<p>•Interact directly with end-users and customer support teams to troubleshoot, verify, and escalate program support issues.</p>\n<p>•Develop and maintain customer flight plans, ensuring timely and accurate execution of all tasks.</p>\n<p>•Lead customer calls, including customer onboarding, training, cross-functional team agendas, and performance reviews.</p>\n<p>•Proactively manage customer issues, escalations, and expectations to maintain high customer satisfaction.</p>\n<p>•Build strong relationships with customers, actively engaging to assess their needs and health status.</p>\n<p>•Retain and grow customer accounts.</p>\n<p>•Provide data-driven recommendations to enhance client marketing strategies and increase revenue potential.</p>\n<p>•Analyze campaign performance, create detailed reports, and present actionable insights to marketing leads.</p>\n<p>•Identify strengths and weaknesses in customer implementations and collaborate with internal teams on solutions.</p>\n<p>•Become a SME on all LoyaltyPlay by Mistplay offerings, ensuring a deep understanding of products and services.</p>\n<p>•Oversee the end-to-end onboarding process for new customers, ensuring smooth technical and operational integration.</p>\n<p>•Manage operational tools like HubSpot and JIRA to track activities, triage tickets, and escalate issues as needed.</p>\n<p>•Assist in backend campaign QA, troubleshooting, and end-user support-related cases.</p>\n<p>•Partner with Sales, Product, and other Client Success team members to align on client goals and deliver outstanding results.</p>\n<p>•Provide feedback to internal teams to improve offerings based on customer input.</p>\n<p>•Build and share best practices to enable successful adoption of solutions by customers.</p>\n<p> </p>\n<p><strong>What you’ll bring to Mistplay</strong></p>\n<p>•Bachelor’s degree in Business, Marketing, Communications, or a related field.</p>\n<p>•2+ years of experience in account management, customer success, or a similar role.</p>\n<p>•Deep understanding of digital marketing strategies and tools, with specific expertise in the restaurant, retail, and convenience industries.</p>\n<p>•Excellent organizational skills with the ability to manage multiple priorities and deadlines.</p>\n<p>•Proficiency in CRM tools (e.g., HubSpot) and ticketing systems (e.g., JIRA).</p>\n<p>•Analytical mindset with experience in performance reporting and data-driven decision-making.</p>\n<p>•Exceptional communication and interpersonal skills, with a customer-first attitude.</p>\n<p>•Demonstrates proven ability to work independently in a 100% remote environment.</p>\n<p>•Experience in ad tech, gaming, loyalty platforms, restaurant, and/or convenience store industries is preferred.</p>\n<p>•Technical expertise in onboarding, integrations, and data analysis, with an emphasis on identifying upsell opportunities.</p>\n<p>•Strong familiarity with loyalty marketing campaign QA processes and creative review workflows.</p>\n</div>\n<p> </p>","id":"8d459210-edd2-45dd-9e30-b2e804508018","lists":[],"text":"Senior Customer Success Manager I (Contract) // Gestionnaire de la réussite client (Contrat)","country":"CA","workplaceType":"hybrid","opening":"<div><span style=\"font-size: 15px\">Mistplay est l'application de fidélité n°1 pour les joueurs mobiles. Notre communauté de millions de joueurs mobiles engagés utilise Mistplay pour découvrir de nouveaux jeux et gagner des récompenses. Les joueurs sont récompensés pour le temps et l'argent qu'ils consacrent aux jeux et peuvent échanger ces récompenses contre des cartes cadeaux. Mistplay a pour mission d'être le meilleur moyen de jouer à des jeux mobiles pour tous, partout dans le monde ! Téléchargez Mistplay sur le Google Play Store </span><a href=\"https://play.google.com/store/apps/details?id=com.mistplay.mistplay&hl=en_CA&gl=US\" style=\"font-size: 15px\">ici</a><span style=\"font-size: 15px\"> et suivez-nous sur </span><a href=\"https://www.instagram.com/mistplay/?hl=en\" style=\"font-size: 15px\">Instagram</a><span style=\"font-size: 15px\">, </span><a href=\"https://twitter.com/mistplayapp?ref_src=twsrc%5Egoogle%7Ctwcamp%5Eserp%7Ctwgr%5Eauthor\" style=\"font-size: 15px\">Twitter</a><span style=\"font-size: 15px\"> et </span><a href=\"https://www.facebook.com/mistplayapp/\" style=\"font-size: 15px\">Facebook</a><span style=\"font-size: 15px\">.</span></div><div><br></div><div>📍 <b><u>Veuillez noter :</u></b> Au Canada 🇨🇦, Mistplay suit un modèle hybride de 2 jours/semaine en bureau à Toronto (400 University Ave) & Montréal (1001 Blvd. Robert-Bourassa) </div><div><br></div><div><span style=\"font-size: 15px\">Mistplay is the #1 loyalty app for mobile gamers. Our community of millions of engaged mobile gamers come to Mistplay to discover new games to play and earn rewards. Gamers are rewarded for their time and money spent within the games and can redeem those rewards for gift cards. Mistplay is on a mission to be the best way to play mobile games for everyone everywhere! Download Mistplay on the Google Play Store </span><a href=\"https://play.google.com/store/apps/details?id=com.mistplay.mistplay&hl=en_CA&gl=US\" style=\"font-size: 15px\">here</a><span style=\"font-size: 15px\"> and follow us on </span><a href=\"https://www.instagram.com/mistplay/?hl=en\" style=\"font-size: 15px\">Instagram</a><span style=\"font-size: 15px\">, </span><a href=\"https://twitter.com/mistplayapp?ref_src=twsrc%5Egoogle%7Ctwcamp%5Eserp%7Ctwgr%5Eauthor\" style=\"font-size: 15px\">Twitter</a><span style=\"font-size: 15px\"> and </span><a href=\"https://www.facebook.com/mistplayapp/\" style=\"font-size: 15px\">Facebook</a><span style=\"font-size: 15px\">.</span></div><div><br></div><div><b>📍 <u>Please Note:</u></b> In Canada 🇨🇦, Mistplay follows a 2 days/week in-office hybrid model in Toronto (400 University Ave) & Montreal (1001 Blvd. Robert-Bourassa) </div>","openingPlain":"Mistplay est l'application de fidélité n°1 pour les joueurs mobiles. Notre communauté de millions de joueurs mobiles engagés utilise Mistplay pour découvrir de nouveaux jeux et gagner des récompenses. Les joueurs sont récompensés pour le temps et l'argent qu'ils consacrent aux jeux et peuvent échanger ces récompenses contre des cartes cadeaux. Mistplay a pour mission d'être le meilleur moyen de jouer à des jeux mobiles pour tous, partout dans le monde ! Téléchargez Mistplay sur le Google Play Store ici et suivez-nous sur Instagram, Twitter et Facebook.\n\n📍 Veuillez noter : Au Canada 🇨🇦, Mistplay suit un modèle hybride de 2 jours/semaine en bureau à Toronto (400 University Ave) & Montréal (1001 Blvd. Robert-Bourassa) \n\nMistplay is the #1 loyalty app for mobile gamers. Our community of millions of engaged mobile gamers come to Mistplay to discover new games to play and earn rewards. Gamers are rewarded for their time and money spent within the games and can redeem those rewards for gift cards. Mistplay is on a mission to be the best way to play mobile games for everyone everywhere! Download Mistplay on the Google Play Store here and follow us on Instagram, Twitter and Facebook.\n\n📍 Please Note: In Canada 🇨🇦, Mistplay follows a 2 days/week in-office hybrid model in Toronto (400 University Ave) & Montreal (1001 Blvd. Robert-Bourassa) \n","descriptionBody":"<p><strong>English Description is Below ⬇️</strong></p>\n<p>Le gestionnaire de la réussite client I (Senior Customer Success Manager I) au sein du département LoyaltyPlay de Mistplay jouera un rôle clé dans le succès et la croissance de nos partenaires de marque en agissant comme leur point de contact principal et en supervisant l'ensemble du cycle de vie client. Vous relèverez du directeur de la réussite client. Votre rôle sera d'assurer une intégration harmonieuse des marques et de fournir des recommandations stratégiques et proactives qui améliorent la satisfaction client et favorisent la fidélisation.</p>\n<h4>Vos responsabilités chez Mistplay</h4>\n<ul>\n<li>\n<p>Agir en tant que liaison principale entre les clients et les équipes internes pour assurer une communication claire et une exécution fluide des campagnes.</p>\n</li>\n<li>\n<p>Interagir directement avec les utilisateurs finaux et les équipes de support client pour dépanner, vérifier et transmettre les problèmes de support du programme.</p>\n</li>\n<li>\n<p>Développer et maintenir des plans de parcours client, en assurant une exécution rapide et précise de toutes les tâches.</p>\n</li>\n<li>\n<p>Diriger les appels clients, y compris l'intégration des clients, la formation, les agendas d'équipes interfonctionnelles et les revues de performance.</p>\n</li>\n<li>\n<p>Gérer de manière proactive les problèmes clients, les signalements et les attentes pour maintenir un niveau élevé de satisfaction.</p>\n</li>\n<li>\n<p>Établir des relations solides avec les clients, en s'engageant activement pour évaluer leurs besoins et leur état de santé.</p>\n</li>\n<li>\n<p>Conserver et faire croître les comptes clients.</p>\n</li>\n<li>\n<p>Fournir des recommandations basées sur les données pour améliorer les stratégies marketing des clients et augmenter le potentiel de revenus.</p>\n</li>\n<li>\n<p>Analyser les performances des campagnes, créer des rapports détaillés et présenter des insights exploitables aux responsables marketing.</p>\n</li>\n<li>\n<p>Identifier les forces et les faiblesses des implantations clients et collaborer avec les équipes internes sur les solutions.</p>\n</li>\n<li>\n<p>Devenir un expert en la matière sur toutes les offres de LoyaltyPlay par Mistplay, en assurant une compréhension approfondie des produits et services.</p>\n</li>\n<li>\n<p>Superviser le processus d'intégration de bout en bout pour les nouveaux clients, en assurant une intégration technique et opérationnelle fluide.</p>\n</li>\n<li>\n<p>Gérer les outils opérationnels tels que HubSpot et JIRA pour suivre les activités, trier les tickets et transmettre les problèmes au besoin.</p>\n</li>\n<li>\n<p>Aider à l'assurance qualité (AQ) des campagnes en arrière-plan, au dépannage et aux cas de support liés aux utilisateurs finaux.</p>\n</li>\n<li>\n<p>S'associer aux ventes, aux produits et aux autres membres de l'équipe de réussite client pour s'aligner sur les objectifs des clients et fournir des résultats exceptionnels.</p>\n</li>\n<li>\n<p>Fournir des commentaires aux équipes internes pour améliorer les offres en fonction des retours clients.</p>\n</li>\n<li>\n<p>Élaborer et partager les meilleures pratiques pour permettre une adoption réussie des solutions par les clients.</p>\n</li>\n</ul>\n<h4>Ce que vous apporterez à Mistplay</h4>\n<ul>\n<li>\n<p>Baccalauréat en administration des affaires, marketing, communications ou dans un domaine connexe.</p>\n</li>\n<li>\n<p>Plus de 2 ans d'expérience en gestion de comptes, en réussite client ou dans un rôle similaire.</p>\n</li>\n<li>\n<p>Compréhension approfondie des stratégies et des outils de marketing numérique, avec une expertise spécifique dans les secteurs de la restauration, du commerce de détail et des dépanneurs.</p>\n</li>\n<li>\n<p>Excellentes compétences organisationnelles avec la capacité de gérer plusieurs priorités et échéances.</p>\n</li>\n<li>\n<p>Maîtrise des outils CRM (ex. HubSpot) et des systèmes de billetterie (ex. JIRA).</p>\n</li>\n<li>\n<p>Esprit analytique avec de l'expérience dans le reporting de performance et la prise de décision basée sur les données.</p>\n</li>\n<li>\n<p>Compétences exceptionnelles en communication et relations interpersonnelles, avec une attitude axée sur le client.</p>\n</li>\n<li>\n<p>Capacité avérée à travailler de manière indépendante dans un environnement 100 % à distance.</p>\n</li>\n<li>\n<p>Une expérience dans les secteurs de la technologie publicitaire (ad tech), du jeu vidéo, des plateformes de fidélisation, de la restauration ou des dépanneurs est préférable.</p>\n</li>\n<li>\n<p>Expertise technique en intégration, onboarding et analyse de données, avec un accent sur l'identification des opportunités de vente incitative (upsell).</p>\n</li>\n<li>\n<p>Forte familiarité avec les processus d'assurance qualité des campagnes de marketing de fidélisation et les flux de travail de révision créative.</p>\n</li>\n</ul>\n<p> </p>\n<h4 style=\"text-align: center;\">Senior Customer Success Manager I (Contract) </h4>\n<div>\n<p>The Senior Customer Success Manager I within Mistplay’s LoyaltyPlay Customer Success department will play a key role in driving the success and growth of our brand partners by acting as their primary point of contact and overseeing the entire customer lifecycle. You will be reporting to the Director, Customer Success. Your role will be to ensure seamless brand integrations and deliver proactive, strategic recommendations that enhance customer satisfaction and foster retention.</p>\n<p><strong>What you’ll do at Mistplay</strong></p>\n<p>•Act as the primary liaison between customers and internal teams to ensure clear communication and seamless execution of campaigns.</p>\n<p>•Interact directly with end-users and customer support teams to troubleshoot, verify, and escalate program support issues.</p>\n<p>•Develop and maintain customer flight plans, ensuring timely and accurate execution of all tasks.</p>\n<p>•Lead customer calls, including customer onboarding, training, cross-functional team agendas, and performance reviews.</p>\n<p>•Proactively manage customer issues, escalations, and expectations to maintain high customer satisfaction.</p>\n<p>•Build strong relationships with customers, actively engaging to assess their needs and health status.</p>\n<p>•Retain and grow customer accounts.</p>\n<p>•Provide data-driven recommendations to enhance client marketing strategies and increase revenue potential.</p>\n<p>•Analyze campaign performance, create detailed reports, and present actionable insights to marketing leads.</p>\n<p>•Identify strengths and weaknesses in customer implementations and collaborate with internal teams on solutions.</p>\n<p>•Become a SME on all LoyaltyPlay by Mistplay offerings, ensuring a deep understanding of products and services.</p>\n<p>•Oversee the end-to-end onboarding process for new customers, ensuring smooth technical and operational integration.</p>\n<p>•Manage operational tools like HubSpot and JIRA to track activities, triage tickets, and escalate issues as needed.</p>\n<p>•Assist in backend campaign QA, troubleshooting, and end-user support-related cases.</p>\n<p>•Partner with Sales, Product, and other Client Success team members to align on client goals and deliver outstanding results.</p>\n<p>•Provide feedback to internal teams to improve offerings based on customer input.</p>\n<p>•Build and share best practices to enable successful adoption of solutions by customers.</p>\n<p> </p>\n<p><strong>What you’ll bring to Mistplay</strong></p>\n<p>•Bachelor’s degree in Business, Marketing, Communications, or a related field.</p>\n<p>•2+ years of experience in account management, customer success, or a similar role.</p>\n<p>•Deep understanding of digital marketing strategies and tools, with specific expertise in the restaurant, retail, and convenience industries.</p>\n<p>•Excellent organizational skills with the ability to manage multiple priorities and deadlines.</p>\n<p>•Proficiency in CRM tools (e.g., HubSpot) and ticketing systems (e.g., JIRA).</p>\n<p>•Analytical mindset with experience in performance reporting and data-driven decision-making.</p>\n<p>•Exceptional communication and interpersonal skills, with a customer-first attitude.</p>\n<p>•Demonstrates proven ability to work independently in a 100% remote environment.</p>\n<p>•Experience in ad tech, gaming, loyalty platforms, restaurant, and/or convenience store industries is preferred.</p>\n<p>•Technical expertise in onboarding, integrations, and data analysis, with an emphasis on identifying upsell opportunities.</p>\n<p>•Strong familiarity with loyalty marketing campaign QA processes and creative review workflows.</p>\n</div>\n<p> </p>","descriptionBodyPlain":"English Description is Below ⬇️\nLe gestionnaire de la réussite client I (Senior Customer Success Manager I) au sein du département LoyaltyPlay de Mistplay jouera un rôle clé dans le succès et la croissance de nos partenaires de marque en agissant comme leur point de contact principal et en supervisant l'ensemble du cycle de vie client. Vous relèverez du directeur de la réussite client. Votre rôle sera d'assurer une intégration harmonieuse des marques et de fournir des recommandations stratégiques et proactives qui améliorent la satisfaction client et favorisent la fidélisation.\nVos responsabilités chez Mistplay\nAgir en tant que liaison principale entre les clients et les équipes internes pour assurer une communication claire et une exécution fluide des campagnes.\nInteragir directement avec les utilisateurs finaux et les équipes de support client pour dépanner, vérifier et transmettre les problèmes de support du programme.\nDévelopper et maintenir des plans de parcours client, en assurant une exécution rapide et précise de toutes les tâches.\nDiriger les appels clients, y compris l'intégration des clients, la formation, les agendas d'équipes interfonctionnelles et les revues de performance.\nGérer de manière proactive les problèmes clients, les signalements et les attentes pour maintenir un niveau élevé de satisfaction.\nÉtablir des relations solides avec les clients, en s'engageant activement pour évaluer leurs besoins et leur état de santé.\nConserver et faire croître les comptes clients.\nFournir des recommandations basées sur les données pour améliorer les stratégies marketing des clients et augmenter le potentiel de revenus.\nAnalyser les performances des campagnes, créer des rapports détaillés et présenter des insights exploitables aux responsables marketing.\nIdentifier les forces et les faiblesses des implantations clients et collaborer avec les équipes internes sur les solutions.\nDevenir un expert en la matière sur toutes les offres de LoyaltyPlay par Mistplay, en assurant une compréhension approfondie des produits et services.\nSuperviser le processus d'intégration de bout en bout pour les nouveaux clients, en assurant une intégration technique et opérationnelle fluide.\nGérer les outils opérationnels tels que HubSpot et JIRA pour suivre les activités, trier les tickets et transmettre les problèmes au besoin.\nAider à l'assurance qualité (AQ) des campagnes en arrière-plan, au dépannage et aux cas de support liés aux utilisateurs finaux.\nS'associer aux ventes, aux produits et aux autres membres de l'équipe de réussite client pour s'aligner sur les objectifs des clients et fournir des résultats exceptionnels.\nFournir des commentaires aux équipes internes pour améliorer les offres en fonction des retours clients.\nÉlaborer et partager les meilleures pratiques pour permettre une adoption réussie des solutions par les clients.\nCe que vous apporterez à Mistplay\nBaccalauréat en administration des affaires, marketing, communications ou dans un domaine connexe.\nPlus de 2 ans d'expérience en gestion de comptes, en réussite client ou dans un rôle similaire.\nCompréhension approfondie des stratégies et des outils de marketing numérique, avec une expertise spécifique dans les secteurs de la restauration, du commerce de détail et des dépanneurs.\nExcellentes compétences organisationnelles avec la capacité de gérer plusieurs priorités et échéances.\nMaîtrise des outils CRM (ex. HubSpot) et des systèmes de billetterie (ex. JIRA).\nEsprit analytique avec de l'expérience dans le reporting de performance et la prise de décision basée sur les données.\nCompétences exceptionnelles en communication et relations interpersonnelles, avec une attitude axée sur le client.\nCapacité avérée à travailler de manière indépendante dans un environnement 100 % à distance.\nUne expérience dans les secteurs de la technologie publicitaire (ad tech), du jeu vidéo, des plateformes de fidélisation, de la restauration ou des dépanneurs est préférable.\nExpertise technique en intégration, onboarding et analyse de données, avec un accent sur l'identification des opportunités de vente incitative (upsell).\nForte familiarité avec les processus d'assurance qualité des campagnes de marketing de fidélisation et les flux de travail de révision créative.\n \nSenior Customer Success Manager I (Contract) \nThe Senior Customer Success Manager I within Mistplay’s LoyaltyPlay Customer Success department will play a key role in driving the success and growth of our brand partners by acting as their primary point of contact and overseeing the entire customer lifecycle. You will be reporting to the Director, Customer Success. Your role will be to ensure seamless brand integrations and deliver proactive, strategic recommendations that enhance customer satisfaction and foster retention.\nWhat you’ll do at Mistplay\n•Act as the primary liaison between customers and internal teams to ensure clear communication and seamless execution of campaigns.\n•Interact directly with end-users and customer support teams to troubleshoot, verify, and escalate program support issues.\n•Develop and maintain customer flight plans, ensuring timely and accurate execution of all tasks.\n•Lead customer calls, including customer onboarding, training, cross-functional team agendas, and performance reviews.\n•Proactively manage customer issues, escalations, and expectations to maintain high customer satisfaction.\n•Build strong relationships with customers, actively engaging to assess their needs and health status.\n•Retain and grow customer accounts.\n•Provide data-driven recommendations to enhance client marketing strategies and increase revenue potential.\n•Analyze campaign performance, create detailed reports, and present actionable insights to marketing leads.\n•Identify strengths and weaknesses in customer implementations and collaborate with internal teams on solutions.\n•Become a SME on all LoyaltyPlay by Mistplay offerings, ensuring a deep understanding of products and services.\n•Oversee the end-to-end onboarding process for new customers, ensuring smooth technical and operational integration.\n•Manage operational tools like HubSpot and JIRA to track activities, triage tickets, and escalate issues as needed.\n•Assist in backend campaign QA, troubleshooting, and end-user support-related cases.\n•Partner with Sales, Product, and other Client Success team members to align on client goals and deliver outstanding results.\n•Provide feedback to internal teams to improve offerings based on customer input.\n•Build and share best practices to enable successful adoption of solutions by customers.\n \nWhat you’ll bring to Mistplay\n•Bachelor’s degree in Business, Marketing, Communications, or a related field.\n•2+ years of experience in account management, customer success, or a similar role.\n•Deep understanding of digital marketing strategies and tools, with specific expertise in the restaurant, retail, and convenience industries.\n•Excellent organizational skills with the ability to manage multiple priorities and deadlines.\n•Proficiency in CRM tools (e.g., HubSpot) and ticketing systems (e.g., JIRA).\n•Analytical mindset with experience in performance reporting and data-driven decision-making.\n•Exceptional communication and interpersonal skills, with a customer-first attitude.\n•Demonstrates proven ability to work independently in a 100% remote environment.\n•Experience in ad tech, gaming, loyalty platforms, restaurant, and/or convenience store industries is preferred.\n•Technical expertise in onboarding, integrations, and data analysis, with an emphasis on identifying upsell opportunities.\n•Strong familiarity with loyalty marketing campaign QA processes and creative review workflows.\n \n","hostedUrl":"https://jobs.lever.co/mistplay/8d459210-edd2-45dd-9e30-b2e804508018","applyUrl":"https://jobs.lever.co/mistplay/8d459210-edd2-45dd-9e30-b2e804508018/apply"},{"additionalPlain":"*Nous remercions tous(tes) les candidat(e)s. Le genre masculin a été utilisé dans le but d'alléger le texte. Nous souscrivons au principe de l’équité en matière d’emploi.\n\nPourquoi choisir Mistplay ?\n\nNous faisons tout pour rendre notre environnement de travail aussi accueillant et plaisant que possible ! Un poste chez Mistplay s’accompagne de toute une série d'avantages que nous proposons en mode virtuel ou présentiel : déjeuners d'équipe, soirées jeux, événements à l'échelle de l'entreprise, et bien plus encore.\nNotre culture est profondément ancrée dans la croissance et soutenue par une équipe de personnes intelligentes, dynamiques et enthousiastes. Nous utilisons les données pour apprendre, améliorer et adapter en permanence. Nous favorisons un environnement dans lequel chacun est encouragé à partager ses idées, à repousser les limites, à prendre des risques calculés et à voir ses visions se concrétiser.\n\nWhy Mistplay?\n\nWe strive to make our work environment as inviting and fun as possible! Working at Mistplay is coupled with a whole array of perks that we've adopted virtually and in-person: Team Lunches, game nights, company-wide events, and so much more. Our culture is deeply rooted in growth and upheld by a team of smart, dynamic, and enthusiastic people. We utilize data to constantly learn, improve, and adapt. We foster an environment where everyone is encouraged to share their ideas, push boundaries, take calculated risks, and witness their visions come to life.\n","additional":"<div><i style=\"font-size: 16px\">*Nous remercions tous(tes) les candidat(e)s. Le genre masculin a été utilisé dans le but d'alléger le texte. Nous souscrivons au principe de l’équité en matière d’emploi.</i></div><div><br></div><div><b style=\"font-size: 11pt\">Pourquoi choisir Mistplay ?</b></div><div><br></div><div><span style=\"font-size: 11pt\">Nous faisons tout pour rendre notre environnement de travail aussi accueillant et plaisant que possible ! Un poste chez Mistplay s’accompagne de toute une série d'avantages que nous proposons en mode virtuel ou présentiel : déjeuners d'équipe, soirées jeux, événements à l'échelle de l'entreprise, et bien plus encore.</span></div><div><span style=\"font-size: 11pt\">Notre culture est profondément ancrée dans la croissance et soutenue par une équipe de personnes intelligentes, dynamiques et enthousiastes. Nous utilisons les données pour apprendre, améliorer et adapter en permanence. Nous favorisons un environnement dans lequel chacun est encouragé à partager ses idées, à repousser les limites, à prendre des risques calculés et à voir ses visions se concrétiser.</span></div><div><br></div><div><b style=\"font-size: 11pt\">Why Mistplay?</b></div><div><br></div><div><span style=\"font-size: 11pt\">We strive to make our work environment as inviting and fun as possible! Working at Mistplay is coupled with a whole array of perks that we've adopted virtually and in-person: Team Lunches, game nights, company-wide events, and so much more.</span> <span style=\"font-size: 11pt\">Our culture is deeply rooted in growth and upheld by a team of smart, dynamic, and enthusiastic people. We utilize data to constantly learn, improve, and adapt. We foster an environment where everyone is encouraged to share their ideas, push boundaries, take calculated risks, and witness their visions come to life.</span></div>","categories":{"commitment":"Temps Plein / Full Time","department":"Marketing / Marketing","location":"Montreal","team":"Creative","allLocations":["Montreal"]},"createdAt":1786377695277,"descriptionPlain":"Mistplay est l'application de fidélité n°1 pour les joueurs mobiles. Notre communauté de millions de joueurs mobiles engagés utilise Mistplay pour découvrir de nouveaux jeux et gagner des récompenses. Les joueurs sont récompensés pour le temps et l'argent qu'ils consacrent aux jeux et peuvent échanger ces récompenses contre des cartes cadeaux. Mistplay a pour mission d'être le meilleur moyen de jouer à des jeux mobiles pour tous, partout dans le monde ! Téléchargez Mistplay sur le Google Play Store ici et suivez-nous sur Instagram, Twitter et Facebook.\n\n📍 Veuillez noter : Au Canada 🇨🇦, Mistplay suit un modèle hybride de 2 jours/semaine en bureau à Toronto (400 University Ave) & Montréal (1001 Blvd. Robert-Bourassa) \n\nMistplay is the #1 loyalty app for mobile gamers. Our community of millions of engaged mobile gamers come to Mistplay to discover new games to play and earn rewards. Gamers are rewarded for their time and money spent within the games and can redeem those rewards for gift cards. Mistplay is on a mission to be the best way to play mobile games for everyone everywhere! Download Mistplay on the Google Play Store here and follow us on Instagram, Twitter and Facebook.\n\n📍 Please Note: In Canada 🇨🇦, Mistplay follows a 2 days/week in-office hybrid model in Toronto (400 University Ave) & Montreal (1001 Blvd. Robert-Bourassa) \n\n\nWe are looking for a highly skilled, performance-minded Senior Motion Designer to join our Creative Studio. In this role, you will be the creative engine behind our User Acquisition (UA) video ads—crafting high-converting, visually efficient video assets across paid social and programmatic channels (TikTok, Meta, YouTube, Unity, etc.).\nYou’ll blend UGC plates, gameplay storytelling, slick animations, and data-backed creative insights to scale our global user base.\nYou are curious, eager to explore, analyze, and optimize.\nWhat You’ll Do\nEnd-to-End UA Video Production: Concept, storyboard, design, animate, and edit high-impact, short-form video ads optimized for mobile UA channels.\nCreative Iteration & Scaling: Take winning ad concepts and rapidly produce high-quality variations (hooks, CTAs, gameplay mechanics, sound design, visual styles) to combat ad fatigue and test performance hypotheses.\nCollaborate with Creative & UA Teams: Partner closely with Motion Designers, Creative Strategists, Copywriters, and UA Managers to align visual concepts with performance metrics (CPI, ROAS, IPM).\nInnovate & Trend Watch: Stay ahead of mobile gaming trends, platform-specific editing styles (TikTok/Reels native style vs. cinematic gameplay), and creative tech/AI workflows to continuously elevate our creative output.\nMentorship & Quality Standards: Help set the bar for visual quality, animation polish, and workflow efficiency across the UA Creative team.\nWhat We’re Looking For\n5-7+ years of professional motion design experience, with a strong focus on mobile User Acquisition (UA), direct-response marketing, or performance creative.\nMastery of Adobe Creative Cloud (After Effects, Premiere Pro, Photoshop, Illustrator) with deep expertise in 2D animation, typography, editing, visual storytelling, and kinetic text.\nMobile Gaming Native: A strong understanding of mobile game mechanics, gameplay editing, and mobile UI/UX visual language.\nFamiliarity with AI-driven creative tools (Midjourney, Runway, Stable Diffusion) to speed up concepting and asset generation.\nPerformance-Driven Mindset: You don't just care about making things look good—you understand why a 3-second hook works, how visual pacing drives conversion, and how to read creative performance data.\nSound & Storytelling Expertise: Excellent sense of pacing, rhythm, audio mixing, and sound design tailored for short-form, mobile-first video.\nStrong Portfolio/Reel: A robust showreel demonstrating high-end motion graphics, engaging video ads, and dynamic visual storytelling (specifically in mobile, gaming, or app marketing).\nNice-to-Haves\nExperience with 3D motion design (Cinema 4D, Blender, or Maya).\nExperience with interactive ads (playable ads, HTML5) or Unity/Unreal Engine asset extraction.\nWhy Join Mistplay?\nHigh Impact: Your work directly drives company revenue, user growth, and brand presence globally.\nCreative Autonomy: A culture that encourages rapid experimentation, bold concepts, and continuous learning.\nGrowth & Culture: Competitive compensation, comprehensive benefits, flexible working arrangements, and a collaborative team of passionate gamers and creators.\n \n \nFrançais: \nNous sommes à la recherche d'un concepteur ou d'une conceptrice en animation (Senior Motion Designer) de haut niveau, axé sur la performance, pour rejoindre notre studio de création. Dans ce rôle, vous serez le moteur créatif de nos publicités vidéo d'acquisition d'utilisateurs (UA) — en créant des éléments vidéo à fort taux de conversion et visuellement efficaces sur les médias sociaux payants et les canaux programmatiques (TikTok, Meta, YouTube, Unity, etc.).\nVous combinerez des séquences de contenu généré par les utilisateurs (UGC), du récit de jeu (gameplay), des animations fluides et des données créatives pour accroître notre base d'utilisateurs mondiale.\nVous êtes de nature curieuse et vous avez hâte d'explorer, d'analyser et d'optimiser.\nCe que vous ferez\nProduction vidéo UA de bout en bout : Concevoir, scénariser, designer, animer et monter des publicités vidéo percutantes de format court, optimisées pour les canaux d'acquisition mobile.\nItération créative et mise à l'échelle : Reprendre les concepts publicitaires gagnants et produire rapidement des variations de haute qualité (accroches, appels à l'action, mécaniques de jeu, conception sonore, styles visuels) pour contrer la fatigue publicitaire et tester des hypothèses de performance.\nCollaboration avec les équipes créatives et UA : Collaborer étroitement avec les concepteurs en animation, les stratèges créatifs, les rédacteurs et les gestionnaires UA pour aligner les concepts visuels avec les mesures de performance (CPI, ROAS, IPM).\nInnovation et veille sur les tendances : Rester à l'affût des tendances du jeu mobile, des styles de montage propres aux plateformes (style natif TikTok/Reels vs gameplay cinématique) et des flux de travail technologiques créatifs/IA pour élever continuellement notre production créative.\nMentorat et normes de qualité : Aider à établir les standards de qualité visuelle, de finition d'animation et d'efficacité opérationnelle au sein de l'équipe créative UA.\nCe que nous recherchons\n5-7+ ans d'expérience professionnelle en conception d'animation, avec une solide expertise en acquisition d'utilisateurs mobiles (UA), en marketing de réponse directe ou en création axée sur la performance.\nMaîtrise d'Adobe Creative Cloud (After Effects, Premiere Pro, Photoshop, Illustrator) avec une expertise approfondie en animation 2D, typographie, montage, récit visuel et texte cinétique.\nPassionné(e) de jeux mobiles : Une compréhension approfondie des mécaniques de jeu mobile, du montage de gameplay et du langage visuel UI/UX mobile.\nConnaissance des outils créatifs basés sur l'IA (Midjourney, Runway, Stable Diffusion) pour accélérer la conceptualisation et la génération d'actifs.\nEsprit axé sur la performance : Vous ne vous contentez pas de créer de beaux visuels — vous comprenez pourquoi une accroche de 3 secondes fonctionne, comment le rythme visuel stimule la conversion et comment interpréter les données de performance créative.\nExpertise en son et en narration : Un excellent sens du rythme, du mixage audio et de la conception sonore adaptés à la vidéo courte pensée pour le mobile.\nPortfolio ou démo solide : Une démo robuste démontrant des graphismes animés de haute qualité, des publicités vidéo attrayantes et un récit visuel dynamique (particulièrement dans le domaine du mobile, des jeux ou du marketing d'applications).\nAtouts (Nice-to-Haves)\nExpérience en conception d'animation 3D (Cinema 4D, Blender ou Maya).\nExpérience avec les publicités interactives (playable ads, HTML5) ou l'extraction d'actifs Unity/Unreal Engine.\n","description":"<div><span style=\"font-size: 15px\">Mistplay est l'application de fidélité n°1 pour les joueurs mobiles. Notre communauté de millions de joueurs mobiles engagés utilise Mistplay pour découvrir de nouveaux jeux et gagner des récompenses. Les joueurs sont récompensés pour le temps et l'argent qu'ils consacrent aux jeux et peuvent échanger ces récompenses contre des cartes cadeaux. Mistplay a pour mission d'être le meilleur moyen de jouer à des jeux mobiles pour tous, partout dans le monde ! Téléchargez Mistplay sur le Google Play Store </span><a style=\"font-size: 15px\" href=\"https://play.google.com/store/apps/details?id=com.mistplay.mistplay&hl=en_CA&gl=US\">ici</a><span style=\"font-size: 15px\"> et suivez-nous sur </span><a style=\"font-size: 15px\" href=\"https://www.instagram.com/mistplay/?hl=en\">Instagram</a><span style=\"font-size: 15px\">, </span><a style=\"font-size: 15px\" href=\"https://twitter.com/mistplayapp?ref_src=twsrc%5Egoogle%7Ctwcamp%5Eserp%7Ctwgr%5Eauthor\">Twitter</a><span style=\"font-size: 15px\"> et </span><a style=\"font-size: 15px\" href=\"https://www.facebook.com/mistplayapp/\">Facebook</a><span style=\"font-size: 15px\">.</span></div><div><br></div><div>📍 <b><u>Veuillez noter :</u></b> Au Canada 🇨🇦, Mistplay suit un modèle hybride de 2 jours/semaine en bureau à Toronto (400 University Ave) & Montréal (1001 Blvd. Robert-Bourassa) </div><div><br></div><div><span style=\"font-size: 15px\">Mistplay is the #1 loyalty app for mobile gamers. Our community of millions of engaged mobile gamers come to Mistplay to discover new games to play and earn rewards. Gamers are rewarded for their time and money spent within the games and can redeem those rewards for gift cards. Mistplay is on a mission to be the best way to play mobile games for everyone everywhere! Download Mistplay on the Google Play Store </span><a style=\"font-size: 15px\" href=\"https://play.google.com/store/apps/details?id=com.mistplay.mistplay&hl=en_CA&gl=US\">here</a><span style=\"font-size: 15px\"> and follow us on </span><a style=\"font-size: 15px\" href=\"https://www.instagram.com/mistplay/?hl=en\">Instagram</a><span style=\"font-size: 15px\">, </span><a style=\"font-size: 15px\" href=\"https://twitter.com/mistplayapp?ref_src=twsrc%5Egoogle%7Ctwcamp%5Eserp%7Ctwgr%5Eauthor\">Twitter</a><span style=\"font-size: 15px\"> and </span><a style=\"font-size: 15px\" href=\"https://www.facebook.com/mistplayapp/\">Facebook</a><span style=\"font-size: 15px\">.</span></div><div><br></div><div><b>📍 <u>Please Note:</u></b> In Canada 🇨🇦, Mistplay follows a 2 days/week in-office hybrid model in Toronto (400 University Ave) & Montreal (1001 Blvd. Robert-Bourassa) </div><div><br></div><div>\n<p>We are looking for a highly skilled, performance-minded Senior Motion Designer to join our Creative Studio. In this role, you will be the creative engine behind our User Acquisition (UA) video ads—crafting high-converting, visually efficient video assets across paid social and programmatic channels (TikTok, Meta, YouTube, Unity, etc.).</p>\n<p>You’ll blend UGC plates, gameplay storytelling, slick animations, and data-backed creative insights to scale our global user base.</p>\n<p>You are curious, eager to explore, analyze, and optimize.</p>\n<h3><strong>What You’ll Do</strong></h3>\n<ul>\n<li>\n<p>End-to-End UA Video Production: Concept, storyboard, design, animate, and edit high-impact, short-form video ads optimized for mobile UA channels.</p>\n</li>\n<li>\n<p>Creative Iteration & Scaling: Take winning ad concepts and rapidly produce high-quality variations (hooks, CTAs, gameplay mechanics, sound design, visual styles) to combat ad fatigue and test performance hypotheses.</p>\n</li>\n<li>\n<p>Collaborate with Creative & UA Teams: Partner closely with Motion Designers, Creative Strategists, Copywriters, and UA Managers to align visual concepts with performance metrics (CPI, ROAS, IPM).</p>\n</li>\n<li>\n<p>Innovate & Trend Watch: Stay ahead of mobile gaming trends, platform-specific editing styles (TikTok/Reels native style vs. cinematic gameplay), and creative tech/AI workflows to continuously elevate our creative output.</p>\n</li>\n<li>\n<p>Mentorship & Quality Standards: Help set the bar for visual quality, animation polish, and workflow efficiency across the UA Creative team.</p>\n</li>\n</ul>\n<h3><strong>What We’re Looking For</strong></h3>\n<ul>\n<li>\n<p>5-7+ years of professional motion design experience, with a strong focus on mobile User Acquisition (UA), direct-response marketing, or performance creative.</p>\n</li>\n<li>\n<p>Mastery of Adobe Creative Cloud (After Effects, Premiere Pro, Photoshop, Illustrator) with deep expertise in 2D animation, typography, editing, visual storytelling, and kinetic text.</p>\n</li>\n</ul>\n<ul>\n<li>\n<p>Mobile Gaming Native: A strong understanding of mobile game mechanics, gameplay editing, and mobile UI/UX visual language.</p>\n</li>\n<li>Familiarity with AI-driven creative tools (Midjourney, Runway, Stable Diffusion) to speed up concepting and asset generation.</li>\n<li>\n<p>Performance-Driven Mindset: You don't just care about making things look good—you understand why a 3-second hook works, how visual pacing drives conversion, and how to read creative performance data.</p>\n</li>\n<li>\n<p>Sound & Storytelling Expertise: Excellent sense of pacing, rhythm, audio mixing, and sound design tailored for short-form, mobile-first video.</p>\n</li>\n</ul>\n<ul>\n<li>\n<p>Strong Portfolio/Reel: A robust showreel demonstrating high-end motion graphics, engaging video ads, and dynamic visual storytelling (specifically in mobile, gaming, or app marketing).</p>\n</li>\n</ul>\n<h3><strong>Nice-to-Haves</strong></h3>\n<ul>\n<li>\n<p>Experience with 3D motion design (Cinema 4D, Blender, or Maya).</p>\n</li>\n<li>\n<p>Experience with interactive ads (playable ads, HTML5) or Unity/Unreal Engine asset extraction.</p>\n</li>\n</ul>\n<h3><strong>Why Join Mistplay?</strong></h3>\n<ul>\n<li>\n<p>High Impact: Your work directly drives company revenue, user growth, and brand presence globally.</p>\n</li>\n<li>\n<p>Creative Autonomy: A culture that encourages rapid experimentation, bold concepts, and continuous learning.</p>\n</li>\n<li>\n<p>Growth & Culture: Competitive compensation, comprehensive benefits, flexible working arrangements, and a collaborative team of passionate gamers and creators.</p>\n</li>\n</ul>\n</div>\n<div>\n<h3> </h3>\n<h3> </h3>\n<h3><strong>Français: </strong></h3>\n<p>Nous sommes à la recherche d'un concepteur ou d'une conceptrice en animation (Senior Motion Designer) de haut niveau, axé sur la performance, pour rejoindre notre studio de création. Dans ce rôle, vous serez le moteur créatif de nos publicités vidéo d'acquisition d'utilisateurs (UA) — en créant des éléments vidéo à fort taux de conversion et visuellement efficaces sur les médias sociaux payants et les canaux programmatiques (TikTok, Meta, YouTube, Unity, etc.).</p>\n<p>Vous combinerez des séquences de contenu généré par les utilisateurs (UGC), du récit de jeu (gameplay), des animations fluides et des données créatives pour accroître notre base d'utilisateurs mondiale.</p>\n<p>Vous êtes de nature curieuse et vous avez hâte d'explorer, d'analyser et d'optimiser.</p>\n<h3><strong>Ce que vous ferez</strong></h3>\n<ul>\n<li>\n<p>Production vidéo UA de bout en bout : Concevoir, scénariser, designer, animer et monter des publicités vidéo percutantes de format court, optimisées pour les canaux d'acquisition mobile.</p>\n</li>\n<li>\n<p>Itération créative et mise à l'échelle : Reprendre les concepts publicitaires gagnants et produire rapidement des variations de haute qualité (accroches, appels à l'action, mécaniques de jeu, conception sonore, styles visuels) pour contrer la fatigue publicitaire et tester des hypothèses de performance.</p>\n</li>\n<li>\n<p>Collaboration avec les équipes créatives et UA : Collaborer étroitement avec les concepteurs en animation, les stratèges créatifs, les rédacteurs et les gestionnaires UA pour aligner les concepts visuels avec les mesures de performance (CPI, ROAS, IPM).</p>\n</li>\n<li>\n<p>Innovation et veille sur les tendances : Rester à l'affût des tendances du jeu mobile, des styles de montage propres aux plateformes (style natif TikTok/Reels vs gameplay cinématique) et des flux de travail technologiques créatifs/IA pour élever continuellement notre production créative.</p>\n</li>\n<li>\n<p>Mentorat et normes de qualité : Aider à établir les standards de qualité visuelle, de finition d'animation et d'efficacité opérationnelle au sein de l'équipe créative UA.</p>\n</li>\n</ul>\n<h3><strong>Ce que nous recherchons</strong></h3>\n<ul>\n<li>\n<p>5-7+ ans d'expérience professionnelle en conception d'animation, avec une solide expertise en acquisition d'utilisateurs mobiles (UA), en marketing de réponse directe ou en création axée sur la performance.</p>\n</li>\n<li>\n<p>Maîtrise d'Adobe Creative Cloud (After Effects, Premiere Pro, Photoshop, Illustrator) avec une expertise approfondie en animation 2D, typographie, montage, récit visuel et texte cinétique.</p>\n</li>\n<li>\n<p>Passionné(e) de jeux mobiles : Une compréhension approfondie des mécaniques de jeu mobile, du montage de gameplay et du langage visuel UI/UX mobile.</p>\n</li>\n<li>Connaissance des outils créatifs basés sur l'IA (Midjourney, Runway, Stable Diffusion) pour accélérer la conceptualisation et la génération d'actifs.</li>\n<li>\n<p>Esprit axé sur la performance : Vous ne vous contentez pas de créer de beaux visuels — vous comprenez pourquoi une accroche de 3 secondes fonctionne, comment le rythme visuel stimule la conversion et comment interpréter les données de performance créative.</p>\n</li>\n<li>\n<p>Expertise en son et en narration : Un excellent sens du rythme, du mixage audio et de la conception sonore adaptés à la vidéo courte pensée pour le mobile.</p>\n</li>\n<li>\n<p>Portfolio ou démo solide : Une démo robuste démontrant des graphismes animés de haute qualité, des publicités vidéo attrayantes et un récit visuel dynamique (particulièrement dans le domaine du mobile, des jeux ou du marketing d'applications).</p>\n</li>\n</ul>\n<h3><strong>Atouts (Nice-to-Haves)</strong></h3>\n<ul>\n<li>\n<p>Expérience en conception d'animation 3D (Cinema 4D, Blender ou Maya).</p>\n</li>\n<li>\n<p>Expérience avec les publicités interactives (playable ads, HTML5) ou l'extraction d'actifs Unity/Unreal Engine.</p>\n</li>\n</ul>\n</div>","id":"6cf16909-e4a6-439a-8773-21a2827566ba","lists":[],"salaryRange":{"min":87500,"max":128000,"currency":"CAD","interval":"per-year-salary"},"salaryDescription":"<p>The salary listed is based on the average across Montreal & Toronto regions where we have our office spaces. For candidates who demonstrate full readiness for the defined scope of the role, the typical starting salary is $90,500 per year. Offers below this point reflect candidates we believe can grow into the full scope of the role with support and development. Offers above this point reflect impact that meaningfully exceeds the role’s defined expectations or an expanded scope from day one.</p>\n<p>It is possible we will up-level the position as well, based on the successful candidate's experience & expertise!</p>\n<p>At Mistplay, we are very passionate about internal equity, & ensuring all staff members are paid fairly & equally for their position, based on internal & external data. The final salary offered will vary based on candidate's capabilities, experience & location.</p>","salaryDescriptionPlain":"The salary listed is based on the average across Montreal & Toronto regions where we have our office spaces. For candidates who demonstrate full readiness for the defined scope of the role, the typical starting salary is $90,500 per year. Offers below this point reflect candidates we believe can grow into the full scope of the role with support and development. Offers above this point reflect impact that meaningfully exceeds the role’s defined expectations or an expanded scope from day one.\nIt is possible we will up-level the position as well, based on the successful candidate's experience & expertise!\nAt Mistplay, we are very passionate about internal equity, & ensuring all staff members are paid fairly & equally for their position, based on internal & external data. The final salary offered will vary based on candidate's capabilities, experience & location.\n","text":"Senior Motion Designer II","country":"CA","workplaceType":"hybrid","opening":"<div><span style=\"font-size: 15px\">Mistplay est l'application de fidélité n°1 pour les joueurs mobiles. Notre communauté de millions de joueurs mobiles engagés utilise Mistplay pour découvrir de nouveaux jeux et gagner des récompenses. Les joueurs sont récompensés pour le temps et l'argent qu'ils consacrent aux jeux et peuvent échanger ces récompenses contre des cartes cadeaux. Mistplay a pour mission d'être le meilleur moyen de jouer à des jeux mobiles pour tous, partout dans le monde ! Téléchargez Mistplay sur le Google Play Store </span><a href=\"https://play.google.com/store/apps/details?id=com.mistplay.mistplay&hl=en_CA&gl=US\" style=\"font-size: 15px\">ici</a><span style=\"font-size: 15px\"> et suivez-nous sur </span><a href=\"https://www.instagram.com/mistplay/?hl=en\" style=\"font-size: 15px\">Instagram</a><span style=\"font-size: 15px\">, </span><a href=\"https://twitter.com/mistplayapp?ref_src=twsrc%5Egoogle%7Ctwcamp%5Eserp%7Ctwgr%5Eauthor\" style=\"font-size: 15px\">Twitter</a><span style=\"font-size: 15px\"> et </span><a href=\"https://www.facebook.com/mistplayapp/\" style=\"font-size: 15px\">Facebook</a><span style=\"font-size: 15px\">.</span></div><div><br></div><div>📍 <b><u>Veuillez noter :</u></b> Au Canada 🇨🇦, Mistplay suit un modèle hybride de 2 jours/semaine en bureau à Toronto (400 University Ave) & Montréal (1001 Blvd. Robert-Bourassa) </div><div><br></div><div><span style=\"font-size: 15px\">Mistplay is the #1 loyalty app for mobile gamers. Our community of millions of engaged mobile gamers come to Mistplay to discover new games to play and earn rewards. Gamers are rewarded for their time and money spent within the games and can redeem those rewards for gift cards. Mistplay is on a mission to be the best way to play mobile games for everyone everywhere! Download Mistplay on the Google Play Store </span><a href=\"https://play.google.com/store/apps/details?id=com.mistplay.mistplay&hl=en_CA&gl=US\" style=\"font-size: 15px\">here</a><span style=\"font-size: 15px\"> and follow us on </span><a href=\"https://www.instagram.com/mistplay/?hl=en\" style=\"font-size: 15px\">Instagram</a><span style=\"font-size: 15px\">, </span><a href=\"https://twitter.com/mistplayapp?ref_src=twsrc%5Egoogle%7Ctwcamp%5Eserp%7Ctwgr%5Eauthor\" style=\"font-size: 15px\">Twitter</a><span style=\"font-size: 15px\"> and </span><a href=\"https://www.facebook.com/mistplayapp/\" style=\"font-size: 15px\">Facebook</a><span style=\"font-size: 15px\">.</span></div><div><br></div><div><b>📍 <u>Please Note:</u></b> In Canada 🇨🇦, Mistplay follows a 2 days/week in-office hybrid model in Toronto (400 University Ave) & Montreal (1001 Blvd. Robert-Bourassa) </div>","openingPlain":"Mistplay est l'application de fidélité n°1 pour les joueurs mobiles. Notre communauté de millions de joueurs mobiles engagés utilise Mistplay pour découvrir de nouveaux jeux et gagner des récompenses. Les joueurs sont récompensés pour le temps et l'argent qu'ils consacrent aux jeux et peuvent échanger ces récompenses contre des cartes cadeaux. Mistplay a pour mission d'être le meilleur moyen de jouer à des jeux mobiles pour tous, partout dans le monde ! Téléchargez Mistplay sur le Google Play Store ici et suivez-nous sur Instagram, Twitter et Facebook.\n\n📍 Veuillez noter : Au Canada 🇨🇦, Mistplay suit un modèle hybride de 2 jours/semaine en bureau à Toronto (400 University Ave) & Montréal (1001 Blvd. Robert-Bourassa) \n\nMistplay is the #1 loyalty app for mobile gamers. Our community of millions of engaged mobile gamers come to Mistplay to discover new games to play and earn rewards. Gamers are rewarded for their time and money spent within the games and can redeem those rewards for gift cards. Mistplay is on a mission to be the best way to play mobile games for everyone everywhere! Download Mistplay on the Google Play Store here and follow us on Instagram, Twitter and Facebook.\n\n📍 Please Note: In Canada 🇨🇦, Mistplay follows a 2 days/week in-office hybrid model in Toronto (400 University Ave) & Montreal (1001 Blvd. Robert-Bourassa) \n","descriptionBody":"<div>\n<p>We are looking for a highly skilled, performance-minded Senior Motion Designer to join our Creative Studio. In this role, you will be the creative engine behind our User Acquisition (UA) video ads—crafting high-converting, visually efficient video assets across paid social and programmatic channels (TikTok, Meta, YouTube, Unity, etc.).</p>\n<p>You’ll blend UGC plates, gameplay storytelling, slick animations, and data-backed creative insights to scale our global user base.</p>\n<p>You are curious, eager to explore, analyze, and optimize.</p>\n<h3><strong>What You’ll Do</strong></h3>\n<ul>\n<li>\n<p>End-to-End UA Video Production: Concept, storyboard, design, animate, and edit high-impact, short-form video ads optimized for mobile UA channels.</p>\n</li>\n<li>\n<p>Creative Iteration & Scaling: Take winning ad concepts and rapidly produce high-quality variations (hooks, CTAs, gameplay mechanics, sound design, visual styles) to combat ad fatigue and test performance hypotheses.</p>\n</li>\n<li>\n<p>Collaborate with Creative & UA Teams: Partner closely with Motion Designers, Creative Strategists, Copywriters, and UA Managers to align visual concepts with performance metrics (CPI, ROAS, IPM).</p>\n</li>\n<li>\n<p>Innovate & Trend Watch: Stay ahead of mobile gaming trends, platform-specific editing styles (TikTok/Reels native style vs. cinematic gameplay), and creative tech/AI workflows to continuously elevate our creative output.</p>\n</li>\n<li>\n<p>Mentorship & Quality Standards: Help set the bar for visual quality, animation polish, and workflow efficiency across the UA Creative team.</p>\n</li>\n</ul>\n<h3><strong>What We’re Looking For</strong></h3>\n<ul>\n<li>\n<p>5-7+ years of professional motion design experience, with a strong focus on mobile User Acquisition (UA), direct-response marketing, or performance creative.</p>\n</li>\n<li>\n<p>Mastery of Adobe Creative Cloud (After Effects, Premiere Pro, Photoshop, Illustrator) with deep expertise in 2D animation, typography, editing, visual storytelling, and kinetic text.</p>\n</li>\n</ul>\n<ul>\n<li>\n<p>Mobile Gaming Native: A strong understanding of mobile game mechanics, gameplay editing, and mobile UI/UX visual language.</p>\n</li>\n<li>Familiarity with AI-driven creative tools (Midjourney, Runway, Stable Diffusion) to speed up concepting and asset generation.</li>\n<li>\n<p>Performance-Driven Mindset: You don't just care about making things look good—you understand why a 3-second hook works, how visual pacing drives conversion, and how to read creative performance data.</p>\n</li>\n<li>\n<p>Sound & Storytelling Expertise: Excellent sense of pacing, rhythm, audio mixing, and sound design tailored for short-form, mobile-first video.</p>\n</li>\n</ul>\n<ul>\n<li>\n<p>Strong Portfolio/Reel: A robust showreel demonstrating high-end motion graphics, engaging video ads, and dynamic visual storytelling (specifically in mobile, gaming, or app marketing).</p>\n</li>\n</ul>\n<h3><strong>Nice-to-Haves</strong></h3>\n<ul>\n<li>\n<p>Experience with 3D motion design (Cinema 4D, Blender, or Maya).</p>\n</li>\n<li>\n<p>Experience with interactive ads (playable ads, HTML5) or Unity/Unreal Engine asset extraction.</p>\n</li>\n</ul>\n<h3><strong>Why Join Mistplay?</strong></h3>\n<ul>\n<li>\n<p>High Impact: Your work directly drives company revenue, user growth, and brand presence globally.</p>\n</li>\n<li>\n<p>Creative Autonomy: A culture that encourages rapid experimentation, bold concepts, and continuous learning.</p>\n</li>\n<li>\n<p>Growth & Culture: Competitive compensation, comprehensive benefits, flexible working arrangements, and a collaborative team of passionate gamers and creators.</p>\n</li>\n</ul>\n</div>\n<div>\n<h3> </h3>\n<h3> </h3>\n<h3><strong>Français: </strong></h3>\n<p>Nous sommes à la recherche d'un concepteur ou d'une conceptrice en animation (Senior Motion Designer) de haut niveau, axé sur la performance, pour rejoindre notre studio de création. Dans ce rôle, vous serez le moteur créatif de nos publicités vidéo d'acquisition d'utilisateurs (UA) — en créant des éléments vidéo à fort taux de conversion et visuellement efficaces sur les médias sociaux payants et les canaux programmatiques (TikTok, Meta, YouTube, Unity, etc.).</p>\n<p>Vous combinerez des séquences de contenu généré par les utilisateurs (UGC), du récit de jeu (gameplay), des animations fluides et des données créatives pour accroître notre base d'utilisateurs mondiale.</p>\n<p>Vous êtes de nature curieuse et vous avez hâte d'explorer, d'analyser et d'optimiser.</p>\n<h3><strong>Ce que vous ferez</strong></h3>\n<ul>\n<li>\n<p>Production vidéo UA de bout en bout : Concevoir, scénariser, designer, animer et monter des publicités vidéo percutantes de format court, optimisées pour les canaux d'acquisition mobile.</p>\n</li>\n<li>\n<p>Itération créative et mise à l'échelle : Reprendre les concepts publicitaires gagnants et produire rapidement des variations de haute qualité (accroches, appels à l'action, mécaniques de jeu, conception sonore, styles visuels) pour contrer la fatigue publicitaire et tester des hypothèses de performance.</p>\n</li>\n<li>\n<p>Collaboration avec les équipes créatives et UA : Collaborer étroitement avec les concepteurs en animation, les stratèges créatifs, les rédacteurs et les gestionnaires UA pour aligner les concepts visuels avec les mesures de performance (CPI, ROAS, IPM).</p>\n</li>\n<li>\n<p>Innovation et veille sur les tendances : Rester à l'affût des tendances du jeu mobile, des styles de montage propres aux plateformes (style natif TikTok/Reels vs gameplay cinématique) et des flux de travail technologiques créatifs/IA pour élever continuellement notre production créative.</p>\n</li>\n<li>\n<p>Mentorat et normes de qualité : Aider à établir les standards de qualité visuelle, de finition d'animation et d'efficacité opérationnelle au sein de l'équipe créative UA.</p>\n</li>\n</ul>\n<h3><strong>Ce que nous recherchons</strong></h3>\n<ul>\n<li>\n<p>5-7+ ans d'expérience professionnelle en conception d'animation, avec une solide expertise en acquisition d'utilisateurs mobiles (UA), en marketing de réponse directe ou en création axée sur la performance.</p>\n</li>\n<li>\n<p>Maîtrise d'Adobe Creative Cloud (After Effects, Premiere Pro, Photoshop, Illustrator) avec une expertise approfondie en animation 2D, typographie, montage, récit visuel et texte cinétique.</p>\n</li>\n<li>\n<p>Passionné(e) de jeux mobiles : Une compréhension approfondie des mécaniques de jeu mobile, du montage de gameplay et du langage visuel UI/UX mobile.</p>\n</li>\n<li>Connaissance des outils créatifs basés sur l'IA (Midjourney, Runway, Stable Diffusion) pour accélérer la conceptualisation et la génération d'actifs.</li>\n<li>\n<p>Esprit axé sur la performance : Vous ne vous contentez pas de créer de beaux visuels — vous comprenez pourquoi une accroche de 3 secondes fonctionne, comment le rythme visuel stimule la conversion et comment interpréter les données de performance créative.</p>\n</li>\n<li>\n<p>Expertise en son et en narration : Un excellent sens du rythme, du mixage audio et de la conception sonore adaptés à la vidéo courte pensée pour le mobile.</p>\n</li>\n<li>\n<p>Portfolio ou démo solide : Une démo robuste démontrant des graphismes animés de haute qualité, des publicités vidéo attrayantes et un récit visuel dynamique (particulièrement dans le domaine du mobile, des jeux ou du marketing d'applications).</p>\n</li>\n</ul>\n<h3><strong>Atouts (Nice-to-Haves)</strong></h3>\n<ul>\n<li>\n<p>Expérience en conception d'animation 3D (Cinema 4D, Blender ou Maya).</p>\n</li>\n<li>\n<p>Expérience avec les publicités interactives (playable ads, HTML5) ou l'extraction d'actifs Unity/Unreal Engine.</p>\n</li>\n</ul>\n</div>","descriptionBodyPlain":"We are looking for a highly skilled, performance-minded Senior Motion Designer to join our Creative Studio. In this role, you will be the creative engine behind our User Acquisition (UA) video ads—crafting high-converting, visually efficient video assets across paid social and programmatic channels (TikTok, Meta, YouTube, Unity, etc.).\nYou’ll blend UGC plates, gameplay storytelling, slick animations, and data-backed creative insights to scale our global user base.\nYou are curious, eager to explore, analyze, and optimize.\nWhat You’ll Do\nEnd-to-End UA Video Production: Concept, storyboard, design, animate, and edit high-impact, short-form video ads optimized for mobile UA channels.\nCreative Iteration & Scaling: Take winning ad concepts and rapidly produce high-quality variations (hooks, CTAs, gameplay mechanics, sound design, visual styles) to combat ad fatigue and test performance hypotheses.\nCollaborate with Creative & UA Teams: Partner closely with Motion Designers, Creative Strategists, Copywriters, and UA Managers to align visual concepts with performance metrics (CPI, ROAS, IPM).\nInnovate & Trend Watch: Stay ahead of mobile gaming trends, platform-specific editing styles (TikTok/Reels native style vs. cinematic gameplay), and creative tech/AI workflows to continuously elevate our creative output.\nMentorship & Quality Standards: Help set the bar for visual quality, animation polish, and workflow efficiency across the UA Creative team.\nWhat We’re Looking For\n5-7+ years of professional motion design experience, with a strong focus on mobile User Acquisition (UA), direct-response marketing, or performance creative.\nMastery of Adobe Creative Cloud (After Effects, Premiere Pro, Photoshop, Illustrator) with deep expertise in 2D animation, typography, editing, visual storytelling, and kinetic text.\nMobile Gaming Native: A strong understanding of mobile game mechanics, gameplay editing, and mobile UI/UX visual language.\nFamiliarity with AI-driven creative tools (Midjourney, Runway, Stable Diffusion) to speed up concepting and asset generation.\nPerformance-Driven Mindset: You don't just care about making things look good—you understand why a 3-second hook works, how visual pacing drives conversion, and how to read creative performance data.\nSound & Storytelling Expertise: Excellent sense of pacing, rhythm, audio mixing, and sound design tailored for short-form, mobile-first video.\nStrong Portfolio/Reel: A robust showreel demonstrating high-end motion graphics, engaging video ads, and dynamic visual storytelling (specifically in mobile, gaming, or app marketing).\nNice-to-Haves\nExperience with 3D motion design (Cinema 4D, Blender, or Maya).\nExperience with interactive ads (playable ads, HTML5) or Unity/Unreal Engine asset extraction.\nWhy Join Mistplay?\nHigh Impact: Your work directly drives company revenue, user growth, and brand presence globally.\nCreative Autonomy: A culture that encourages rapid experimentation, bold concepts, and continuous learning.\nGrowth & Culture: Competitive compensation, comprehensive benefits, flexible working arrangements, and a collaborative team of passionate gamers and creators.\n \n \nFrançais: \nNous sommes à la recherche d'un concepteur ou d'une conceptrice en animation (Senior Motion Designer) de haut niveau, axé sur la performance, pour rejoindre notre studio de création. Dans ce rôle, vous serez le moteur créatif de nos publicités vidéo d'acquisition d'utilisateurs (UA) — en créant des éléments vidéo à fort taux de conversion et visuellement efficaces sur les médias sociaux payants et les canaux programmatiques (TikTok, Meta, YouTube, Unity, etc.).\nVous combinerez des séquences de contenu généré par les utilisateurs (UGC), du récit de jeu (gameplay), des animations fluides et des données créatives pour accroître notre base d'utilisateurs mondiale.\nVous êtes de nature curieuse et vous avez hâte d'explorer, d'analyser et d'optimiser.\nCe que vous ferez\nProduction vidéo UA de bout en bout : Concevoir, scénariser, designer, animer et monter des publicités vidéo percutantes de format court, optimisées pour les canaux d'acquisition mobile.\nItération créative et mise à l'échelle : Reprendre les concepts publicitaires gagnants et produire rapidement des variations de haute qualité (accroches, appels à l'action, mécaniques de jeu, conception sonore, styles visuels) pour contrer la fatigue publicitaire et tester des hypothèses de performance.\nCollaboration avec les équipes créatives et UA : Collaborer étroitement avec les concepteurs en animation, les stratèges créatifs, les rédacteurs et les gestionnaires UA pour aligner les concepts visuels avec les mesures de performance (CPI, ROAS, IPM).\nInnovation et veille sur les tendances : Rester à l'affût des tendances du jeu mobile, des styles de montage propres aux plateformes (style natif TikTok/Reels vs gameplay cinématique) et des flux de travail technologiques créatifs/IA pour élever continuellement notre production créative.\nMentorat et normes de qualité : Aider à établir les standards de qualité visuelle, de finition d'animation et d'efficacité opérationnelle au sein de l'équipe créative UA.\nCe que nous recherchons\n5-7+ ans d'expérience professionnelle en conception d'animation, avec une solide expertise en acquisition d'utilisateurs mobiles (UA), en marketing de réponse directe ou en création axée sur la performance.\nMaîtrise d'Adobe Creative Cloud (After Effects, Premiere Pro, Photoshop, Illustrator) avec une expertise approfondie en animation 2D, typographie, montage, récit visuel et texte cinétique.\nPassionné(e) de jeux mobiles : Une compréhension approfondie des mécaniques de jeu mobile, du montage de gameplay et du langage visuel UI/UX mobile.\nConnaissance des outils créatifs basés sur l'IA (Midjourney, Runway, Stable Diffusion) pour accélérer la conceptualisation et la génération d'actifs.\nEsprit axé sur la performance : Vous ne vous contentez pas de créer de beaux visuels — vous comprenez pourquoi une accroche de 3 secondes fonctionne, comment le rythme visuel stimule la conversion et comment interpréter les données de performance créative.\nExpertise en son et en narration : Un excellent sens du rythme, du mixage audio et de la conception sonore adaptés à la vidéo courte pensée pour le mobile.\nPortfolio ou démo solide : Une démo robuste démontrant des graphismes animés de haute qualité, des publicités vidéo attrayantes et un récit visuel dynamique (particulièrement dans le domaine du mobile, des jeux ou du marketing d'applications).\nAtouts (Nice-to-Haves)\nExpérience en conception d'animation 3D (Cinema 4D, Blender ou Maya).\nExpérience avec les publicités interactives (playable ads, HTML5) ou l'extraction d'actifs Unity/Unreal Engine.\n","hostedUrl":"https://jobs.lever.co/mistplay/6cf16909-e4a6-439a-8773-21a2827566ba","applyUrl":"https://jobs.lever.co/mistplay/6cf16909-e4a6-439a-8773-21a2827566ba/apply"},{"additionalPlain":"Why Mistplay?\nWe strive to make our work environment as inviting and fun as possible! Working at Mistplay is coupled with a whole array of perks that we've adopted virtually and in-person: Team Lunches, game nights, company-wide events, and so much more. Our culture is deeply rooted in growth and upheld by a team of smart, dynamic, and enthusiastic people. We utilize data to constantly learn, improve, and adapt. We foster an environment where everyone is encouraged to share their ideas, push boundaries, take calculated risks, and witness their visions come to life.\n","additional":"<div><span style=\"font-size: 11pt\"><b>Why Mistplay?</b></span></div><div><span style=\"font-size: 11pt\">We strive to make our work environment as inviting and fun as possible! Working at Mistplay is coupled with a whole array of perks that we've adopted virtually and in-person: Team Lunches, game nights, company-wide events, and so much more.</span> <span style=\"font-size: 11pt\">Our culture is deeply rooted in growth and upheld by a team of smart, dynamic, and enthusiastic people. We utilize data to constantly learn, improve, and adapt. We foster an environment where everyone is encouraged to share their ideas, push boundaries, take calculated risks, and witness their visions come to life.</span></div>","categories":{"commitment":"Temps Plein / Full Time","department":"Données / Data","location":"Los Angeles, CA","team":"Machine Learning","allLocations":["Los Angeles, CA","Oakland, CA","New York, NY","Toronto"]},"createdAt":1768396193198,"descriptionPlain":"Mistplay is the #1 loyalty app for mobile gamers. Our community of millions of engaged mobile gamers come to Mistplay to discover new games to play and earn rewards. Gamers are rewarded for their time and money spent within the games and can redeem those rewards for gift cards. Mistplay is on a mission to be the best way to play mobile games for everyone everywhere! Download Mistplay on the Google Play Store here and follow us on Instagram, Twitter and Facebook.\n\n\nOverview: \n \nMistplay is seeking an innovative Staff Machine Learning Engineer to lead the design and optimization of our rewards economy. In this critical role, you will develop and balance reward systems to ensure they are enticing and add value for our users while maintaining cost-effectiveness for Mistplay. Collaborating with the Data and AI organization, you will lead initiatives in real-time predictive modeling and optimization within our machine learning-driven operations.\n \nWhat You'll Do at Mistplay:\n• You’ll be part of the cross functional group responsible for the overall rewarding and the loyalty economy strategy for Mistplay.\n• Create and implement machine learning driven rewards economy systems and core loops.\n• Continuously analyze and optimize the economy strategies and implementations to enhance engagement and drive business outcomes.\n• Drive the strategy on how machine learning and AI can further advance the optimization for better user experience and business performance.\n• Distill complex model outputs into actionable strategic insights for executive stakeholders, advocating for AI-first approaches to loyalty and retention.\n• Design and productionize contextual bandits and RL frameworks to personalize reward distribution in real-time, ensuring the right user gets the right incentive at the right moment.\n• Serve as the primary architect for Mistplay’s rewards economy, balancing complex variables to ensure a \"win-win\" scenario: maximum value for players and sustainable unit economics for the business.\n• Partner with Product, Analytics, Engineering, and Finance to translate high-level business goals into algorithmic constraints and incentive structures.\n \nWhat You'll Bring to Mistplay:\n• A combination of 8+ years in Data Science, Machine Learning, Quant, Econometrics, and/or Master’s or Ph.D. in Data Science, Machine Learning, or a related quantitative field.\n• Understanding of reinforcement learning (DeepQ, contextual bandits)\n• Expertise in implementing machine learning models using Python and PyTorch, paired with strong data analysis skills using SQL and Python.\n• Fast and agile approach to exploring and implementing solutions with a strategic focus on the core loop and economy design.\n• Experience and background in Quant, Data Science, or related fields, with a foundational understanding of economy and core loop designs in gaming or similar contexts.\n• A passion for innovative AI solutions, with demonstrated knowledge in real-time predictive modeling.\n• Prior experience in the mobile gaming industry with a focus on the economics of player behavior and engagement strategies is a strong nice-to-have.\n \n","description":"<div><span style=\"font-size: 15px;\">Mistplay is the #1 loyalty app for mobile gamers. Our community of millions of engaged mobile gamers come to Mistplay to discover new games to play and earn rewards. Gamers are rewarded for their time and money spent within the games and can redeem those rewards for gift cards. Mistplay is on a mission to be the best way to play mobile games for everyone everywhere! Download Mistplay on the Google Play Store </span><a style=\"font-size: 15px;\" href=\"https://play.google.com/store/apps/details?id=com.mistplay.mistplay&hl=en_CA&gl=US\">here</a><span style=\"font-size: 15px;\"> and follow us on </span><a style=\"font-size: 15px;\" href=\"https://www.instagram.com/mistplay/?hl=en\">Instagram</a><span style=\"font-size: 15px;\">, </span><a style=\"font-size: 15px;\" href=\"https://twitter.com/mistplayapp?ref_src=twsrc%5Egoogle%7Ctwcamp%5Eserp%7Ctwgr%5Eauthor\">Twitter</a><span style=\"font-size: 15px;\"> and </span><a style=\"font-size: 15px;\" href=\"https://www.facebook.com/mistplayapp/\">Facebook</a><span style=\"font-size: 15px;\">.</span></div><div><br></div><div><strong>Overview: </strong></div>\n<div> </div>\n<div>Mistplay is seeking an innovative Staff Machine Learning Engineer to lead the design and optimization of our rewards economy. In this critical role, you will develop and balance reward systems to ensure they are enticing and add value for our users while maintaining cost-effectiveness for Mistplay. Collaborating with the Data and AI organization, you will lead initiatives in real-time predictive modeling and optimization within our machine learning-driven operations.</div>\n<div> </div>\n<div><strong>What You'll Do at Mistplay</strong>:</div>\n<div>• You’ll be part of the cross functional group responsible for the overall rewarding and the loyalty economy strategy for Mistplay.</div>\n<div>• Create and implement machine learning driven rewards economy systems and core loops.</div>\n<div>• Continuously analyze and optimize the economy strategies and implementations to enhance engagement and drive business outcomes.</div>\n<div>• Drive the strategy on how machine learning and AI can further advance the optimization for better user experience and business performance.</div>\n<div>• Distill complex model outputs into actionable strategic insights for executive stakeholders, advocating for AI-first approaches to loyalty and retention.</div>\n<div>• Design and productionize contextual bandits and RL frameworks to personalize reward distribution in real-time, ensuring the right user gets the right incentive at the right moment.</div>\n<div>• Serve as the primary architect for Mistplay’s rewards economy, balancing complex variables to ensure a \"win-win\" scenario: maximum value for players and sustainable unit economics for the business.</div>\n<div>• Partner with Product, Analytics, Engineering, and Finance to translate high-level business goals into algorithmic constraints and incentive structures.</div>\n<div> </div>\n<div><strong>What You'll Bring to Mistplay</strong>:</div>\n<div>• A combination of 8+ years in Data Science, Machine Learning, Quant, Econometrics, and/or Master’s or Ph.D. in Data Science, Machine Learning, or a related quantitative field.</div>\n<div>• Understanding of reinforcement learning (DeepQ, contextual bandits)</div>\n<div>• Expertise in implementing machine learning models using Python and PyTorch, paired with strong data analysis skills using SQL and Python.</div>\n<div>• Fast and agile approach to exploring and implementing solutions with a strategic focus on the core loop and economy design.</div>\n<div>• Experience and background in Quant, Data Science, or related fields, with a foundational understanding of economy and core loop designs in gaming or similar contexts.</div>\n<div>• A passion for innovative AI solutions, with demonstrated knowledge in real-time predictive modeling.</div>\n<div>• Prior experience in the mobile gaming industry with a focus on the economics of player behavior and engagement strategies is a strong nice-to-have.</div>\n<div> </div>","id":"e097bc46-7211-4b95-9e83-cb6b2c467d2b","lists":[],"salaryRange":{"min":180000,"max":220000,"currency":"USD","interval":"per-year-salary"},"salaryDescription":"<p>The salary listed is based on the National Average across the US, & based on base salary only, there is a bonus component & many additional benefits, including equity.</p>\n<p>For candidates who demonstrate full readiness for the defined scope of the role, the typical starting salary is $190,000 USD. Offers below this point reflect candidates we believe can grow into the full scope of the role with support and development. Offers above this point reflect impact that meaningfully exceeds the role’s defined expectations or an expanded scope from day one. </p>\n<p>At Mistplay, we are very passionate about internal equity, & ensuring all staff members are paid fairly & equally for their position. The final salary offered will vary based on candidate experience & location.</p>\n<p><strong>We are also open to up levelling the position to Principal</strong> for candidates that demonstrate this level of expertise - We encourage all to apply! </p>\n<p> </p>","salaryDescriptionPlain":"The salary listed is based on the National Average across the US, & based on base salary only, there is a bonus component & many additional benefits, including equity.\nFor candidates who demonstrate full readiness for the defined scope of the role, the typical starting salary is $190,000 USD. Offers below this point reflect candidates we believe can grow into the full scope of the role with support and development. Offers above this point reflect impact that meaningfully exceeds the role’s defined expectations or an expanded scope from day one. \nAt Mistplay, we are very passionate about internal equity, & ensuring all staff members are paid fairly & equally for their position. The final salary offered will vary based on candidate experience & location.\nWe are also open to up levelling the position to Principal for candidates that demonstrate this level of expertise - We encourage all to apply! \n \n","text":"Staff Machine Learning Engineer, Rewards Economy","country":"US","workplaceType":"remote","opening":"<div><span style=\"font-size: 15px;\">Mistplay is the #1 loyalty app for mobile gamers. Our community of millions of engaged mobile gamers come to Mistplay to discover new games to play and earn rewards. Gamers are rewarded for their time and money spent within the games and can redeem those rewards for gift cards. Mistplay is on a mission to be the best way to play mobile games for everyone everywhere! Download Mistplay on the Google Play Store </span><a href=\"https://play.google.com/store/apps/details?id=com.mistplay.mistplay&hl=en_CA&gl=US\" style=\"font-size: 15px;\">here</a><span style=\"font-size: 15px;\"> and follow us on </span><a href=\"https://www.instagram.com/mistplay/?hl=en\" style=\"font-size: 15px;\">Instagram</a><span style=\"font-size: 15px;\">, </span><a href=\"https://twitter.com/mistplayapp?ref_src=twsrc%5Egoogle%7Ctwcamp%5Eserp%7Ctwgr%5Eauthor\" style=\"font-size: 15px;\">Twitter</a><span style=\"font-size: 15px;\"> and </span><a href=\"https://www.facebook.com/mistplayapp/\" style=\"font-size: 15px;\">Facebook</a><span style=\"font-size: 15px;\">.</span></div>","openingPlain":"Mistplay is the #1 loyalty app for mobile gamers. Our community of millions of engaged mobile gamers come to Mistplay to discover new games to play and earn rewards. Gamers are rewarded for their time and money spent within the games and can redeem those rewards for gift cards. Mistplay is on a mission to be the best way to play mobile games for everyone everywhere! Download Mistplay on the Google Play Store here and follow us on Instagram, Twitter and Facebook.\n","descriptionBody":"<div><strong>Overview: </strong></div>\n<div> </div>\n<div>Mistplay is seeking an innovative Staff Machine Learning Engineer to lead the design and optimization of our rewards economy. In this critical role, you will develop and balance reward systems to ensure they are enticing and add value for our users while maintaining cost-effectiveness for Mistplay. Collaborating with the Data and AI organization, you will lead initiatives in real-time predictive modeling and optimization within our machine learning-driven operations.</div>\n<div> </div>\n<div><strong>What You'll Do at Mistplay</strong>:</div>\n<div>• You’ll be part of the cross functional group responsible for the overall rewarding and the loyalty economy strategy for Mistplay.</div>\n<div>• Create and implement machine learning driven rewards economy systems and core loops.</div>\n<div>• Continuously analyze and optimize the economy strategies and implementations to enhance engagement and drive business outcomes.</div>\n<div>• Drive the strategy on how machine learning and AI can further advance the optimization for better user experience and business performance.</div>\n<div>• Distill complex model outputs into actionable strategic insights for executive stakeholders, advocating for AI-first approaches to loyalty and retention.</div>\n<div>• Design and productionize contextual bandits and RL frameworks to personalize reward distribution in real-time, ensuring the right user gets the right incentive at the right moment.</div>\n<div>• Serve as the primary architect for Mistplay’s rewards economy, balancing complex variables to ensure a \"win-win\" scenario: maximum value for players and sustainable unit economics for the business.</div>\n<div>• Partner with Product, Analytics, Engineering, and Finance to translate high-level business goals into algorithmic constraints and incentive structures.</div>\n<div> </div>\n<div><strong>What You'll Bring to Mistplay</strong>:</div>\n<div>• A combination of 8+ years in Data Science, Machine Learning, Quant, Econometrics, and/or Master’s or Ph.D. in Data Science, Machine Learning, or a related quantitative field.</div>\n<div>• Understanding of reinforcement learning (DeepQ, contextual bandits)</div>\n<div>• Expertise in implementing machine learning models using Python and PyTorch, paired with strong data analysis skills using SQL and Python.</div>\n<div>• Fast and agile approach to exploring and implementing solutions with a strategic focus on the core loop and economy design.</div>\n<div>• Experience and background in Quant, Data Science, or related fields, with a foundational understanding of economy and core loop designs in gaming or similar contexts.</div>\n<div>• A passion for innovative AI solutions, with demonstrated knowledge in real-time predictive modeling.</div>\n<div>• Prior experience in the mobile gaming industry with a focus on the economics of player behavior and engagement strategies is a strong nice-to-have.</div>\n<div> </div>","descriptionBodyPlain":"Overview: \n \nMistplay is seeking an innovative Staff Machine Learning Engineer to lead the design and optimization of our rewards economy. In this critical role, you will develop and balance reward systems to ensure they are enticing and add value for our users while maintaining cost-effectiveness for Mistplay. Collaborating with the Data and AI organization, you will lead initiatives in real-time predictive modeling and optimization within our machine learning-driven operations.\n \nWhat You'll Do at Mistplay:\n• You’ll be part of the cross functional group responsible for the overall rewarding and the loyalty economy strategy for Mistplay.\n• Create and implement machine learning driven rewards economy systems and core loops.\n• Continuously analyze and optimize the economy strategies and implementations to enhance engagement and drive business outcomes.\n• Drive the strategy on how machine learning and AI can further advance the optimization for better user experience and business performance.\n• Distill complex model outputs into actionable strategic insights for executive stakeholders, advocating for AI-first approaches to loyalty and retention.\n• Design and productionize contextual bandits and RL frameworks to personalize reward distribution in real-time, ensuring the right user gets the right incentive at the right moment.\n• Serve as the primary architect for Mistplay’s rewards economy, balancing complex variables to ensure a \"win-win\" scenario: maximum value for players and sustainable unit economics for the business.\n• Partner with Product, Analytics, Engineering, and Finance to translate high-level business goals into algorithmic constraints and incentive structures.\n \nWhat You'll Bring to Mistplay:\n• A combination of 8+ years in Data Science, Machine Learning, Quant, Econometrics, and/or Master’s or Ph.D. in Data Science, Machine Learning, or a related quantitative field.\n• Understanding of reinforcement learning (DeepQ, contextual bandits)\n• Expertise in implementing machine learning models using Python and PyTorch, paired with strong data analysis skills using SQL and Python.\n• Fast and agile approach to exploring and implementing solutions with a strategic focus on the core loop and economy design.\n• Experience and background in Quant, Data Science, or related fields, with a foundational understanding of economy and core loop designs in gaming or similar contexts.\n• A passion for innovative AI solutions, with demonstrated knowledge in real-time predictive modeling.\n• Prior experience in the mobile gaming industry with a focus on the economics of player behavior and engagement strategies is a strong nice-to-have.\n \n","hostedUrl":"https://jobs.lever.co/mistplay/e097bc46-7211-4b95-9e83-cb6b2c467d2b","applyUrl":"https://jobs.lever.co/mistplay/e097bc46-7211-4b95-9e83-cb6b2c467d2b/apply"}] | |
| \ No newline at end of file | ||
added
tests/fixtures/mistplay/expected.json
+47 −0
@@ -0,0 +1,47 @@ | ||
| 1 | +{ | |
| 2 | + "count": 3, | |
| 3 | + "jobs": [ | |
| 4 | + { | |
| 5 | + "uid": "mistplay:50f40ccd-a45a-4290-919f-1295a08207da", | |
| 6 | + "url": "https://jobs.lever.co/mistplay/50f40ccd-a45a-4290-919f-1295a08207da", | |
| 7 | + "title": "Customer Support Specialist // Spécialiste de l'Expérience Client", | |
| 8 | + "employer": "Mistplay", | |
| 9 | + "city": "", | |
| 10 | + "location_label": "Montreal", | |
| 11 | + "salary_min": null, | |
| 12 | + "salary_max": null, | |
| 13 | + "salary_unit": null, | |
| 14 | + "employment_type": null, | |
| 15 | + "ats": "lever", | |
| 16 | + "desc_len": 6414 | |
| 17 | + }, | |
| 18 | + { | |
| 19 | + "uid": "mistplay:6cf16909-e4a6-439a-8773-21a2827566ba", | |
| 20 | + "url": "https://jobs.lever.co/mistplay/6cf16909-e4a6-439a-8773-21a2827566ba", | |
| 21 | + "title": "Senior Motion Designer II", | |
| 22 | + "employer": "Mistplay", | |
| 23 | + "city": "", | |
| 24 | + "location_label": "Montreal", | |
| 25 | + "salary_min": 87500, | |
| 26 | + "salary_max": 128000, | |
| 27 | + "salary_unit": "year", | |
| 28 | + "employment_type": null, | |
| 29 | + "ats": "lever", | |
| 30 | + "desc_len": 6735 | |
| 31 | + }, | |
| 32 | + { | |
| 33 | + "uid": "mistplay:8d459210-edd2-45dd-9e30-b2e804508018", | |
| 34 | + "url": "https://jobs.lever.co/mistplay/8d459210-edd2-45dd-9e30-b2e804508018", | |
| 35 | + "title": "Senior Customer Success Manager I (Contract) // Gestionnaire de la réussite client (Contrat)", | |
| 36 | + "employer": "Mistplay", | |
| 37 | + "city": "", | |
| 38 | + "location_label": "Montreal", | |
| 39 | + "salary_min": null, | |
| 40 | + "salary_max": null, | |
| 41 | + "salary_unit": null, | |
| 42 | + "employment_type": null, | |
| 43 | + "ats": "lever", | |
| 44 | + "desc_len": 7707 | |
| 45 | + } | |
| 46 | + ] | |
| 47 | +} | |
| \ No newline at end of file | ||
added
tests/fixtures/mistplay/index.json
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +{ | |
| 2 | + "b53675f2801b6632ebba": { | |
| 3 | + "method": "GET", | |
| 4 | + "url": "https://api.lever.co/v0/postings/mistplay?mode=json", | |
| 5 | + "status": 200, | |
| 6 | + "content_type": "application/json; charset=utf-8", | |
| 7 | + "file": "b53675f2801b6632ebba.json" | |
| 8 | + } | |
| 9 | +} | |
| \ No newline at end of file | ||
added
tests/fixtures/poka/0b773b59b34e454db34d.json
+1 −0
@@ -0,0 +1 @@ | ||
| 1 | +{"jobs":[{"absolute_url":"https://job-boards.greenhouse.io/poka/jobs/6067288004","data_compliance":[{"type":"gdpr","requires_consent":true,"requires_processing_consent":false,"requires_retention_consent":true,"retention_period":730,"demographic_data_consent_applies":false}],"internal_job_id":5156255004,"location":{"name":"United States (Central or East Coast)"},"metadata":null,"id":6067288004,"updated_at":"2026-06-16T05:06:55-04:00","requisition_id":"NA0243","title":"Account Executive, Enterprise Sales","company_name":"Poka EN","first_published":"2026-06-16T05:06:55-04:00","language":"en","application_deadline":null,"content":"\u0026lt;div class=\u0026quot;content-intro\u0026quot;\u0026gt;\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;At Poka, we’re transforming how frontline teams operate, learn, and solve problems.\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;Our connected worker platform is purpose-built for manufacturing, empowering frontline workers to share knowledge, collaborate in real-time, and drive continuous improvement. Think of it as the missing digital link, bringing the human interface into the stack of the Smart Factory, generating the right data and insights that drive entirely new scales of process improvements.\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;We’re proud to support global leaders like Nestlé, Bosch, Mars, and Danone in their digital transformation journeys and as part of the IFS family, our reach and impact continue to grow globally.\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;hr\u0026gt;\n\u0026lt;p\u0026gt;\u0026amp;nbsp;\u0026lt;/p\u0026gt;\u0026lt;/div\u0026gt;\u0026lt;div\u0026gt;\n\u0026lt;p data-start=\u0026quot;691\u0026quot; data-end=\u0026quot;840\u0026quot;\u0026gt;If you thrive on building relationships, opening doors, and helping manufacturers modernize the way their frontline teams work, this role is for you.\u0026lt;/p\u0026gt;\n\u0026lt;p data-start=\u0026quot;842\u0026quot; data-end=\u0026quot;1221\u0026quot;\u0026gt;We’re looking for an Enterprise Account Executive to join our growing Sales team and help bring Poka’s connected worker platform to manufacturers around the world. You’ll work with some of the industry’s most recognized brands, helping them tackle operational challenges, accelerate digital transformation, and unlock measurable business impact across their frontline operations.\u0026lt;/p\u0026gt;\n\u0026lt;p data-start=\u0026quot;1223\u0026quot; data-end=\u0026quot;1388\u0026quot;\u0026gt;This is a high-impact role for someone who combines strong commercial instincts with a genuine understanding of manufacturing environments and enterprise SaaS sales.\u0026lt;/p\u0026gt;\n\u0026lt;h2 data-section-id=\u0026quot;dhw6ft\u0026quot; data-start=\u0026quot;1390\u0026quot; data-end=\u0026quot;1413\u0026quot;\u0026gt;What you’ll be doing\u0026lt;/h2\u0026gt;\n\u0026lt;ul data-start=\u0026quot;1415\u0026quot; data-end=\u0026quot;2406\u0026quot;\u0026gt;\n\u0026lt;li data-section-id=\u0026quot;1ad8caa\u0026quot; data-start=\u0026quot;1415\u0026quot; data-end=\u0026quot;1562\u0026quot;\u0026gt;Identify and develop new business opportunities through market research, outbound prospecting, and collaboration with the Sales Development team.\u0026lt;/li\u0026gt;\n\u0026lt;li data-section-id=\u0026quot;3rg715\u0026quot; data-start=\u0026quot;1563\u0026quot; data-end=\u0026quot;1702\u0026quot;\u0026gt;Own the full sales cycle, from discovery and needs analysis through product demonstrations, proposal development, negotiation, and close.\u0026lt;/li\u0026gt;\n\u0026lt;li data-section-id=\u0026quot;1rwjogj\u0026quot; data-start=\u0026quot;1703\u0026quot; data-end=\u0026quot;1839\u0026quot;\u0026gt;Build strong business cases tailored to each prospect’s operational priorities, demonstrating the value and impact of Poka’s platform.\u0026lt;/li\u0026gt;\n\u0026lt;li data-section-id=\u0026quot;1td0z9p\u0026quot; data-start=\u0026quot;1840\u0026quot; data-end=\u0026quot;1977\u0026quot;\u0026gt;Partner closely with Customer Success and cross-functional teams to ensure a seamless customer journey and long-term value realization.\u0026lt;/li\u0026gt;\n\u0026lt;li data-section-id=\u0026quot;gz1jw3\u0026quot; data-start=\u0026quot;1978\u0026quot; data-end=\u0026quot;2072\u0026quot;\u0026gt;Maintain accurate opportunity tracking, forecasting, and account activity within Salesforce.\u0026lt;/li\u0026gt;\n\u0026lt;li data-section-id=\u0026quot;iw4eq3\u0026quot; data-start=\u0026quot;2073\u0026quot; data-end=\u0026quot;2193\u0026quot;\u0026gt;Share customer insights and market feedback with leadership to help shape go-to-market strategy and product direction.\u0026lt;/li\u0026gt;\n\u0026lt;li data-section-id=\u0026quot;164m0gt\u0026quot; data-start=\u0026quot;2194\u0026quot; data-end=\u0026quot;2305\u0026quot;\u0026gt;Contribute to lead generation initiatives and campaigns by leveraging customer knowledge and industry trends.\u0026lt;/li\u0026gt;\n\u0026lt;li data-section-id=\u0026quot;k86h8j\u0026quot; data-start=\u0026quot;2306\u0026quot; data-end=\u0026quot;2406\u0026quot;\u0026gt;Represent Poka at trade shows, industry events, networking opportunities, and virtual conferences.\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;h2 data-section-id=\u0026quot;bhay4c\u0026quot; data-start=\u0026quot;2408\u0026quot; data-end=\u0026quot;2425\u0026quot;\u0026gt;What you bring\u0026lt;/h2\u0026gt;\n\u0026lt;ul data-start=\u0026quot;2427\u0026quot; data-end=\u0026quot;3143\u0026quot;\u0026gt;\n\u0026lt;li data-section-id=\u0026quot;1hglcbe\u0026quot; data-start=\u0026quot;2427\u0026quot; data-end=\u0026quot;2515\u0026quot;\u0026gt;5+ years of experience in enterprise B2B sales or a comparable Account Executive role.\u0026lt;/li\u0026gt;\n\u0026lt;li data-section-id=\u0026quot;7pn8f0\u0026quot; data-start=\u0026quot;2516\u0026quot; data-end=\u0026quot;2652\u0026quot;\u0026gt;Proven experience selling SaaS solutions, ideally within manufacturing, industrial technology, or digital transformation environments.\u0026lt;/li\u0026gt;\n\u0026lt;li data-section-id=\u0026quot;2dv82d\u0026quot; data-start=\u0026quot;2749\u0026quot; data-end=\u0026quot;2835\u0026quot;\u0026gt;Excellent communication and presentation skills in English, both written and spoken.\u0026lt;/li\u0026gt;\n\u0026lt;li data-section-id=\u0026quot;125i2os\u0026quot; data-start=\u0026quot;2836\u0026quot; data-end=\u0026quot;2932\u0026quot;\u0026gt;Confidence leading complex sales conversations with multiple stakeholders and decision-makers.\u0026lt;/li\u0026gt;\n\u0026lt;li data-section-id=\u0026quot;1l6gkm\u0026quot; data-start=\u0026quot;2933\u0026quot; data-end=\u0026quot;3008\u0026quot;\u0026gt;Strong negotiation, relationship-building, and problem-solving abilities.\u0026lt;/li\u0026gt;\n\u0026lt;li data-section-id=\u0026quot;179msqz\u0026quot; data-start=\u0026quot;3009\u0026quot; data-end=\u0026quot;3096\u0026quot;\u0026gt;A results-driven mindset with a track record of achieving or exceeding sales targets.\u0026lt;/li\u0026gt;\n\u0026lt;li data-section-id=\u0026quot;mics9y\u0026quot; data-start=\u0026quot;3097\u0026quot; data-end=\u0026quot;3143\u0026quot;\u0026gt;Ability and willingness to travel up to 40%.\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;h2 data-section-id=\u0026quot;vket06\u0026quot; data-start=\u0026quot;3145\u0026quot; data-end=\u0026quot;3156\u0026quot;\u0026gt;Why Poka\u0026lt;/h2\u0026gt;\n\u0026lt;ul data-start=\u0026quot;3158\u0026quot; data-end=\u0026quot;3555\u0026quot; data-is-last-node=\u0026quot;\u0026quot; data-is-only-node=\u0026quot;\u0026quot;\u0026gt;\n\u0026lt;li data-section-id=\u0026quot;9mmiuc\u0026quot; data-start=\u0026quot;3158\u0026quot; data-end=\u0026quot;3242\u0026quot;\u0026gt;Join a fast-growing company shaping the future of connected work in manufacturing.\u0026lt;/li\u0026gt;\n\u0026lt;li data-section-id=\u0026quot;1n8375d\u0026quot; data-start=\u0026quot;3243\u0026quot; data-end=\u0026quot;3346\u0026quot;\u0026gt;Work alongside passionate, collaborative teams that care deeply about innovation and customer impact.\u0026lt;/li\u0026gt;\n\u0026lt;li data-section-id=\u0026quot;1rmof08\u0026quot; data-start=\u0026quot;3347\u0026quot; data-end=\u0026quot;3452\u0026quot;\u0026gt;Be part of a global organization backed by IFS, with opportunities to grow your career internationally.\u0026lt;/li\u0026gt;\n\u0026lt;li data-section-id=\u0026quot;1h3gy14\u0026quot; data-start=\u0026quot;3453\u0026quot; data-end=\u0026quot;3555\u0026quot; data-is-last-node=\u0026quot;\u0026quot;\u0026gt;Help some of the world’s leading manufacturers empower their frontline workforce through technology.\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;span style=\u0026quot;font-weight: 400; color: #ffffff;\u0026quot;\u0026gt;#LI-TK1\u0026lt;/span\u0026gt;\u0026lt;/p\u0026gt;\u0026lt;div class=\u0026quot;content-conclusion\u0026quot;\u0026gt;\u0026lt;div\u0026gt;\n\u0026lt;p data-ghq-card-content-type=\u0026quot;paragraph\u0026quot;\u0026gt;\u0026lt;strong\u0026gt;What we offer:\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;div data-slate-fragment=\u0026quot;JTdCJTIyb2JqZWN0JTIyJTNBJTIyZG9jdW1lbnQlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMldoYXQlMjB3ZSUyMG9mZmVyJTNBJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJtYXJrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJPTEQlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIybWFyayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJJVEFMSUMlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMkR5bmFtaWMlMjBhbmQlMjBjb2xsYWJvcmF0aXZlJTIwd29yayUyMGVudmlyb25tZW50LiUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMkdlbmVyb3VzJTIwdmFjYXRpb24lMjBwb2xpY3klMjB1cG9uJTIwYXJyaXZhbC4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjIxMDAlMjUlMjByZW1vdGUlMjBvciUyMGh5YnJpZCUyMG1vZGUlMjAoYWNjZXNzJTIwdG8lMjBvdXIlMjBvZmZpY2VzJTIwaW4lMjBRdWViZWMlMjBDaXR5JTIwYW5kJTIwTW9udHJlYWwpJTJDJTIwZGVwZW5kaW5nJTIwb24lMjB5b3VyJTIwcHJlZmVyZW5jZS4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJUcnVseSUyMGZsZXhpYmxlJTIwc2NoZWR1bGUlMjBhbmQlMjB0aGUlMjBwb3NzaWJpbGl0eSUyMG9mJTIwdGFraW5nJTIwZXZlcnklMjBGcmlkYXklMjBhZnRlcm5vb24lMjBvZmYuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyR3JvdXAlMjBpbnN1cmFuY2UlMjBmcm9tJTIwZGF5JTIwb25lJTIwKGRlbnRhbCUyQyUyMG1lZGljYXRpb24lMkMlMjBpbnZhbGlkaXR5JTJDJTIwdHJhdmVsJTJDJTIwZXRjLikuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyVGVsZW1lZGljaW5lJTIwc2VydmljZSUyMHdpdGglMjBEaWFsb2d1ZSUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMlJldGlyZW1lbnQlMjBwbGFuJTIwd2l0aCUyMFdlYWx0aHNpbXBsZSUyMGFmdGVyJTIwYSUyMHllYXIuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyTWFjQm9vayUyMFBybyUyMGFuZCUyMCUyNDc1MCUyMGFsbG93YW5jZSUyMGZvciUyMHlvdXIlMjBob21lJTIwb2ZmaWNlLiUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMlRhc3R5JTIwc25hY2tzJTIwKEZyZXNoJTIwZnJ1aXRzJTJDJTIwc25hY2tzJTJDJTIwY29mZmVlcyUyQyUyMHRlYXMlMkMlMjBldGMuKS4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJHZW5lcm91cyUyMHJlZmVycmFsJTIwYm9udXMuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyV2hhdGV2ZXIlMjB5b3UlMjBuZWVkJTIwdG8lMjBrZWVwJTIwbGVhcm5pbmclMjBhbmQlMjBldm9sdmUlMjBwcm9mZXNzaW9uYWxseS4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJSYXBpZCUyMGV2b2x1dGlvbiUyMGluJTIweW91ciUyMHJvbGUuJTIwWW91ciUyMHJlc3BvbnNpYmlsaXRpZXMlMjB3aWxsJTIwbm90JTIwYmUlMjBsaW1pdGVkISUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdE\u0026quot;\u0026gt;\n\u0026lt;ul\u0026gt;\n\u0026lt;li\u0026gt;\u0026lt;span data-ghq-card-content-type=\u0026quot;paragraph\u0026quot;\u0026gt;Dynamic and collaborative work environment.\u0026lt;/span\u0026gt;\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;143\u0026quot; data-end=\u0026quot;199\u0026quot;\u0026gt;A generous vacation policy starting on your first day.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Hybrid work policy - Employees who live within a 40 km radius of our Québec City office or a 15 km radius of our Montréal office are required to work in-office two days per week.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;100% remote work if you do not live within the radiuses mentioned above.\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;435\u0026quot; data-end=\u0026quot;534\u0026quot;\u0026gt;A truly flexible schedule that allows you to adapt your work hours.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Group insurance from day one (dental, medication, disability, travel, etc.) with employer contributions, including access to telemedicine and an Employee Assistance Program (EAP).\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A retirement savings plan with employer contributions.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A MacBook Pro and a $500 home office allowance upon hiring.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A generous annual learning and development budget to support your growth, plus the tools and opportunities you need to keep progressing in your career.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A rewarding employee referral program for successful hires.\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;875\u0026quot; data-end=\u0026quot;969\u0026quot;\u0026gt;Tasty snacks available at all times in our offices (fresh fruits, snacks, pastries, coffee, tea, etc.)\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;em style=\u0026quot;color: rgb(126, 140, 141);\u0026quot;\u0026gt;*Certain benefits are exclusively accessible to employees in Canada.\u0026lt;/em\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;em\u0026gt;Want to learn more about our culture? Click \u0026lt;a href=\u0026quot;https://2394300.fs1.hubspotusercontent-na1.net/hubfs/2394300/HR%20files/CultureBook-En-screen.pdf\u0026quot; target=\u0026quot;_blank\u0026quot;\u0026gt;HERE\u0026lt;/a\u0026gt;.\u0026lt;/em\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;Please note: \u0026lt;/strong\u0026gt;An understanding of English\u0026amp;nbsp;is essential at Poka, as it is the language used during company-wide meetings and many internal communications, ensuring effective collaboration and decision-making. In addition, as Poka operates in an international environment, English is the primary language for communicating with our clients and partners, enabling us to better serve them and represent Poka in a professional manner.\u0026lt;/p\u0026gt;\u0026lt;/div\u0026gt;","departments":[{"id":4046826004,"name":"Sales / Ventes","child_ids":[],"parent_id":null}],"offices":[{"id":4027731004,"name":"Poka HQ","location":"Québec, Quebec, Canada","child_ids":[],"parent_id":null},{"id":4027732004,"name":"Poka Montreal","location":"Montréal, Quebec, Canada","child_ids":[],"parent_id":null},{"id":4027735004,"name":"Remote - Canada","location":"Alberta, Canada","child_ids":[],"parent_id":null},{"id":4027740004,"name":"Remote - US","location":"Arizona, United States","child_ids":[],"parent_id":null}],"ai_disclaimer":null,"include_ai_disclaimer":null,"ai_opt_out_request_url":null},{"absolute_url":"https://job-boards.greenhouse.io/poka/jobs/6114006004","data_compliance":[{"type":"gdpr","requires_consent":true,"requires_processing_consent":false,"requires_retention_consent":true,"retention_period":730,"demographic_data_consent_applies":false}],"internal_job_id":5186503004,"location":{"name":"Québec, Quebec, Canada, Montréal, Quebec, Canada"},"metadata":null,"id":6114006004,"updated_at":"2026-07-23T13:41:12-04:00","requisition_id":"NA0255","title":"Accounting Analyst","company_name":"Poka EN","first_published":"2026-07-14T10:01:41-04:00","language":"en","application_deadline":null,"content":"\u0026lt;div class=\u0026quot;content-intro\u0026quot;\u0026gt;\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;At Poka, we’re transforming how frontline teams operate, learn, and solve problems.\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;Our connected worker platform is purpose-built for manufacturing, empowering frontline workers to share knowledge, collaborate in real-time, and drive continuous improvement. Think of it as the missing digital link, bringing the human interface into the stack of the Smart Factory, generating the right data and insights that drive entirely new scales of process improvements.\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;We’re proud to support global leaders like Nestlé, Bosch, Mars, and Danone in their digital transformation journeys and as part of the IFS family, our reach and impact continue to grow globally.\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;hr\u0026gt;\n\u0026lt;p\u0026gt;\u0026amp;nbsp;\u0026lt;/p\u0026gt;\u0026lt;/div\u0026gt;\u0026lt;div\u0026gt;\n\u0026lt;p class=\u0026quot;font-claude-response-body break-words whitespace-normal\u0026quot;\u0026gt;Now we\u0026#39;re looking for an Accounting Analyst who can read the story the numbers tell, question it, and make it better.\u0026lt;/p\u0026gt;\n\u0026lt;p class=\u0026quot;font-claude-response-body break-words whitespace-normal\u0026quot;\u0026gt;\u0026lt;strong\u0026gt;About the role\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p class=\u0026quot;font-claude-response-body break-words whitespace-normal\u0026quot;\u0026gt;Reporting to the Assistant Controller, you\u0026#39;ll play a hands-on role in the financial accounting of a growing technology company with clients around the world. Poka still operates with a start-up mindset, which means the work rewards people who are open-minded, autonomous, and resourceful. In return, you get a lot of freedom and real room to grow, both professionally and personally.\u0026lt;/p\u0026gt;\n\u0026lt;p class=\u0026quot;font-claude-response-body break-words whitespace-normal\u0026quot;\u0026gt;\u0026lt;strong\u0026gt;What you\u0026#39;ll be doing\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;ul class=\u0026quot;[li_\u0026amp;amp;]:mb-0 [li_\u0026amp;amp;]:mt-1 [li_\u0026amp;amp;]:gap-1 [\u0026amp;amp;:not(:last-child)_ul]:pb-1 [\u0026amp;amp;:not(:last-child)_ol]:pb-1 list-disc flex flex-col gap-1 pl-8 mb-3\u0026quot;\u0026gt;\n\u0026lt;li class=\u0026quot;font-claude-response-body whitespace-normal break-words pl-2\u0026quot;\u0026gt;Accounts payable oversight: Review expense processing operations, making sure invoices, credit card transactions, and expense reports are recorded accurately and on time. Offer coaching and support to team members to promote quality and consistency in how things are done.\u0026lt;/li\u0026gt;\n\u0026lt;li class=\u0026quot;font-claude-response-body whitespace-normal break-words pl-2\u0026quot;\u0026gt;Month-end close: Support the monthly close process, including preparing and recording expense-related entries (prepaid expenses, accrued charges, fixed assets, etc.).\u0026lt;/li\u0026gt;\n\u0026lt;li class=\u0026quot;font-claude-response-body whitespace-normal break-words pl-2\u0026quot;\u0026gt;Cash management: Prepare weekly payment batches and review cash flow forecasts to ensure they\u0026#39;re reliable and accurate.\u0026lt;/li\u0026gt;\n\u0026lt;li class=\u0026quot;font-claude-response-body whitespace-normal break-words pl-2\u0026quot;\u0026gt;Payroll and benefits support: Support our payroll specialist in managing payroll. Coordinate the setup and administration of benefits for European employees. Act as the point of contact between payroll providers and employees for payroll and benefits questions.\u0026lt;/li\u0026gt;\n\u0026lt;li class=\u0026quot;font-claude-response-body whitespace-normal break-words pl-2\u0026quot;\u0026gt;Employee experience and documentation: Guide employees through payroll and benefits platform processes. Create and maintain explanatory documents (e.g., Guru cards) to clarify payroll and benefits processes.\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;p class=\u0026quot;font-claude-response-body break-words whitespace-normal\u0026quot;\u0026gt;\u0026lt;strong\u0026gt;What you bring\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;ul class=\u0026quot;[li_\u0026amp;amp;]:mb-0 [li_\u0026amp;amp;]:mt-1 [li_\u0026amp;amp;]:gap-1 [\u0026amp;amp;:not(:last-child)_ul]:pb-1 [\u0026amp;amp;:not(:last-child)_ol]:pb-1 list-disc flex flex-col gap-1 pl-8 mb-3\u0026quot;\u0026gt;\n\u0026lt;li class=\u0026quot;font-claude-response-body whitespace-normal break-words pl-2\u0026quot;\u0026gt;A DEC or higher in Accounting or a related field\u0026lt;/li\u0026gt;\n\u0026lt;li class=\u0026quot;font-claude-response-body whitespace-normal break-words pl-2\u0026quot;\u0026gt;Advanced knowledge of French and English, both written and spoken\u0026lt;/li\u0026gt;\n\u0026lt;li class=\u0026quot;font-claude-response-body whitespace-normal break-words pl-2\u0026quot;\u0026gt;Excellent knowledge of Excel\u0026lt;/li\u0026gt;\n\u0026lt;li class=\u0026quot;font-claude-response-body whitespace-normal break-words pl-2\u0026quot;\u0026gt;Experience with project management (an asset)\u0026lt;/li\u0026gt;\n\u0026lt;li class=\u0026quot;font-claude-response-body whitespace-normal break-words pl-2\u0026quot;\u0026gt;Knowledge of Salesforce and FinancialForce (an asset)\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;p class=\u0026quot;font-claude-response-body break-words whitespace-normal\u0026quot;\u0026gt;You\u0026#39;re a self-starter who works proactively and doesn\u0026#39;t wait to be told what needs doing. You\u0026#39;re ingenious and resourceful, with a strong analytical mind, and you bring professionalism and rigour to your work. And you\u0026#39;re a team player who genuinely likes collaborating across departments.\u0026lt;/p\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;div data-slate-fragment=\u0026quot;JTdCJTIyb2JqZWN0JTIyJTNBJTIyZG9jdW1lbnQlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMlBva2ElMjBpcyUyMGxvb2tpbmclMjBmb3IlMjBhbiUyMEFjY291bnRpbmclMjBBbmFseXN0JTIwdG8lMjBqb2luJTIwb3VyJTIwZmlyZSUyMHRlYW0hJTIwVW5kZXIlMjB0aGUlMjBzdXBlcnZpc2lvbiUyMG9mJTIwb3VyJTIwQ29udHJvbGxlciUyQyUyMHRoaXMlMjBpcyUyMHlvdXIlMjBjaGFuY2UlMjB0byUyMG1ha2UlMjBhbiUyMGltcGFjdCUyMGluJTIwYSUyMGdyb3dpbmclMjB0ZWNobm9sb2d5JTIwY29tcGFueSUyMHdpdGglMjBjbGllbnRzJTIwYWxsJTIwb3ZlciUyMHRoZSUyMHdvcmxkJTIwYnklMjBleGVjdXRpbmclMjB0aGUlMjBmaW5hbmNpYWwlMjBhY2NvdW50aW5nJTIwb2YlMjB0aGUlMjBjb21wYW55ISUyMEFzJTIwUG9rYSUyMGlzJTIwYSUyMHN0YXJ0LXVwJTJDJTIwd2UlMjBtdXN0JTIwYmUlMjBvcGVuLW1pbmRlZCUyQyUyMGF1dG9ub21vdXMlMjBhbmQlMjByZXNvdXJjZWZ1bCUyMGluJTIwb3VyJTIwZGFpbHklMjB0YXNrcyElMjBHcmVhdCUyMGNvbXBhbnklMjB0byUyMGdyb3clMjBwcm9mZXNzaW9uYWxseSUyMGFuZCUyMHBlcnNvbmFsbHkhJUMyJUEwJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMldoYXQlMjB5b3UlRTIlODAlOTlsbCUyMGJlJTIwcmVzcG9uc2libGUlMjBmb3IlM0ElMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMm1hcmslMjIlMkMlMjJ0eXBlJTIyJTNBJTIySVRBTElDJTIyJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMm1hcmslMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQk9MRCUyMiUyQyUyMmRhdGElMjIlM0ElN0IlN0QlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyV29ya2luZyUyMGluJTIwY29sbGFib3JhdGlvbiUyMHdpdGglMjB0aGUlMjBzYWxlcyUyMGRlcGFydG1lbnQlMjB0byUyMGVuc3VyZSUyMHRoZSUyMHByb3BlciUyMHByb2Nlc3NpbmclMjBvZiUyMHRoZSUyMGFubnVhbCUyMGxpY2Vuc2UlMjBjb250cmFjdHMlMjAoaW52b2ljaW5nJTJDJTIwcmV2ZW51ZSUyMGFjY291bnRpbmclMkMlMjBldGMuKSUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMkFzc2lzdGluZyUyMGluJTIwdGhlJTIwcHJlcGFyYXRpb24lMjBvZiUyMHRoZSUyMGFubnVhbCUyMGZpbmFuY2lhbCUyMGF1ZGl0JTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyQXNzaXN0aW5nJTIwaW4lMjB0aGUlMjBwcmVwYXJhdGlvbiUyMG9mJTIwQ2FuYWRpYW4lMjBhbmQlMjBBbWVyaWNhbiUyMHNhbGVzJTIwdGF4JTIwcmVwb3J0cyUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMkFzc2lzdGluZyUyMGluJTIwdGhlJTIwcHJlcGFyYXRpb24lMjBhbmQlMjBmb2xsb3ctdXAlMjBvZiUyMG1vbnRobHklMjBidWRnZXRzJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyUGFydGljaXBhdGluZyUyMGFjdGl2ZWx5JTIwaW4lMjB0aGUlMjBtb250aC1lbmQlMjBhbmQlMjB5ZWFyLWVuZCUyMHByb2Nlc3MlMjAocmV2ZW51ZSUyMHJlY29uY2lsaWF0aW9uJTJDJTIwZGVmZXJyZWQlMjByZXZlbnVlJTIwcmVjb25jaWxpYXRpb24lMkMlMjBldGMuKSUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMlByZXBhcmF0aW9uJTIwYW5kJTIwYW5hbHlzaXMlMjBvZiUyMG1vbnRobHklMjBmaW5hbmNpYWwlMjBzdGF0ZW1lbnRzJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyQW5hbHlzaXMlMjBhbmQlMjBjYWxjdWxhdGlvbiUyMG9mJTIwdW5kZXJ3cml0aW5nJTIwY29zdHMlMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJQcm9jZXNzJTIwb3B0aW1pemF0aW9uJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyQWNjb3VudGluZyUyMHNvZnR3YXJlJTIwb3B0aW1pemF0aW9uJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyUGFydGljaXBhdGUlMjBpbiUyMHZhcmlvdXMlMjBpbnRlcm5hbCUyMGNvbXBhbnklMjBwcm9qZWN0cyUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJQZW9wbGUlMjBkZXNjcmliZSUyMHlvdSUyMGFzJTNBJTIwJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJtYXJrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJPTEQlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIybWFyayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJJVEFMSUMlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMlNlbGYtc3RhcnRlciUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMlNlbGYlMjBtb3RpdmF0ZWQlMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJJbmdlbmlvdXMlMjBhbmQlMjByZXNvdXJjZWZ1bCUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMkhhdmluZyUyMGElMjBnb29kJTIwYW5hbHl0aWNhbCUyMG1pbmQlMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJQcm9mZXNzaW9uYWwlMjBhbmQlMjByaWdvcm91cyUyMGF0JTIwd29yayUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMkElMjB0ZWFtJTIwcGxheWVyJTVDbiUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJXaGF0JTIweW91JTIwYnJpbmclMjB0byUyMHRoZSUyMHRhYmxlJTNBJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJtYXJrJTIyJTJDJTIydHlwZSUyMiUzQSUyMklUQUxJQyUyMiUyQyUyMmRhdGElMjIlM0ElN0IlN0QlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJtYXJrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJPTEQlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMkNQQSUyMGRlc2lnbmF0aW9uJTIwb3IlMjBpbiUyMHRoZSUyMHByb2Nlc3MlMjBvZiUyMG9idGFpbmluZyUyMHlvdXIlMjBDUEElMjBkZXNpZ25hdGlvbiUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMkJldHdlZW4lMjAyJTIwYW5kJTIwNCUyMHllYXJzJTIwb2YlMjBleHBlcmllbmNlJTIwaW4lMjBhY2NvdW50aW5nJTIwKGJ1c2luZXNzJTIwb3IlMjBmaXJtKSUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMkV4cGVyaWVuY2UlMjB3aXRoJTIwcHJvamVjdCUyMG1hbmFnZW1lbnQlMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJBZHZhbmNlZCUyMGtub3dsZWRnZSUyMG9mJTIwRnJlbmNoJTIwYW5kJTIwRW5nbGlzaCUyQyUyMGJvdGglMjB3cml0dGVuJTIwYW5kJTIwc3Bva2VuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyRXhjZWxsZW50JTIwa25vd2xlZGdlJTIwb2YlMjBFeGNlbCUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMktub3dsZWRnZSUyMG9mJTIwU2FsZXNGb3JjZSUyMGFuZCUyMEZpbmFuY2lhbEZvcmNlJTIwYW4lMjBhc3NldCUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdE\u0026quot;\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;span data-ghq-card-content-type=\u0026quot;paragraph\u0026quot;\u0026gt;#LI-FH1\u0026lt;/span\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;/div\u0026gt;\u0026lt;div class=\u0026quot;content-conclusion\u0026quot;\u0026gt;\u0026lt;div\u0026gt;\n\u0026lt;p data-ghq-card-content-type=\u0026quot;paragraph\u0026quot;\u0026gt;\u0026lt;strong\u0026gt;What we offer:\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;div data-slate-fragment=\u0026quot;JTdCJTIyb2JqZWN0JTIyJTNBJTIyZG9jdW1lbnQlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMldoYXQlMjB3ZSUyMG9mZmVyJTNBJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJtYXJrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJPTEQlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIybWFyayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJJVEFMSUMlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMkR5bmFtaWMlMjBhbmQlMjBjb2xsYWJvcmF0aXZlJTIwd29yayUyMGVudmlyb25tZW50LiUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMkdlbmVyb3VzJTIwdmFjYXRpb24lMjBwb2xpY3klMjB1cG9uJTIwYXJyaXZhbC4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjIxMDAlMjUlMjByZW1vdGUlMjBvciUyMGh5YnJpZCUyMG1vZGUlMjAoYWNjZXNzJTIwdG8lMjBvdXIlMjBvZmZpY2VzJTIwaW4lMjBRdWViZWMlMjBDaXR5JTIwYW5kJTIwTW9udHJlYWwpJTJDJTIwZGVwZW5kaW5nJTIwb24lMjB5b3VyJTIwcHJlZmVyZW5jZS4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJUcnVseSUyMGZsZXhpYmxlJTIwc2NoZWR1bGUlMjBhbmQlMjB0aGUlMjBwb3NzaWJpbGl0eSUyMG9mJTIwdGFraW5nJTIwZXZlcnklMjBGcmlkYXklMjBhZnRlcm5vb24lMjBvZmYuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyR3JvdXAlMjBpbnN1cmFuY2UlMjBmcm9tJTIwZGF5JTIwb25lJTIwKGRlbnRhbCUyQyUyMG1lZGljYXRpb24lMkMlMjBpbnZhbGlkaXR5JTJDJTIwdHJhdmVsJTJDJTIwZXRjLikuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyVGVsZW1lZGljaW5lJTIwc2VydmljZSUyMHdpdGglMjBEaWFsb2d1ZSUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMlJldGlyZW1lbnQlMjBwbGFuJTIwd2l0aCUyMFdlYWx0aHNpbXBsZSUyMGFmdGVyJTIwYSUyMHllYXIuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyTWFjQm9vayUyMFBybyUyMGFuZCUyMCUyNDc1MCUyMGFsbG93YW5jZSUyMGZvciUyMHlvdXIlMjBob21lJTIwb2ZmaWNlLiUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMlRhc3R5JTIwc25hY2tzJTIwKEZyZXNoJTIwZnJ1aXRzJTJDJTIwc25hY2tzJTJDJTIwY29mZmVlcyUyQyUyMHRlYXMlMkMlMjBldGMuKS4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJHZW5lcm91cyUyMHJlZmVycmFsJTIwYm9udXMuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyV2hhdGV2ZXIlMjB5b3UlMjBuZWVkJTIwdG8lMjBrZWVwJTIwbGVhcm5pbmclMjBhbmQlMjBldm9sdmUlMjBwcm9mZXNzaW9uYWxseS4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJSYXBpZCUyMGV2b2x1dGlvbiUyMGluJTIweW91ciUyMHJvbGUuJTIwWW91ciUyMHJlc3BvbnNpYmlsaXRpZXMlMjB3aWxsJTIwbm90JTIwYmUlMjBsaW1pdGVkISUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdE\u0026quot;\u0026gt;\n\u0026lt;ul\u0026gt;\n\u0026lt;li\u0026gt;\u0026lt;span data-ghq-card-content-type=\u0026quot;paragraph\u0026quot;\u0026gt;Dynamic and collaborative work environment.\u0026lt;/span\u0026gt;\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;143\u0026quot; data-end=\u0026quot;199\u0026quot;\u0026gt;A generous vacation policy starting on your first day.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Hybrid work policy - Employees who live within a 40 km radius of our Québec City office or a 15 km radius of our Montréal office are required to work in-office two days per week.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;100% remote work if you do not live within the radiuses mentioned above.\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;435\u0026quot; data-end=\u0026quot;534\u0026quot;\u0026gt;A truly flexible schedule that allows you to adapt your work hours.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Group insurance from day one (dental, medication, disability, travel, etc.) with employer contributions, including access to telemedicine and an Employee Assistance Program (EAP).\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A retirement savings plan with employer contributions.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A MacBook Pro and a $500 home office allowance upon hiring.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A generous annual learning and development budget to support your growth, plus the tools and opportunities you need to keep progressing in your career.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A rewarding employee referral program for successful hires.\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;875\u0026quot; data-end=\u0026quot;969\u0026quot;\u0026gt;Tasty snacks available at all times in our offices (fresh fruits, snacks, pastries, coffee, tea, etc.)\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;em style=\u0026quot;color: rgb(126, 140, 141);\u0026quot;\u0026gt;*Certain benefits are exclusively accessible to employees in Canada.\u0026lt;/em\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;em\u0026gt;Want to learn more about our culture? Click \u0026lt;a href=\u0026quot;https://2394300.fs1.hubspotusercontent-na1.net/hubfs/2394300/HR%20files/CultureBook-En-screen.pdf\u0026quot; target=\u0026quot;_blank\u0026quot;\u0026gt;HERE\u0026lt;/a\u0026gt;.\u0026lt;/em\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;Please note: \u0026lt;/strong\u0026gt;An understanding of English\u0026amp;nbsp;is essential at Poka, as it is the language used during company-wide meetings and many internal communications, ensuring effective collaboration and decision-making. In addition, as Poka operates in an international environment, English is the primary language for communicating with our clients and partners, enabling us to better serve them and represent Poka in a professional manner.\u0026lt;/p\u0026gt;\u0026lt;/div\u0026gt;","departments":[{"id":4046833004,"name":"General \u0026 Administration / Général \u0026 Administration","child_ids":[],"parent_id":null}],"offices":[{"id":4027731004,"name":"Poka HQ","location":"Québec, Quebec, Canada","child_ids":[],"parent_id":null},{"id":4027732004,"name":"Poka Montreal","location":"Montréal, Quebec, Canada","child_ids":[],"parent_id":null}],"ai_disclaimer":null,"include_ai_disclaimer":null,"ai_opt_out_request_url":null},{"absolute_url":"https://job-boards.greenhouse.io/poka/jobs/6103724004","data_compliance":[{"type":"gdpr","requires_consent":true,"requires_processing_consent":false,"requires_retention_consent":true,"retention_period":730,"demographic_data_consent_applies":false}],"internal_job_id":5180016004,"location":{"name":"Montreal, Canada"},"metadata":null,"id":6103724004,"updated_at":"2026-07-06T11:30:40-04:00","requisition_id":"NA0258","title":"Customer Marketing Manager","company_name":"Poka EN","first_published":"2026-06-29T14:20:37-04:00","language":"en","application_deadline":null,"content":"\u0026lt;div class=\u0026quot;content-intro\u0026quot;\u0026gt;\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;At Poka, we’re transforming how frontline teams operate, learn, and solve problems.\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;Our connected worker platform is purpose-built for manufacturing, empowering frontline workers to share knowledge, collaborate in real-time, and drive continuous improvement. Think of it as the missing digital link, bringing the human interface into the stack of the Smart Factory, generating the right data and insights that drive entirely new scales of process improvements.\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;We’re proud to support global leaders like Nestlé, Bosch, Mars, and Danone in their digital transformation journeys and as part of the IFS family, our reach and impact continue to grow globally.\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;hr\u0026gt;\n\u0026lt;p\u0026gt;\u0026amp;nbsp;\u0026lt;/p\u0026gt;\u0026lt;/div\u0026gt;\u0026lt;p\u0026gt;At Poka, we\u0026#39;re transforming how frontline teams operate, learn, and solve problems. Our connected worker platform is purpose-built for manufacturing, empowering frontline workers to share knowledge, collaborate in real-time, and drive continuous improvement.\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;Think of it as the missing digital link, bringing the human interface into the stack of the Smart Factory, generating the right data and insights that drive entirely new scales of process improvements.\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;We\u0026#39;re proud to support global leaders like Nestlé, Bosch, Mars, and Danone on their digital transformation journeys. And as part of the IFS family, our global reach and impact continue to grow.\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;The best customer stories are earned.\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;If you\u0026#39;ve built an advocacy program from real relationships, not reference requests fired off when Sales needs a logo, this role was built for you.\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;About the role\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;The Customer Marketing Manager owns how Poka\u0026#39;s customers show up in the market: through case studies, advisory boards, user groups, and the kind of relationships that make a customer pick up the phone when we ask. You\u0026#39;ll work closely with Customer Success, Sales, and Product to build an advocacy engine that keeps pace with our growth and gives the field the proof points it needs to close deals.\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;This is not a content coordination role. The right person is comfortable in a boardroom with a VP of Operations, knows how to ask a plant manager for a reference without making it feel like a favour, and understands that the relationship with a customer executive \u0026lt;em\u0026gt;is\u0026lt;/em\u0026gt; the asset. The case study is just a byproduct.\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;What you\u0026#39;ll be doing\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;ul\u0026gt;\n\u0026lt;li\u0026gt;Build and maintain a pipeline of customer advocates, references, case study subjects, speaker candidates, and site tour hosts, with a focus on cultivating relationships with executives who can speak to business outcomes, not just product features.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Own the Customer Advisory Board end to end: agenda design, participant selection, pre- and post-session follow-through, and turning what you hear into something actionable for Product and Marketing.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Run regional user groups and virtual roundtables that manufacturing leaders actually want to attend. Not webinars with a different name, sessions where peers trade real problems and real fixes.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Develop customer stories across formats: written case studies, short-form video, ROI narratives, and customer-authored content for events and thought leadership programs.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Build out the customer events calendar, including Poka-hosted sessions, customer tracks at industry events like IFS Unleashed, and onsite visits that double as storytelling opportunities.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Partner with Campaign and Content teams to make sure customer proof points land where they matter, ads, nurture flows, sales decks, web pages, instead of sitting in a shared drive.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Track what\u0026#39;s working: reference velocity, case study pipeline health, CAB participation, and community engagement. Bring those numbers to team planning with clear read-outs.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Carry the customer\u0026#39;s perspective back into Marketing, flagging when messaging doesn\u0026#39;t match what customers actually say, and pushing for the language that does.\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;What you bring\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;ul\u0026gt;\n\u0026lt;li\u0026gt;4–6 years in customer marketing, community management, customer success, or B2B field marketing, including time in enterprise SaaS or industrial technology.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;You\u0026#39;ve \u0026lt;em\u0026gt;built\u0026lt;/em\u0026gt; an advocacy program, not just contributed to one. You know what a reference pipeline looks like when it\u0026#39;s healthy, and what it looks like right before it dries up.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A track record of engaging plant managers, operations directors, and manufacturing VPs as long-term relationships, not one-time contacts. You build trust over time and ask for things, a reference, a quote, a speaking slot, in a way that strengthens the relationship rather than drawing it down.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;The judgment that comes from sourcing advocates out of a base of busy executives without burning goodwill. That instinct comes from doing the work, not observing it.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Strong writing. You can take a one-hour customer interview and turn it into something a prospect would actually read.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Working knowledge of HubSpot and Salesforce for tracking programs and contacts.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;The organization to run several programs at once without letting any of them slip.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;French fluency is a meaningful asset given Poka\u0026#39;s customer base.\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;Why Poka\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;You\u0026#39;ll sit at the intersection of customer relationships, marketing, and frontline manufacturing, close enough to the work to see the impact, with the autonomy to shape how an entire advocacy function runs. As Poka grows within the IFS family, the customer stories you build become the proof points that carry the brand into new markets. If you want ownership and a program you can genuinely call yours, this is it.\u0026lt;/p\u0026gt;\u0026lt;div class=\u0026quot;content-conclusion\u0026quot;\u0026gt;\u0026lt;div\u0026gt;\n\u0026lt;p data-ghq-card-content-type=\u0026quot;paragraph\u0026quot;\u0026gt;\u0026lt;strong\u0026gt;What we offer:\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;div data-slate-fragment=\u0026quot;JTdCJTIyb2JqZWN0JTIyJTNBJTIyZG9jdW1lbnQlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMldoYXQlMjB3ZSUyMG9mZmVyJTNBJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJtYXJrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJPTEQlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIybWFyayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJJVEFMSUMlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMkR5bmFtaWMlMjBhbmQlMjBjb2xsYWJvcmF0aXZlJTIwd29yayUyMGVudmlyb25tZW50LiUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMkdlbmVyb3VzJTIwdmFjYXRpb24lMjBwb2xpY3klMjB1cG9uJTIwYXJyaXZhbC4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjIxMDAlMjUlMjByZW1vdGUlMjBvciUyMGh5YnJpZCUyMG1vZGUlMjAoYWNjZXNzJTIwdG8lMjBvdXIlMjBvZmZpY2VzJTIwaW4lMjBRdWViZWMlMjBDaXR5JTIwYW5kJTIwTW9udHJlYWwpJTJDJTIwZGVwZW5kaW5nJTIwb24lMjB5b3VyJTIwcHJlZmVyZW5jZS4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJUcnVseSUyMGZsZXhpYmxlJTIwc2NoZWR1bGUlMjBhbmQlMjB0aGUlMjBwb3NzaWJpbGl0eSUyMG9mJTIwdGFraW5nJTIwZXZlcnklMjBGcmlkYXklMjBhZnRlcm5vb24lMjBvZmYuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyR3JvdXAlMjBpbnN1cmFuY2UlMjBmcm9tJTIwZGF5JTIwb25lJTIwKGRlbnRhbCUyQyUyMG1lZGljYXRpb24lMkMlMjBpbnZhbGlkaXR5JTJDJTIwdHJhdmVsJTJDJTIwZXRjLikuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyVGVsZW1lZGljaW5lJTIwc2VydmljZSUyMHdpdGglMjBEaWFsb2d1ZSUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMlJldGlyZW1lbnQlMjBwbGFuJTIwd2l0aCUyMFdlYWx0aHNpbXBsZSUyMGFmdGVyJTIwYSUyMHllYXIuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyTWFjQm9vayUyMFBybyUyMGFuZCUyMCUyNDc1MCUyMGFsbG93YW5jZSUyMGZvciUyMHlvdXIlMjBob21lJTIwb2ZmaWNlLiUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMlRhc3R5JTIwc25hY2tzJTIwKEZyZXNoJTIwZnJ1aXRzJTJDJTIwc25hY2tzJTJDJTIwY29mZmVlcyUyQyUyMHRlYXMlMkMlMjBldGMuKS4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJHZW5lcm91cyUyMHJlZmVycmFsJTIwYm9udXMuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyV2hhdGV2ZXIlMjB5b3UlMjBuZWVkJTIwdG8lMjBrZWVwJTIwbGVhcm5pbmclMjBhbmQlMjBldm9sdmUlMjBwcm9mZXNzaW9uYWxseS4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJSYXBpZCUyMGV2b2x1dGlvbiUyMGluJTIweW91ciUyMHJvbGUuJTIwWW91ciUyMHJlc3BvbnNpYmlsaXRpZXMlMjB3aWxsJTIwbm90JTIwYmUlMjBsaW1pdGVkISUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdE\u0026quot;\u0026gt;\n\u0026lt;ul\u0026gt;\n\u0026lt;li\u0026gt;\u0026lt;span data-ghq-card-content-type=\u0026quot;paragraph\u0026quot;\u0026gt;Dynamic and collaborative work environment.\u0026lt;/span\u0026gt;\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;143\u0026quot; data-end=\u0026quot;199\u0026quot;\u0026gt;A generous vacation policy starting on your first day.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Hybrid work policy - Employees who live within a 40 km radius of our Québec City office or a 15 km radius of our Montréal office are required to work in-office two days per week.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;100% remote work if you do not live within the radiuses mentioned above.\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;435\u0026quot; data-end=\u0026quot;534\u0026quot;\u0026gt;A truly flexible schedule that allows you to adapt your work hours.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Group insurance from day one (dental, medication, disability, travel, etc.) with employer contributions, including access to telemedicine and an Employee Assistance Program (EAP).\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A retirement savings plan with employer contributions.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A MacBook Pro and a $500 home office allowance upon hiring.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A generous annual learning and development budget to support your growth, plus the tools and opportunities you need to keep progressing in your career.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A rewarding employee referral program for successful hires.\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;875\u0026quot; data-end=\u0026quot;969\u0026quot;\u0026gt;Tasty snacks available at all times in our offices (fresh fruits, snacks, pastries, coffee, tea, etc.)\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;em style=\u0026quot;color: rgb(126, 140, 141);\u0026quot;\u0026gt;*Certain benefits are exclusively accessible to employees in Canada.\u0026lt;/em\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;em\u0026gt;Want to learn more about our culture? Click \u0026lt;a href=\u0026quot;https://2394300.fs1.hubspotusercontent-na1.net/hubfs/2394300/HR%20files/CultureBook-En-screen.pdf\u0026quot; target=\u0026quot;_blank\u0026quot;\u0026gt;HERE\u0026lt;/a\u0026gt;.\u0026lt;/em\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;Please note: \u0026lt;/strong\u0026gt;An understanding of English\u0026amp;nbsp;is essential at Poka, as it is the language used during company-wide meetings and many internal communications, ensuring effective collaboration and decision-making. In addition, as Poka operates in an international environment, English is the primary language for communicating with our clients and partners, enabling us to better serve them and represent Poka in a professional manner.\u0026lt;/p\u0026gt;\u0026lt;/div\u0026gt;","departments":[{"id":4046829004,"name":"Marketing","child_ids":[],"parent_id":null}],"offices":[{"id":4027731004,"name":"Poka HQ","location":"Québec, Quebec, Canada","child_ids":[],"parent_id":null},{"id":4027732004,"name":"Poka Montreal","location":"Montréal, Quebec, Canada","child_ids":[],"parent_id":null}],"ai_disclaimer":null,"include_ai_disclaimer":null,"ai_opt_out_request_url":null},{"absolute_url":"https://job-boards.greenhouse.io/poka/jobs/6134510004","data_compliance":[{"type":"gdpr","requires_consent":true,"requires_processing_consent":false,"requires_retention_consent":true,"retention_period":730,"demographic_data_consent_applies":false}],"internal_job_id":5195439004,"location":{"name":"Québec \u0026 Montréal "},"metadata":null,"id":6134510004,"updated_at":"2026-08-05T15:38:27-04:00","requisition_id":"NA0264","title":"Frontend Developer ","company_name":"Poka EN","first_published":"2026-08-05T15:38:27-04:00","language":"en","application_deadline":null,"content":"\u0026lt;div class=\u0026quot;content-intro\u0026quot;\u0026gt;\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;At Poka, we’re transforming how frontline teams operate, learn, and solve problems.\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;Our connected worker platform is purpose-built for manufacturing, empowering frontline workers to share knowledge, collaborate in real-time, and drive continuous improvement. Think of it as the missing digital link, bringing the human interface into the stack of the Smart Factory, generating the right data and insights that drive entirely new scales of process improvements.\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;We’re proud to support global leaders like Nestlé, Bosch, Mars, and Danone in their digital transformation journeys and as part of the IFS family, our reach and impact continue to grow globally.\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;hr\u0026gt;\n\u0026lt;p\u0026gt;\u0026amp;nbsp;\u0026lt;/p\u0026gt;\u0026lt;/div\u0026gt;\u0026lt;h3 data-start=\u0026quot;204\u0026quot; data-end=\u0026quot;223\u0026quot;\u0026gt;\u0026lt;/h3\u0026gt;\n\u0026lt;h3\u0026gt;\u0026lt;strong\u0026gt;What You’ll Do\u0026lt;/strong\u0026gt;\u0026lt;/h3\u0026gt;\n\u0026lt;p data-start=\u0026quot;803\u0026quot; data-end=\u0026quot;1086\u0026quot;\u0026gt;As a Frontend Developer at Poka, you’ll play a key role in building features that have a real impact on the daily lives of frontline workers. You’ll work closely with Product, Design, and other Engineers to deliver a world-class user experience and continuously improve how we build.\u0026lt;/p\u0026gt;\n\u0026lt;p data-start=\u0026quot;1088\u0026quot; data-end=\u0026quot;1097\u0026quot;\u0026gt;You will:\u0026lt;/p\u0026gt;\n\u0026lt;ul\u0026gt;\n\u0026lt;li data-start=\u0026quot;1101\u0026quot; data-end=\u0026quot;1188\u0026quot;\u0026gt;Design, build, and maintain responsive frontend features using Angular, React Native and TypeScript\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;1191\u0026quot; data-end=\u0026quot;1264\u0026quot;\u0026gt;Translate product ideas into user-friendly, high-performance interfaces\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;1267\u0026quot; data-end=\u0026quot;1363\u0026quot;\u0026gt;Collaborate in an Agile team alongside backend developers, designers, QA, and product managers\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;1366\u0026quot; data-end=\u0026quot;1451\u0026quot;\u0026gt;Contribute to our architecture (Nx) and continuously improve dev workflows\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;1454\u0026quot; data-end=\u0026quot;1522\u0026quot;\u0026gt;Participate in code reviews, technical discussions, and mentorship\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;1525\u0026quot; data-end=\u0026quot;1664\u0026quot;\u0026gt;Embrace and experiment with AI tools to optimize workflows and explore new approaches to problem-solving\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;h3 data-start=\u0026quot;1671\u0026quot; data-end=\u0026quot;1689\u0026quot;\u0026gt;What You Bring\u0026lt;/h3\u0026gt;\n\u0026lt;ul\u0026gt;\n\u0026lt;li data-start=\u0026quot;1693\u0026quot; data-end=\u0026quot;1784\u0026quot;\u0026gt;4+ years of experience in web application development\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;1787\u0026quot; data-end=\u0026quot;1829\u0026quot;\u0026gt;Proficiency in HTML, CSS, and JavaScript\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;1832\u0026quot; data-end=\u0026quot;1924\u0026quot;\u0026gt;Experience with a frontend framework—preferably Angular, or strong interest in learning it\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;1927\u0026quot; data-end=\u0026quot;2001\u0026quot;\u0026gt;Solid understanding of component-based architecture and state management\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;2004\u0026quot; data-end=\u0026quot;2079\u0026quot;\u0026gt;Experience with CI/CD tools and scalable codebase organization (e.g., Nx)\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;2082\u0026quot; data-end=\u0026quot;2181\u0026quot;\u0026gt;Comfortable using AI tools to assist with writing, coding, content generation, or task automation\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;h3 data-start=\u0026quot;2293\u0026quot; data-end=\u0026quot;2316\u0026quot;\u0026gt;Technologies We Use\u0026lt;/h3\u0026gt;\n\u0026lt;ul\u0026gt;\n\u0026lt;li data-start=\u0026quot;2320\u0026quot; data-end=\u0026quot;2329\u0026quot;\u0026gt;Angular\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;2320\u0026quot; data-end=\u0026quot;2329\u0026quot;\u0026gt;React Native\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;2332\u0026quot; data-end=\u0026quot;2344\u0026quot;\u0026gt;TypeScript\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;2347\u0026quot; data-end=\u0026quot;2353\u0026quot;\u0026gt;SASS\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;2356\u0026quot; data-end=\u0026quot;2360\u0026quot;\u0026gt;Nx\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;2363\u0026quot; data-end=\u0026quot;2378\u0026quot;\u0026gt;GitHub, CI/CD\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;2381\u0026quot; data-end=\u0026quot;2419\u0026quot;\u0026gt;A growing set of AI productivity tools\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;h3 data-start=\u0026quot;2426\u0026quot; data-end=\u0026quot;2454\u0026quot;\u0026gt;What Makes You Stand Out\u0026lt;/h3\u0026gt;\n\u0026lt;ul\u0026gt;\n\u0026lt;li data-start=\u0026quot;2458\u0026quot; data-end=\u0026quot;2507\u0026quot;\u0026gt;You’re curious, resourceful, and take ownership\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;2510\u0026quot; data-end=\u0026quot;2567\u0026quot;\u0026gt;You bring a positive mindset and a collaborative spirit\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;2570\u0026quot; data-end=\u0026quot;2663\u0026quot;\u0026gt;You’re interested in how AI is changing the landscape of work—and excited to evolve with it\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;2666\u0026quot; data-end=\u0026quot;2730\u0026quot;\u0026gt;You aspire to write clean, maintainable, and high-quality code\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;2733\u0026quot; data-end=\u0026quot;2790\u0026quot;\u0026gt;You care about your users, your teammates, and your craft\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;h3 data-start=\u0026quot;3170\u0026quot; data-end=\u0026quot;3189\u0026quot;\u0026gt;Ready to Apply?\u0026lt;/h3\u0026gt;\n\u0026lt;p data-start=\u0026quot;3191\u0026quot; data-end=\u0026quot;3282\u0026quot;\u0026gt;We’d love to hear from you!\u0026amp;nbsp;Apply now and let’s explore how you can grow with us at Poka.\u0026amp;nbsp;🚀\u0026lt;/p\u0026gt;\u0026lt;div class=\u0026quot;content-conclusion\u0026quot;\u0026gt;\u0026lt;div\u0026gt;\n\u0026lt;p data-ghq-card-content-type=\u0026quot;paragraph\u0026quot;\u0026gt;\u0026lt;strong\u0026gt;What we offer:\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;div data-slate-fragment=\u0026quot;JTdCJTIyb2JqZWN0JTIyJTNBJTIyZG9jdW1lbnQlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMldoYXQlMjB3ZSUyMG9mZmVyJTNBJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJtYXJrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJPTEQlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIybWFyayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJJVEFMSUMlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMkR5bmFtaWMlMjBhbmQlMjBjb2xsYWJvcmF0aXZlJTIwd29yayUyMGVudmlyb25tZW50LiUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMkdlbmVyb3VzJTIwdmFjYXRpb24lMjBwb2xpY3klMjB1cG9uJTIwYXJyaXZhbC4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjIxMDAlMjUlMjByZW1vdGUlMjBvciUyMGh5YnJpZCUyMG1vZGUlMjAoYWNjZXNzJTIwdG8lMjBvdXIlMjBvZmZpY2VzJTIwaW4lMjBRdWViZWMlMjBDaXR5JTIwYW5kJTIwTW9udHJlYWwpJTJDJTIwZGVwZW5kaW5nJTIwb24lMjB5b3VyJTIwcHJlZmVyZW5jZS4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJUcnVseSUyMGZsZXhpYmxlJTIwc2NoZWR1bGUlMjBhbmQlMjB0aGUlMjBwb3NzaWJpbGl0eSUyMG9mJTIwdGFraW5nJTIwZXZlcnklMjBGcmlkYXklMjBhZnRlcm5vb24lMjBvZmYuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyR3JvdXAlMjBpbnN1cmFuY2UlMjBmcm9tJTIwZGF5JTIwb25lJTIwKGRlbnRhbCUyQyUyMG1lZGljYXRpb24lMkMlMjBpbnZhbGlkaXR5JTJDJTIwdHJhdmVsJTJDJTIwZXRjLikuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyVGVsZW1lZGljaW5lJTIwc2VydmljZSUyMHdpdGglMjBEaWFsb2d1ZSUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMlJldGlyZW1lbnQlMjBwbGFuJTIwd2l0aCUyMFdlYWx0aHNpbXBsZSUyMGFmdGVyJTIwYSUyMHllYXIuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyTWFjQm9vayUyMFBybyUyMGFuZCUyMCUyNDc1MCUyMGFsbG93YW5jZSUyMGZvciUyMHlvdXIlMjBob21lJTIwb2ZmaWNlLiUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMlRhc3R5JTIwc25hY2tzJTIwKEZyZXNoJTIwZnJ1aXRzJTJDJTIwc25hY2tzJTJDJTIwY29mZmVlcyUyQyUyMHRlYXMlMkMlMjBldGMuKS4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJHZW5lcm91cyUyMHJlZmVycmFsJTIwYm9udXMuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyV2hhdGV2ZXIlMjB5b3UlMjBuZWVkJTIwdG8lMjBrZWVwJTIwbGVhcm5pbmclMjBhbmQlMjBldm9sdmUlMjBwcm9mZXNzaW9uYWxseS4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJSYXBpZCUyMGV2b2x1dGlvbiUyMGluJTIweW91ciUyMHJvbGUuJTIwWW91ciUyMHJlc3BvbnNpYmlsaXRpZXMlMjB3aWxsJTIwbm90JTIwYmUlMjBsaW1pdGVkISUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdE\u0026quot;\u0026gt;\n\u0026lt;ul\u0026gt;\n\u0026lt;li\u0026gt;\u0026lt;span data-ghq-card-content-type=\u0026quot;paragraph\u0026quot;\u0026gt;Dynamic and collaborative work environment.\u0026lt;/span\u0026gt;\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;143\u0026quot; data-end=\u0026quot;199\u0026quot;\u0026gt;A generous vacation policy starting on your first day.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Hybrid work policy - Employees who live within a 40 km radius of our Québec City office or a 15 km radius of our Montréal office are required to work in-office two days per week.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;100% remote work if you do not live within the radiuses mentioned above.\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;435\u0026quot; data-end=\u0026quot;534\u0026quot;\u0026gt;A truly flexible schedule that allows you to adapt your work hours.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Group insurance from day one (dental, medication, disability, travel, etc.) with employer contributions, including access to telemedicine and an Employee Assistance Program (EAP).\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A retirement savings plan with employer contributions.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A MacBook Pro and a $500 home office allowance upon hiring.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A generous annual learning and development budget to support your growth, plus the tools and opportunities you need to keep progressing in your career.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A rewarding employee referral program for successful hires.\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;875\u0026quot; data-end=\u0026quot;969\u0026quot;\u0026gt;Tasty snacks available at all times in our offices (fresh fruits, snacks, pastries, coffee, tea, etc.)\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;em style=\u0026quot;color: rgb(126, 140, 141);\u0026quot;\u0026gt;*Certain benefits are exclusively accessible to employees in Canada.\u0026lt;/em\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;em\u0026gt;Want to learn more about our culture? Click \u0026lt;a href=\u0026quot;https://2394300.fs1.hubspotusercontent-na1.net/hubfs/2394300/HR%20files/CultureBook-En-screen.pdf\u0026quot; target=\u0026quot;_blank\u0026quot;\u0026gt;HERE\u0026lt;/a\u0026gt;.\u0026lt;/em\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;Please note: \u0026lt;/strong\u0026gt;An understanding of English\u0026amp;nbsp;is essential at Poka, as it is the language used during company-wide meetings and many internal communications, ensuring effective collaboration and decision-making. In addition, as Poka operates in an international environment, English is the primary language for communicating with our clients and partners, enabling us to better serve them and represent Poka in a professional manner.\u0026lt;/p\u0026gt;\u0026lt;/div\u0026gt;","departments":[{"id":4046834004,"name":"Engineering / Ingénierie","child_ids":[],"parent_id":null}],"offices":[{"id":4027731004,"name":"Poka HQ","location":"Québec, Quebec, Canada","child_ids":[],"parent_id":null},{"id":4027732004,"name":"Poka Montreal","location":"Montréal, Quebec, Canada","child_ids":[],"parent_id":null}],"ai_disclaimer":null,"include_ai_disclaimer":null,"ai_opt_out_request_url":null},{"absolute_url":"https://job-boards.greenhouse.io/poka/jobs/6098023004","data_compliance":[{"type":"gdpr","requires_consent":true,"requires_processing_consent":false,"requires_retention_consent":true,"retention_period":730,"demographic_data_consent_applies":false}],"internal_job_id":5177057004,"location":{"name":"United States"},"metadata":null,"id":6098023004,"updated_at":"2026-06-25T13:32:38-04:00","requisition_id":"NA0252","title":"Manufacturing Solutions Consultant","company_name":"Poka EN","first_published":"2026-06-25T13:32:38-04:00","language":"en","application_deadline":null,"content":"\u0026lt;div class=\u0026quot;content-intro\u0026quot;\u0026gt;\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;At Poka, we’re transforming how frontline teams operate, learn, and solve problems.\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;Our connected worker platform is purpose-built for manufacturing, empowering frontline workers to share knowledge, collaborate in real-time, and drive continuous improvement. Think of it as the missing digital link, bringing the human interface into the stack of the Smart Factory, generating the right data and insights that drive entirely new scales of process improvements.\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;We’re proud to support global leaders like Nestlé, Bosch, Mars, and Danone in their digital transformation journeys and as part of the IFS family, our reach and impact continue to grow globally.\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;hr\u0026gt;\n\u0026lt;p\u0026gt;\u0026amp;nbsp;\u0026lt;/p\u0026gt;\u0026lt;/div\u0026gt;\u0026lt;div id=\u0026quot;message-list_1754406080.141119\u0026quot; class=\u0026quot;c-virtual_list__item\u0026quot; data-qa=\u0026quot;virtual-list-item\u0026quot; data-item-key=\u0026quot;1754406080.141119\u0026quot;\u0026gt;\n\u0026lt;div class=\u0026quot;c-message_kit__background c-message_kit__background--hovered p-message_pane_message__message c-message_kit__message\u0026quot; data-qa=\u0026quot;message_container\u0026quot; data-qa-unprocessed=\u0026quot;false\u0026quot; data-qa-placeholder=\u0026quot;false\u0026quot;\u0026gt;\n\u0026lt;div class=\u0026quot;c-message_kit__hover c-message_kit__hover--hovered\u0026quot; data-qa-hover=\u0026quot;true\u0026quot;\u0026gt;\n\u0026lt;div class=\u0026quot;c-message_kit__actions c-message_kit__actions--default\u0026quot;\u0026gt;\n\u0026lt;div class=\u0026quot;c-message_kit__gutter\u0026quot;\u0026gt;\n\u0026lt;div class=\u0026quot;c-message_kit__gutter__right\u0026quot; data-qa=\u0026quot;message_content\u0026quot;\u0026gt;\n\u0026lt;div class=\u0026quot;c-message_kit__blocks c-message_kit__blocks--rich_text\u0026quot;\u0026gt;\n\u0026lt;div class=\u0026quot;c-message__message_blocks c-message__message_blocks--rich_text\u0026quot; data-qa=\u0026quot;message-text\u0026quot;\u0026gt;\n\u0026lt;div class=\u0026quot;p-block_kit_renderer\u0026quot; data-qa=\u0026quot;block-kit-renderer\u0026quot;\u0026gt;\n\u0026lt;div class=\u0026quot;p-block_kit_renderer__block_wrapper p-block_kit_renderer__block_wrapper--first\u0026quot;\u0026gt;\n\u0026lt;div class=\u0026quot;p-rich_text_block\u0026quot;\u0026gt;\n\u0026lt;div class=\u0026quot;p-rich_text_section\u0026quot;\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;If you\u0026#39;ve lived the realities of manufacturing or industrial operations — and know how to translate those challenges into compelling technical solutions — this role is built for you.\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;We\u0026#39;re looking for a Solutions Consultant who combines hands-on operational knowledge with pre-sales expertise to join our growing North American team. Whether you\u0026#39;ve sold technical solutions into factories and industrial environments or made the move from the plant floor into a customer-facing role, you understand both the operational complexity and the consultative depth required to drive real digital transformation.\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;You\u0026#39;ll be a key partner to our North American Sales team, guiding prospects through how Poka — and its AI-powered capabilities — unlocks measurable value across operations, frontline enablement, and continuous improvement initiatives.\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;What you\u0026#39;ll be doing\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;ul\u0026gt;\n\u0026lt;li\u0026gt;Act as a trusted advisor to operational leaders across manufacturing and industrial sectors, deeply understanding their processes, pain points, and digital maturity — including where AI can accelerate their journey.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Lead consultative discovery conversations that connect real-world operational challenges to Poka\u0026#39;s connected worker capabilities and AI-driven insights.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Design and deliver tailored product demonstrations that clearly articulate business impact, from waste reduction and standardized work to training optimization and AI-assisted knowledge capture.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Architect solution frameworks that support enterprise-level manufacturing and industrial environments, ensuring alignment with IT, OT, and operational requirements.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Develop compelling value narratives and business cases grounded in Lean, Continuous Improvement, and operational excellence principles.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Partner closely with the North American Sales team to support strategic deals, RFPs, PoCs, and executive presentations.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Contribute operational expertise to the broader Solutions team, helping elevate demo strategy, storytelling, and use-case positioning.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Represent Poka at customer-facing events, site visits, and strategic workshops across the region.\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;What you bring\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;ul\u0026gt;\n\u0026lt;li\u0026gt;Minimum 4+ years of experience in a pre-sales, solutions consulting, or technical sales role supporting manufacturing or industrial customers.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Plus either:\u0026lt;/li\u0026gt;\n\u0026lt;ul\u0026gt;\n\u0026lt;li\u0026gt;5+ years of hands-on experience in manufacturing or industrial operations (engineering, CI, production leadership, field operations), or\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;5+ years selling or supporting technical solutions into manufacturing or industrial environments.\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;li\u0026gt;Strong understanding of Lean Manufacturing, Continuous Improvement, standardized work, and operational KPIs.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Comfort engaging with AI-powered tools and the ability to articulate how AI features — such as intelligent search, automated reporting, and predictive insights — translate into operational value on the frontline.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Experience delivering customized product demonstrations and leading technical discussions with both operational and IT stakeholders.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Ability to translate complex technical capabilities into clear operational outcomes and business value.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Strong communication skills with the confidence to engage site managers, CI leaders, IT teams, and C-suite executives.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Experience contributing to PoCs, solution design, or technical validation processes.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Fluency in English required.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Willingness to travel within North America (up to 50%).\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;Why Poka?\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;At Poka, you won\u0026#39;t just demo software — you\u0026#39;ll help shape how global manufacturers and industrial operators empower their frontline teams.\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;You\u0026#39;ll work at the intersection of operations, technology, and AI-driven innovation, where features like intelligent knowledge retrieval, AI-assisted work instructions, and automated operational insights are already changing what\u0026#39;s possible on the factory floor and beyond. As part of a fast-growing company within the IFS ecosystem, you\u0026#39;ll have global exposure, strong cross-regional collaboration, and real influence on how we position and evolve our solutions across North America.\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;If you\u0026#39;re ready to combine operational expertise with consultative selling — and make a tangible impact on worksites from coast to coast — we\u0026#39;d love to meet you.\u0026lt;/p\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;div id=\u0026quot;te-floating-button-container\u0026quot;\u0026gt;\u0026lt;/div\u0026gt;\n\u0026lt;div id=\u0026quot;te-floating-button-container\u0026quot;\u0026gt;\u0026lt;/div\u0026gt;\n\u0026lt;div id=\u0026quot;te-floating-button-container\u0026quot;\u0026gt;\u0026lt;/div\u0026gt;\u0026lt;div class=\u0026quot;content-conclusion\u0026quot;\u0026gt;\u0026lt;div\u0026gt;\n\u0026lt;p data-ghq-card-content-type=\u0026quot;paragraph\u0026quot;\u0026gt;\u0026lt;strong\u0026gt;What we offer:\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;div data-slate-fragment=\u0026quot;JTdCJTIyb2JqZWN0JTIyJTNBJTIyZG9jdW1lbnQlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMldoYXQlMjB3ZSUyMG9mZmVyJTNBJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJtYXJrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJPTEQlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIybWFyayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJJVEFMSUMlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMkR5bmFtaWMlMjBhbmQlMjBjb2xsYWJvcmF0aXZlJTIwd29yayUyMGVudmlyb25tZW50LiUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMkdlbmVyb3VzJTIwdmFjYXRpb24lMjBwb2xpY3klMjB1cG9uJTIwYXJyaXZhbC4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjIxMDAlMjUlMjByZW1vdGUlMjBvciUyMGh5YnJpZCUyMG1vZGUlMjAoYWNjZXNzJTIwdG8lMjBvdXIlMjBvZmZpY2VzJTIwaW4lMjBRdWViZWMlMjBDaXR5JTIwYW5kJTIwTW9udHJlYWwpJTJDJTIwZGVwZW5kaW5nJTIwb24lMjB5b3VyJTIwcHJlZmVyZW5jZS4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJUcnVseSUyMGZsZXhpYmxlJTIwc2NoZWR1bGUlMjBhbmQlMjB0aGUlMjBwb3NzaWJpbGl0eSUyMG9mJTIwdGFraW5nJTIwZXZlcnklMjBGcmlkYXklMjBhZnRlcm5vb24lMjBvZmYuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyR3JvdXAlMjBpbnN1cmFuY2UlMjBmcm9tJTIwZGF5JTIwb25lJTIwKGRlbnRhbCUyQyUyMG1lZGljYXRpb24lMkMlMjBpbnZhbGlkaXR5JTJDJTIwdHJhdmVsJTJDJTIwZXRjLikuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyVGVsZW1lZGljaW5lJTIwc2VydmljZSUyMHdpdGglMjBEaWFsb2d1ZSUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMlJldGlyZW1lbnQlMjBwbGFuJTIwd2l0aCUyMFdlYWx0aHNpbXBsZSUyMGFmdGVyJTIwYSUyMHllYXIuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyTWFjQm9vayUyMFBybyUyMGFuZCUyMCUyNDc1MCUyMGFsbG93YW5jZSUyMGZvciUyMHlvdXIlMjBob21lJTIwb2ZmaWNlLiUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMlRhc3R5JTIwc25hY2tzJTIwKEZyZXNoJTIwZnJ1aXRzJTJDJTIwc25hY2tzJTJDJTIwY29mZmVlcyUyQyUyMHRlYXMlMkMlMjBldGMuKS4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJHZW5lcm91cyUyMHJlZmVycmFsJTIwYm9udXMuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyV2hhdGV2ZXIlMjB5b3UlMjBuZWVkJTIwdG8lMjBrZWVwJTIwbGVhcm5pbmclMjBhbmQlMjBldm9sdmUlMjBwcm9mZXNzaW9uYWxseS4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJSYXBpZCUyMGV2b2x1dGlvbiUyMGluJTIweW91ciUyMHJvbGUuJTIwWW91ciUyMHJlc3BvbnNpYmlsaXRpZXMlMjB3aWxsJTIwbm90JTIwYmUlMjBsaW1pdGVkISUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdE\u0026quot;\u0026gt;\n\u0026lt;ul\u0026gt;\n\u0026lt;li\u0026gt;\u0026lt;span data-ghq-card-content-type=\u0026quot;paragraph\u0026quot;\u0026gt;Dynamic and collaborative work environment.\u0026lt;/span\u0026gt;\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;143\u0026quot; data-end=\u0026quot;199\u0026quot;\u0026gt;A generous vacation policy starting on your first day.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Hybrid work policy - Employees who live within a 40 km radius of our Québec City office or a 15 km radius of our Montréal office are required to work in-office two days per week.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;100% remote work if you do not live within the radiuses mentioned above.\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;435\u0026quot; data-end=\u0026quot;534\u0026quot;\u0026gt;A truly flexible schedule that allows you to adapt your work hours.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Group insurance from day one (dental, medication, disability, travel, etc.) with employer contributions, including access to telemedicine and an Employee Assistance Program (EAP).\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A retirement savings plan with employer contributions.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A MacBook Pro and a $500 home office allowance upon hiring.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A generous annual learning and development budget to support your growth, plus the tools and opportunities you need to keep progressing in your career.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A rewarding employee referral program for successful hires.\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;875\u0026quot; data-end=\u0026quot;969\u0026quot;\u0026gt;Tasty snacks available at all times in our offices (fresh fruits, snacks, pastries, coffee, tea, etc.)\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;em style=\u0026quot;color: rgb(126, 140, 141);\u0026quot;\u0026gt;*Certain benefits are exclusively accessible to employees in Canada.\u0026lt;/em\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;em\u0026gt;Want to learn more about our culture? Click \u0026lt;a href=\u0026quot;https://2394300.fs1.hubspotusercontent-na1.net/hubfs/2394300/HR%20files/CultureBook-En-screen.pdf\u0026quot; target=\u0026quot;_blank\u0026quot;\u0026gt;HERE\u0026lt;/a\u0026gt;.\u0026lt;/em\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;Please note: \u0026lt;/strong\u0026gt;An understanding of English\u0026amp;nbsp;is essential at Poka, as it is the language used during company-wide meetings and many internal communications, ensuring effective collaboration and decision-making. In addition, as Poka operates in an international environment, English is the primary language for communicating with our clients and partners, enabling us to better serve them and represent Poka in a professional manner.\u0026lt;/p\u0026gt;\u0026lt;/div\u0026gt;","departments":[{"id":4046826004,"name":"Sales / Ventes","child_ids":[],"parent_id":null}],"offices":[{"id":4027740004,"name":"Remote - US","location":"Arizona, United States","child_ids":[],"parent_id":null}],"ai_disclaimer":null,"include_ai_disclaimer":null,"ai_opt_out_request_url":null},{"absolute_url":"https://job-boards.greenhouse.io/poka/jobs/6103227004","data_compliance":[{"type":"gdpr","requires_consent":true,"requires_processing_consent":false,"requires_retention_consent":true,"retention_period":730,"demographic_data_consent_applies":false}],"internal_job_id":5179725004,"location":{"name":"Montreal, Canada"},"metadata":null,"id":6103227004,"updated_at":"2026-06-29T13:22:40-04:00","requisition_id":"NA0257","title":"Marketing AI \u0026 Analytics Strategist","company_name":"Poka EN","first_published":"2026-06-29T13:22:40-04:00","language":"en","application_deadline":null,"content":"\u0026lt;div class=\u0026quot;content-intro\u0026quot;\u0026gt;\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;At Poka, we’re transforming how frontline teams operate, learn, and solve problems.\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;Our connected worker platform is purpose-built for manufacturing, empowering frontline workers to share knowledge, collaborate in real-time, and drive continuous improvement. Think of it as the missing digital link, bringing the human interface into the stack of the Smart Factory, generating the right data and insights that drive entirely new scales of process improvements.\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;We’re proud to support global leaders like Nestlé, Bosch, Mars, and Danone in their digital transformation journeys and as part of the IFS family, our reach and impact continue to grow globally.\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;hr\u0026gt;\n\u0026lt;p\u0026gt;\u0026amp;nbsp;\u0026lt;/p\u0026gt;\u0026lt;/div\u0026gt;\u0026lt;p\u0026gt;At Poka, we\u0026#39;re transforming how frontline teams operate, learn, and solve problems. Our connected worker platform is purpose-built for manufacturing, empowering frontline workers to share knowledge, collaborate in real-time, and drive continuous improvement.\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;Think of it as the missing digital link, bringing the human interface into the stack of the Smart Factory, generating the right data and insights that drive entirely new scales of process improvements.\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;We\u0026#39;re proud to support global leaders like Nestlé, Bosch, Mars, and Danone on their digital transformation journeys. And as part of the IFS family, our global reach and impact continue to grow.\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;If a misleading dashboard ruins your afternoon, we should talk.\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;We have a sophisticated stack and more data than we currently know what to do with. This role turns that data into decisions.\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;About the role\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;Poka\u0026#39;s marketing team runs a complex stack, HubSpot, 6sense, Supermetrics, LinkedIn, Google, Influ2, and generates more data than we currently put to use. This role fixes that. You\u0026#39;ll own how we measure marketing performance, where the gaps in our funnel logic are, and how we combine automation, AI, and human-in-the-loop processes to sharpen conversion rates and close the gaps in how we operate.\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;The person who thrives here gets genuinely annoyed when a dashboard is misleading, spots process gaps before they become problems, and has enough curiosity about AI tooling to have already pushed it beyond the basics.\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;What you\u0026#39;ll be doing\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;ul\u0026gt;\n\u0026lt;li\u0026gt;Own funnel performance reporting across MQL/MQA volume and quality, pipeline contribution by source and segment, and conversion rates at each stage, and bring a clear point of view on what the numbers \u0026lt;em\u0026gt;mean\u0026lt;/em\u0026gt;, not just what they are.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Build and maintain dashboards in HubSpot and Supermetrics that Marketing, Sales, and RevOps actually use, which means keeping them accurate, fast, and honest about what\u0026#39;s missing.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Design and implement a marketing attribution framework: defining the touchpoint model, aligning with RevOps on methodology, and making sure the logic holds across channels and reporting tools.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Analyze paid media performance across LinkedIn, Google, and programmatic, not just reporting on it, but forming a view on where the budget should move and why.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Use AI tools to automate the analytics work that doesn\u0026#39;t require judgment: lead enrichment, anomaly detection, routine reporting, intent signal processing. The point is to free up time for the work that does require judgment.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Identify and fix process gaps in the funnel, lead routing errors, lifecycle stage inconsistencies, data hygiene issues, working with RevOps and the HubSpot admin to close them properly.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Partner with campaign and demand generation leads at the start of each program to set measurement frameworks, then come back at the end with a retrospective that\u0026#39;s genuinely useful for the next campaign.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Evaluate new martech and AI tools with a practical lens: does this solve a real problem we have, at a cost and complexity that makes sense?\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;What you bring\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;ul\u0026gt;\n\u0026lt;li\u0026gt;4–6 years in marketing analytics, marketing operations, or a growth-focused analytics role, ideally in B2B SaaS or enterprise technology.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;HubSpot Marketing Hub experience beyond report-pulling. You\u0026#39;ve built workflows, managed lifecycle stages, and troubleshot attribution issues inside the platform. Certifications are a plus, but no substitute for having done the work.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Supermetrics experience, or another cross-channel data connector you can speak to concretely.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;You\u0026#39;ve built or significantly improved a marketing attribution model. First-touch, linear, data-driven, custom, the methodology matters less than having aligned stakeholders on one and made it stick.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Paid media analytics across LinkedIn Ads and Google Ads. You can look at a campaign\u0026#39;s performance data and tell us something we don\u0026#39;t already know.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Genuine hands-on experience with AI tools in a marketing context, actual use, not just awareness. 6sense, OpenAI API integrations, or similar. Someone who has automated a workflow that used to take hours is the bar.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Comfortable in Excel or Google Sheets for data work; Google Apps Script, Looker, and SQL experience are real advantages.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Able to explain analytical findings to people who don\u0026#39;t share your relationship with spreadsheets.\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;Why Poka\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;You\u0026#39;ll have real ownership over how an entire marketing org understands its own performance, the attribution logic, the dashboards, the AI workflows, the calls on where budget moves. The stack is already sophisticated, the data is already flowing, and the appetite for sharper answers is there. What\u0026#39;s missing is someone to make it all add up. As part of the IFS family, the systems you build here scale with a growing global marketing function.\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;br\u0026gt;\u0026lt;br\u0026gt;\u0026lt;/p\u0026gt;\u0026lt;div class=\u0026quot;content-conclusion\u0026quot;\u0026gt;\u0026lt;div\u0026gt;\n\u0026lt;p data-ghq-card-content-type=\u0026quot;paragraph\u0026quot;\u0026gt;\u0026lt;strong\u0026gt;What we offer:\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;div data-slate-fragment=\u0026quot;JTdCJTIyb2JqZWN0JTIyJTNBJTIyZG9jdW1lbnQlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMldoYXQlMjB3ZSUyMG9mZmVyJTNBJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJtYXJrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJPTEQlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIybWFyayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJJVEFMSUMlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMkR5bmFtaWMlMjBhbmQlMjBjb2xsYWJvcmF0aXZlJTIwd29yayUyMGVudmlyb25tZW50LiUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMkdlbmVyb3VzJTIwdmFjYXRpb24lMjBwb2xpY3klMjB1cG9uJTIwYXJyaXZhbC4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjIxMDAlMjUlMjByZW1vdGUlMjBvciUyMGh5YnJpZCUyMG1vZGUlMjAoYWNjZXNzJTIwdG8lMjBvdXIlMjBvZmZpY2VzJTIwaW4lMjBRdWViZWMlMjBDaXR5JTIwYW5kJTIwTW9udHJlYWwpJTJDJTIwZGVwZW5kaW5nJTIwb24lMjB5b3VyJTIwcHJlZmVyZW5jZS4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJUcnVseSUyMGZsZXhpYmxlJTIwc2NoZWR1bGUlMjBhbmQlMjB0aGUlMjBwb3NzaWJpbGl0eSUyMG9mJTIwdGFraW5nJTIwZXZlcnklMjBGcmlkYXklMjBhZnRlcm5vb24lMjBvZmYuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyR3JvdXAlMjBpbnN1cmFuY2UlMjBmcm9tJTIwZGF5JTIwb25lJTIwKGRlbnRhbCUyQyUyMG1lZGljYXRpb24lMkMlMjBpbnZhbGlkaXR5JTJDJTIwdHJhdmVsJTJDJTIwZXRjLikuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyVGVsZW1lZGljaW5lJTIwc2VydmljZSUyMHdpdGglMjBEaWFsb2d1ZSUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMlJldGlyZW1lbnQlMjBwbGFuJTIwd2l0aCUyMFdlYWx0aHNpbXBsZSUyMGFmdGVyJTIwYSUyMHllYXIuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyTWFjQm9vayUyMFBybyUyMGFuZCUyMCUyNDc1MCUyMGFsbG93YW5jZSUyMGZvciUyMHlvdXIlMjBob21lJTIwb2ZmaWNlLiUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMlRhc3R5JTIwc25hY2tzJTIwKEZyZXNoJTIwZnJ1aXRzJTJDJTIwc25hY2tzJTJDJTIwY29mZmVlcyUyQyUyMHRlYXMlMkMlMjBldGMuKS4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJHZW5lcm91cyUyMHJlZmVycmFsJTIwYm9udXMuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyV2hhdGV2ZXIlMjB5b3UlMjBuZWVkJTIwdG8lMjBrZWVwJTIwbGVhcm5pbmclMjBhbmQlMjBldm9sdmUlMjBwcm9mZXNzaW9uYWxseS4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJSYXBpZCUyMGV2b2x1dGlvbiUyMGluJTIweW91ciUyMHJvbGUuJTIwWW91ciUyMHJlc3BvbnNpYmlsaXRpZXMlMjB3aWxsJTIwbm90JTIwYmUlMjBsaW1pdGVkISUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdE\u0026quot;\u0026gt;\n\u0026lt;ul\u0026gt;\n\u0026lt;li\u0026gt;\u0026lt;span data-ghq-card-content-type=\u0026quot;paragraph\u0026quot;\u0026gt;Dynamic and collaborative work environment.\u0026lt;/span\u0026gt;\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;143\u0026quot; data-end=\u0026quot;199\u0026quot;\u0026gt;A generous vacation policy starting on your first day.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Hybrid work policy - Employees who live within a 40 km radius of our Québec City office or a 15 km radius of our Montréal office are required to work in-office two days per week.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;100% remote work if you do not live within the radiuses mentioned above.\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;435\u0026quot; data-end=\u0026quot;534\u0026quot;\u0026gt;A truly flexible schedule that allows you to adapt your work hours.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Group insurance from day one (dental, medication, disability, travel, etc.) with employer contributions, including access to telemedicine and an Employee Assistance Program (EAP).\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A retirement savings plan with employer contributions.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A MacBook Pro and a $500 home office allowance upon hiring.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A generous annual learning and development budget to support your growth, plus the tools and opportunities you need to keep progressing in your career.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A rewarding employee referral program for successful hires.\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;875\u0026quot; data-end=\u0026quot;969\u0026quot;\u0026gt;Tasty snacks available at all times in our offices (fresh fruits, snacks, pastries, coffee, tea, etc.)\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;em style=\u0026quot;color: rgb(126, 140, 141);\u0026quot;\u0026gt;*Certain benefits are exclusively accessible to employees in Canada.\u0026lt;/em\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;em\u0026gt;Want to learn more about our culture? Click \u0026lt;a href=\u0026quot;https://2394300.fs1.hubspotusercontent-na1.net/hubfs/2394300/HR%20files/CultureBook-En-screen.pdf\u0026quot; target=\u0026quot;_blank\u0026quot;\u0026gt;HERE\u0026lt;/a\u0026gt;.\u0026lt;/em\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;Please note: \u0026lt;/strong\u0026gt;An understanding of English\u0026amp;nbsp;is essential at Poka, as it is the language used during company-wide meetings and many internal communications, ensuring effective collaboration and decision-making. In addition, as Poka operates in an international environment, English is the primary language for communicating with our clients and partners, enabling us to better serve them and represent Poka in a professional manner.\u0026lt;/p\u0026gt;\u0026lt;/div\u0026gt;","departments":[{"id":4046829004,"name":"Marketing","child_ids":[],"parent_id":null}],"offices":[{"id":4027731004,"name":"Poka HQ","location":"Québec, Quebec, Canada","child_ids":[],"parent_id":null},{"id":4027732004,"name":"Poka Montreal","location":"Montréal, Quebec, Canada","child_ids":[],"parent_id":null}],"ai_disclaimer":null,"include_ai_disclaimer":null,"ai_opt_out_request_url":null},{"absolute_url":"https://job-boards.greenhouse.io/poka/jobs/6133571004","data_compliance":[{"type":"gdpr","requires_consent":true,"requires_processing_consent":false,"requires_retention_consent":true,"retention_period":730,"demographic_data_consent_applies":false}],"internal_job_id":5194954004,"location":{"name":"Canada"},"metadata":null,"id":6133571004,"updated_at":"2026-08-05T11:15:17-04:00","requisition_id":"NA0260","title":"Regional Marketing Specialist","company_name":"Poka EN","first_published":"2026-08-05T11:15:17-04:00","language":"en","application_deadline":null,"content":"\u0026lt;div class=\u0026quot;content-intro\u0026quot;\u0026gt;\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;At Poka, we’re transforming how frontline teams operate, learn, and solve problems.\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;Our connected worker platform is purpose-built for manufacturing, empowering frontline workers to share knowledge, collaborate in real-time, and drive continuous improvement. Think of it as the missing digital link, bringing the human interface into the stack of the Smart Factory, generating the right data and insights that drive entirely new scales of process improvements.\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;We’re proud to support global leaders like Nestlé, Bosch, Mars, and Danone in their digital transformation journeys and as part of the IFS family, our reach and impact continue to grow globally.\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;hr\u0026gt;\n\u0026lt;p\u0026gt;\u0026amp;nbsp;\u0026lt;/p\u0026gt;\u0026lt;/div\u0026gt;\u0026lt;h4\u0026gt;\u0026lt;strong\u0026gt;About the Role\u0026lt;/strong\u0026gt;\u0026lt;/h4\u0026gt;\n\u0026lt;p\u0026gt;We\u0026#39;re looking for a Regional Marketing Specialist, NA who will plan, orchestrate, and optimize multi-channel campaigns across North American markets. You\u0026#39;ll work closely with regional and global sales, product marketing, and digital teams to activate messaging that resonates with segment-specific ICPs, fuels pipeline, and deepens engagement across all lifecycle stages.\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;This role is predominantly focused on planning and executing field events and regional activations, so it\u0026#39;s ideal for someone who enjoys the on-the-ground logistics of bringing campaigns to life as much as the strategy behind them, someone with 2–5 years of experience in B2B SaaS/technology environments who enjoys rolling up their sleeves, experimenting, and continuously improving performance.\u0026lt;/p\u0026gt;\n\u0026lt;h4\u0026gt;\u0026lt;strong\u0026gt;What You\u0026#39;ll Be Doing\u0026lt;/strong\u0026gt;\u0026lt;/h4\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;Field Events \u0026amp;amp; Regional Activations\u0026lt;/strong\u0026gt;\u0026amp;nbsp;\u0026lt;/p\u0026gt;\n\u0026lt;ul\u0026gt;\n\u0026lt;li\u0026gt;Support end-to-end logistics for field events, regional activations, trade shows, and in-person/hybrid experiences\u0026amp;nbsp;\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Coordinate pre-event promotion (invites, registration, LinkedIn, follow-up sequences) and post-event reporting to tie activations back to pipeline impact\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Assist with vendor and venue relationships across multiple markets, keeping events on schedule\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Build repeatable event playbooks/checklists to streamline planning across recurring regional activations\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Travel within North America as needed to support event execution (approximately 10–15%)\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;Account-Based Marketing\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;ul\u0026gt;\n\u0026lt;li\u0026gt;Support ABM by creating tailored programs for priority accounts, in partnership with sales on account selection and personalized engagement\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;Campaign Execution\u0026amp;nbsp;\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;ul\u0026gt;\n\u0026lt;li\u0026gt;Plan, execute, and optimize coordinated multi-channel campaigns across email, paid and organic social, webinars, and nurture workflows\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Build targeted campaigns for specific ICP segments across North America\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Drive measurable impact on pipeline metrics such as MQLs, SQLs, CPL, and Contributed Pipeline\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;Sales Alignment\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;ul\u0026gt;\n\u0026lt;li\u0026gt;Act as the link between global marketing and local sales teams\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Ensure regional marketing activity is aligned to the North American sales strategy and supports agreed priorities\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Partner closely with sales to monitor performance, adjust targeting, and share insights\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;Reporting \u0026amp;amp; Optimization\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;ul\u0026gt;\n\u0026lt;li\u0026gt;Analyze campaign results, identify optimization opportunities, and report on performance with clarity and confidence\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;h4\u0026gt;\u0026lt;strong\u0026gt;What You Bring\u0026lt;/strong\u0026gt;\u0026lt;/h4\u0026gt;\n\u0026lt;ul\u0026gt;\n\u0026lt;li\u0026gt;2–5 years of experience in campaign execution, ideally within a B2B SaaS or technology environment\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Strong experience planning and coordinating events, both digital and in-person — this is a significant part of the role\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Hands-on experience running multi-channel digital campaigns with measurable impact on funnel and pipeline performance\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Familiarity with Account-Based Marketing approaches and tools (e.g., 6sense, Demandbase, RollWorks)\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Experience with HubSpot and Salesforce is an asset\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Confident building and optimizing email journeys, landing pages, and social campaigns\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Experience working closely with sales teams and aligning marketing activity to pipeline priorities\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Organized, collaborative, and comfortable owning campaigns from planning through to reporting\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Data-driven mindset, you instinctively ask \u0026quot;what\u0026#39;s working, why, and how do we scale it?\u0026quot;\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Based in North America; English fluency required\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\u0026lt;div class=\u0026quot;content-conclusion\u0026quot;\u0026gt;\u0026lt;div\u0026gt;\n\u0026lt;p data-ghq-card-content-type=\u0026quot;paragraph\u0026quot;\u0026gt;\u0026lt;strong\u0026gt;What we offer:\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;div data-slate-fragment=\u0026quot;JTdCJTIyb2JqZWN0JTIyJTNBJTIyZG9jdW1lbnQlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMldoYXQlMjB3ZSUyMG9mZmVyJTNBJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJtYXJrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJPTEQlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIybWFyayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJJVEFMSUMlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMkR5bmFtaWMlMjBhbmQlMjBjb2xsYWJvcmF0aXZlJTIwd29yayUyMGVudmlyb25tZW50LiUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMkdlbmVyb3VzJTIwdmFjYXRpb24lMjBwb2xpY3klMjB1cG9uJTIwYXJyaXZhbC4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjIxMDAlMjUlMjByZW1vdGUlMjBvciUyMGh5YnJpZCUyMG1vZGUlMjAoYWNjZXNzJTIwdG8lMjBvdXIlMjBvZmZpY2VzJTIwaW4lMjBRdWViZWMlMjBDaXR5JTIwYW5kJTIwTW9udHJlYWwpJTJDJTIwZGVwZW5kaW5nJTIwb24lMjB5b3VyJTIwcHJlZmVyZW5jZS4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJUcnVseSUyMGZsZXhpYmxlJTIwc2NoZWR1bGUlMjBhbmQlMjB0aGUlMjBwb3NzaWJpbGl0eSUyMG9mJTIwdGFraW5nJTIwZXZlcnklMjBGcmlkYXklMjBhZnRlcm5vb24lMjBvZmYuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyR3JvdXAlMjBpbnN1cmFuY2UlMjBmcm9tJTIwZGF5JTIwb25lJTIwKGRlbnRhbCUyQyUyMG1lZGljYXRpb24lMkMlMjBpbnZhbGlkaXR5JTJDJTIwdHJhdmVsJTJDJTIwZXRjLikuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyVGVsZW1lZGljaW5lJTIwc2VydmljZSUyMHdpdGglMjBEaWFsb2d1ZSUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMlJldGlyZW1lbnQlMjBwbGFuJTIwd2l0aCUyMFdlYWx0aHNpbXBsZSUyMGFmdGVyJTIwYSUyMHllYXIuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyTWFjQm9vayUyMFBybyUyMGFuZCUyMCUyNDc1MCUyMGFsbG93YW5jZSUyMGZvciUyMHlvdXIlMjBob21lJTIwb2ZmaWNlLiUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMlRhc3R5JTIwc25hY2tzJTIwKEZyZXNoJTIwZnJ1aXRzJTJDJTIwc25hY2tzJTJDJTIwY29mZmVlcyUyQyUyMHRlYXMlMkMlMjBldGMuKS4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJHZW5lcm91cyUyMHJlZmVycmFsJTIwYm9udXMuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyV2hhdGV2ZXIlMjB5b3UlMjBuZWVkJTIwdG8lMjBrZWVwJTIwbGVhcm5pbmclMjBhbmQlMjBldm9sdmUlMjBwcm9mZXNzaW9uYWxseS4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJSYXBpZCUyMGV2b2x1dGlvbiUyMGluJTIweW91ciUyMHJvbGUuJTIwWW91ciUyMHJlc3BvbnNpYmlsaXRpZXMlMjB3aWxsJTIwbm90JTIwYmUlMjBsaW1pdGVkISUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdE\u0026quot;\u0026gt;\n\u0026lt;ul\u0026gt;\n\u0026lt;li\u0026gt;\u0026lt;span data-ghq-card-content-type=\u0026quot;paragraph\u0026quot;\u0026gt;Dynamic and collaborative work environment.\u0026lt;/span\u0026gt;\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;143\u0026quot; data-end=\u0026quot;199\u0026quot;\u0026gt;A generous vacation policy starting on your first day.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Hybrid work policy - Employees who live within a 40 km radius of our Québec City office or a 15 km radius of our Montréal office are required to work in-office two days per week.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;100% remote work if you do not live within the radiuses mentioned above.\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;435\u0026quot; data-end=\u0026quot;534\u0026quot;\u0026gt;A truly flexible schedule that allows you to adapt your work hours.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Group insurance from day one (dental, medication, disability, travel, etc.) with employer contributions, including access to telemedicine and an Employee Assistance Program (EAP).\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A retirement savings plan with employer contributions.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A MacBook Pro and a $500 home office allowance upon hiring.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A generous annual learning and development budget to support your growth, plus the tools and opportunities you need to keep progressing in your career.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A rewarding employee referral program for successful hires.\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;875\u0026quot; data-end=\u0026quot;969\u0026quot;\u0026gt;Tasty snacks available at all times in our offices (fresh fruits, snacks, pastries, coffee, tea, etc.)\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;em style=\u0026quot;color: rgb(126, 140, 141);\u0026quot;\u0026gt;*Certain benefits are exclusively accessible to employees in Canada.\u0026lt;/em\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;em\u0026gt;Want to learn more about our culture? Click \u0026lt;a href=\u0026quot;https://2394300.fs1.hubspotusercontent-na1.net/hubfs/2394300/HR%20files/CultureBook-En-screen.pdf\u0026quot; target=\u0026quot;_blank\u0026quot;\u0026gt;HERE\u0026lt;/a\u0026gt;.\u0026lt;/em\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;Please note: \u0026lt;/strong\u0026gt;An understanding of English\u0026amp;nbsp;is essential at Poka, as it is the language used during company-wide meetings and many internal communications, ensuring effective collaboration and decision-making. In addition, as Poka operates in an international environment, English is the primary language for communicating with our clients and partners, enabling us to better serve them and represent Poka in a professional manner.\u0026lt;/p\u0026gt;\u0026lt;/div\u0026gt;","departments":[{"id":4046829004,"name":"Marketing","child_ids":[],"parent_id":null}],"offices":[{"id":4027731004,"name":"Poka HQ","location":"Québec, Quebec, Canada","child_ids":[],"parent_id":null},{"id":4027732004,"name":"Poka Montreal","location":"Montréal, Quebec, Canada","child_ids":[],"parent_id":null}],"ai_disclaimer":null,"include_ai_disclaimer":null,"ai_opt_out_request_url":null},{"absolute_url":"https://job-boards.greenhouse.io/poka/jobs/6115034004","data_compliance":[{"type":"gdpr","requires_consent":true,"requires_processing_consent":false,"requires_retention_consent":true,"retention_period":730,"demographic_data_consent_applies":false}],"internal_job_id":5187027004,"location":{"name":"Quebec, Canada"},"metadata":null,"id":6115034004,"updated_at":"2026-07-15T14:30:43-04:00","requisition_id":"NA0261","title":"Sales Enablement Specialist","company_name":"Poka EN","first_published":"2026-07-15T14:30:43-04:00","language":"en","application_deadline":null,"content":"\u0026lt;div class=\u0026quot;content-intro\u0026quot;\u0026gt;\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;At Poka, we’re transforming how frontline teams operate, learn, and solve problems.\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;Our connected worker platform is purpose-built for manufacturing, empowering frontline workers to share knowledge, collaborate in real-time, and drive continuous improvement. Think of it as the missing digital link, bringing the human interface into the stack of the Smart Factory, generating the right data and insights that drive entirely new scales of process improvements.\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;We’re proud to support global leaders like Nestlé, Bosch, Mars, and Danone in their digital transformation journeys and as part of the IFS family, our reach and impact continue to grow globally.\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;hr\u0026gt;\n\u0026lt;p\u0026gt;\u0026amp;nbsp;\u0026lt;/p\u0026gt;\u0026lt;/div\u0026gt;\u0026lt;p class=\u0026quot;font-claude-response-body break-words whitespace-normal\u0026quot;\u0026gt;\u0026lt;strong\u0026gt;About the role\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p class=\u0026quot;font-claude-response-body break-words whitespace-normal\u0026quot;\u0026gt;The Sales Enablement Specialist owns the programs, content, and learning experiences that make customer-facing teams more effective. You\u0026#39;ll design onboarding, build sales content, coach reps, and run the systems that keep our commercial teams aligned and performing at a high level. The role sits at the intersection of Sales, Product, Marketing, Pre-Sales, and IFS Enablement, making sure every rep has the clarity, tools, and confidence to execute.\u0026lt;/p\u0026gt;\n\u0026lt;p class=\u0026quot;font-claude-response-body break-words whitespace-normal\u0026quot;\u0026gt;\u0026lt;strong\u0026gt;What you\u0026#39;ll be doing\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;ul class=\u0026quot;[li_\u0026amp;amp;]:mb-0 [li_\u0026amp;amp;]:mt-1 [li_\u0026amp;amp;]:gap-1 [\u0026amp;amp;:not(:last-child)_ul]:pb-1 [\u0026amp;amp;:not(:last-child)_ol]:pb-1 list-disc flex flex-col gap-1 pl-8 mb-3\u0026quot;\u0026gt;\n\u0026lt;li class=\u0026quot;font-claude-response-body whitespace-normal break-words pl-2\u0026quot;\u0026gt;Own the creation, curation, governance, and system management of sales content (playbooks, talk tracks, competitive guides, ICP briefs, value messaging) so it stays consistent, findable, and version-controlled.\u0026lt;/li\u0026gt;\n\u0026lt;li class=\u0026quot;font-claude-response-body whitespace-normal break-words pl-2\u0026quot;\u0026gt;Translate product releases, market shifts, and competitive insights into actionable, stage-aligned sales guidance, using analytics from Highspot, Seismic, and Gong to refine what works.\u0026lt;/li\u0026gt;\n\u0026lt;li class=\u0026quot;font-claude-response-body whitespace-normal break-words pl-2\u0026quot;\u0026gt;Partner with Product Marketing, SMEs, and cross-functional teams (Product, Marketing, HR, Sales Ops, IFS Enablement) to produce high-quality, role-specific content and aligned curriculum.\u0026lt;/li\u0026gt;\n\u0026lt;li class=\u0026quot;font-claude-response-body whitespace-normal break-words pl-2\u0026quot;\u0026gt;Design and deliver structured learning paths grounded in adult learning principles, including methodology training across discovery, qualification, value selling, objection handling, and deal strategy.\u0026lt;/li\u0026gt;\n\u0026lt;li class=\u0026quot;font-claude-response-body whitespace-normal break-words pl-2\u0026quot;\u0026gt;Own the full onboarding journey for Sales, Pre-Sales, and Partner reps, building interactive modules, videos, assessments, LMS curriculum, and manufacturing/product training.\u0026lt;/li\u0026gt;\n\u0026lt;li class=\u0026quot;font-claude-response-body whitespace-normal break-words pl-2\u0026quot;\u0026gt;Establish competency milestones and track rep progress through Salesforce, LMS platforms, and enablement tools, reinforcing best practices through micro-learning and ongoing touchpoints.\u0026lt;/li\u0026gt;\n\u0026lt;li class=\u0026quot;font-claude-response-body whitespace-normal break-words pl-2\u0026quot;\u0026gt;Run enablement sessions, workshops, refreshers, and targeted interventions based on needs assessments with Sales Leadership and shifts in pipeline or product.\u0026lt;/li\u0026gt;\n\u0026lt;li class=\u0026quot;font-claude-response-body whitespace-normal break-words pl-2\u0026quot;\u0026gt;Make sure content, learning experiences, tools, workflows, and processes work together to support rep productivity and confident ramp-up.\u0026lt;/li\u0026gt;\n\u0026lt;li class=\u0026quot;font-claude-response-body whitespace-normal break-words pl-2\u0026quot;\u0026gt;Own the strategy, architecture, and day-to-day governance of the internal enablement platform, so its content, learning paths, analytics, integrations, and user experience drive adoption, productivity, and consistent execution across every revenue team.\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;p class=\u0026quot;font-claude-response-body break-words whitespace-normal\u0026quot;\u0026gt;\u0026lt;strong\u0026gt;What you bring\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;ul class=\u0026quot;[li_\u0026amp;amp;]:mb-0 [li_\u0026amp;amp;]:mt-1 [li_\u0026amp;amp;]:gap-1 [\u0026amp;amp;:not(:last-child)_ul]:pb-1 [\u0026amp;amp;:not(:last-child)_ol]:pb-1 list-disc flex flex-col gap-1 pl-8 mb-3\u0026quot;\u0026gt;\n\u0026lt;li class=\u0026quot;font-claude-response-body whitespace-normal break-words pl-2\u0026quot;\u0026gt;Experience in sales enablement, onboarding, or revenue-focused learning and development within SaaS or technology.\u0026lt;/li\u0026gt;\n\u0026lt;li class=\u0026quot;font-claude-response-body whitespace-normal break-words pl-2\u0026quot;\u0026gt;Strong grasp of sales methodologies such as MEDDICC, Challenger, or Value Selling.\u0026lt;/li\u0026gt;\n\u0026lt;li class=\u0026quot;font-claude-response-body whitespace-normal break-words pl-2\u0026quot;\u0026gt;A knack for turning complex technical concepts into simple, compelling sales narratives, with a track record of building sales content that\u0026#39;s clear, actionable, and aligned to sales stages.\u0026lt;/li\u0026gt;\n\u0026lt;li class=\u0026quot;font-claude-response-body whitespace-normal break-words pl-2\u0026quot;\u0026gt;Hands-on experience with modern sales and enablement tools including Salesforce, Highspot or Seismic, Gong, Outreach, LMS platforms, and analytics tools.\u0026lt;/li\u0026gt;\n\u0026lt;li class=\u0026quot;font-claude-response-body whitespace-normal break-words pl-2\u0026quot;\u0026gt;The ability to manage multiple programs at once with structured planning and prioritization.\u0026lt;/li\u0026gt;\n\u0026lt;li class=\u0026quot;font-claude-response-body whitespace-normal break-words pl-2\u0026quot;\u0026gt;Strong collaboration across Sales, Product, Marketing, Pre-Sales, and Partner teams.\u0026lt;/li\u0026gt;\n\u0026lt;li class=\u0026quot;font-claude-response-body whitespace-normal break-words pl-2\u0026quot;\u0026gt;A genuine interest in helping people improve their craft, and comfort with ambiguity, change, and rapid iteration.\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;p class=\u0026quot;font-claude-response-body break-words whitespace-normal\u0026quot;\u0026gt;\u0026lt;strong\u0026gt;Why Poka\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p class=\u0026quot;font-claude-response-body break-words whitespace-normal\u0026quot;\u0026gt;You\u0026#39;ll work across sales, product, and people development, with real influence on how every new rep, ours or our partners\u0026#39;, experiences the ramp-up. Being part of IFS means your work extends well past our own walls. And since this role is based out of Quebec, French will come in handy about as often as English does.\u0026lt;/p\u0026gt;\u0026lt;div class=\u0026quot;content-conclusion\u0026quot;\u0026gt;\u0026lt;div\u0026gt;\n\u0026lt;p data-ghq-card-content-type=\u0026quot;paragraph\u0026quot;\u0026gt;\u0026lt;strong\u0026gt;What we offer:\u0026lt;/strong\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;div data-slate-fragment=\u0026quot;JTdCJTIyb2JqZWN0JTIyJTNBJTIyZG9jdW1lbnQlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMldoYXQlMjB3ZSUyMG9mZmVyJTNBJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJtYXJrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJPTEQlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIybWFyayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJJVEFMSUMlMjIlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMkR5bmFtaWMlMjBhbmQlMjBjb2xsYWJvcmF0aXZlJTIwd29yayUyMGVudmlyb25tZW50LiUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMkdlbmVyb3VzJTIwdmFjYXRpb24lMjBwb2xpY3klMjB1cG9uJTIwYXJyaXZhbC4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjIxMDAlMjUlMjByZW1vdGUlMjBvciUyMGh5YnJpZCUyMG1vZGUlMjAoYWNjZXNzJTIwdG8lMjBvdXIlMjBvZmZpY2VzJTIwaW4lMjBRdWViZWMlMjBDaXR5JTIwYW5kJTIwTW9udHJlYWwpJTJDJTIwZGVwZW5kaW5nJTIwb24lMjB5b3VyJTIwcHJlZmVyZW5jZS4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJUcnVseSUyMGZsZXhpYmxlJTIwc2NoZWR1bGUlMjBhbmQlMjB0aGUlMjBwb3NzaWJpbGl0eSUyMG9mJTIwdGFraW5nJTIwZXZlcnklMjBGcmlkYXklMjBhZnRlcm5vb24lMjBvZmYuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyR3JvdXAlMjBpbnN1cmFuY2UlMjBmcm9tJTIwZGF5JTIwb25lJTIwKGRlbnRhbCUyQyUyMG1lZGljYXRpb24lMkMlMjBpbnZhbGlkaXR5JTJDJTIwdHJhdmVsJTJDJTIwZXRjLikuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyVGVsZW1lZGljaW5lJTIwc2VydmljZSUyMHdpdGglMjBEaWFsb2d1ZSUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMlJldGlyZW1lbnQlMjBwbGFuJTIwd2l0aCUyMFdlYWx0aHNpbXBsZSUyMGFmdGVyJTIwYSUyMHllYXIuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyTWFjQm9vayUyMFBybyUyMGFuZCUyMCUyNDc1MCUyMGFsbG93YW5jZSUyMGZvciUyMHlvdXIlMjBob21lJTIwb2ZmaWNlLiUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTJDJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIyQlVMTEVURURfTElTVF9JVEVNJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJwYXJhZ3JhcGglMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMnRleHQlMjIlMkMlMjJsZWF2ZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJsZWFmJTIyJTJDJTIydGV4dCUyMiUzQSUyMlRhc3R5JTIwc25hY2tzJTIwKEZyZXNoJTIwZnJ1aXRzJTJDJTIwc25hY2tzJTJDJTIwY29mZmVlcyUyQyUyMHRlYXMlMkMlMjBldGMuKS4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJHZW5lcm91cyUyMHJlZmVycmFsJTIwYm9udXMuJTIyJTJDJTIybWFya3MlMjIlM0ElNUIlNUQlN0QlNUQlN0QlNUQlN0QlNUQlN0QlMkMlN0IlMjJvYmplY3QlMjIlM0ElMjJibG9jayUyMiUyQyUyMnR5cGUlMjIlM0ElMjJCVUxMRVRFRF9MSVNUX0lURU0lMjIlMkMlMjJpc1ZvaWQlMjIlM0FmYWxzZSUyQyUyMmRhdGElMjIlM0ElN0IlN0QlMkMlMjJub2RlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMnBhcmFncmFwaCUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIydGV4dCUyMiUyQyUyMmxlYXZlcyUyMiUzQSU1QiU3QiUyMm9iamVjdCUyMiUzQSUyMmxlYWYlMjIlMkMlMjJ0ZXh0JTIyJTNBJTIyV2hhdGV2ZXIlMjB5b3UlMjBuZWVkJTIwdG8lMjBrZWVwJTIwbGVhcm5pbmclMjBhbmQlMjBldm9sdmUlMjBwcm9mZXNzaW9uYWxseS4lMjIlMkMlMjJtYXJrcyUyMiUzQSU1QiU1RCU3RCU1RCU3RCU1RCU3RCU1RCU3RCUyQyU3QiUyMm9iamVjdCUyMiUzQSUyMmJsb2NrJTIyJTJDJTIydHlwZSUyMiUzQSUyMkJVTExFVEVEX0xJU1RfSVRFTSUyMiUyQyUyMmlzVm9pZCUyMiUzQWZhbHNlJTJDJTIyZGF0YSUyMiUzQSU3QiU3RCUyQyUyMm5vZGVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIyYmxvY2slMjIlMkMlMjJ0eXBlJTIyJTNBJTIycGFyYWdyYXBoJTIyJTJDJTIyaXNWb2lkJTIyJTNBZmFsc2UlMkMlMjJkYXRhJTIyJTNBJTdCJTdEJTJDJTIybm9kZXMlMjIlM0ElNUIlN0IlMjJvYmplY3QlMjIlM0ElMjJ0ZXh0JTIyJTJDJTIybGVhdmVzJTIyJTNBJTVCJTdCJTIyb2JqZWN0JTIyJTNBJTIybGVhZiUyMiUyQyUyMnRleHQlMjIlM0ElMjJSYXBpZCUyMGV2b2x1dGlvbiUyMGluJTIweW91ciUyMHJvbGUuJTIwWW91ciUyMHJlc3BvbnNpYmlsaXRpZXMlMjB3aWxsJTIwbm90JTIwYmUlMjBsaW1pdGVkISUyMiUyQyUyMm1hcmtzJTIyJTNBJTVCJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdEJTVEJTdE\u0026quot;\u0026gt;\n\u0026lt;ul\u0026gt;\n\u0026lt;li\u0026gt;\u0026lt;span data-ghq-card-content-type=\u0026quot;paragraph\u0026quot;\u0026gt;Dynamic and collaborative work environment.\u0026lt;/span\u0026gt;\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;143\u0026quot; data-end=\u0026quot;199\u0026quot;\u0026gt;A generous vacation policy starting on your first day.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Hybrid work policy - Employees who live within a 40 km radius of our Québec City office or a 15 km radius of our Montréal office are required to work in-office two days per week.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;100% remote work if you do not live within the radiuses mentioned above.\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;435\u0026quot; data-end=\u0026quot;534\u0026quot;\u0026gt;A truly flexible schedule that allows you to adapt your work hours.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;Group insurance from day one (dental, medication, disability, travel, etc.) with employer contributions, including access to telemedicine and an Employee Assistance Program (EAP).\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A retirement savings plan with employer contributions.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A MacBook Pro and a $500 home office allowance upon hiring.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A generous annual learning and development budget to support your growth, plus the tools and opportunities you need to keep progressing in your career.\u0026lt;/li\u0026gt;\n\u0026lt;li\u0026gt;A rewarding employee referral program for successful hires.\u0026lt;/li\u0026gt;\n\u0026lt;li data-start=\u0026quot;875\u0026quot; data-end=\u0026quot;969\u0026quot;\u0026gt;Tasty snacks available at all times in our offices (fresh fruits, snacks, pastries, coffee, tea, etc.)\u0026lt;/li\u0026gt;\n\u0026lt;/ul\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;em style=\u0026quot;color: rgb(126, 140, 141);\u0026quot;\u0026gt;*Certain benefits are exclusively accessible to employees in Canada.\u0026lt;/em\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;/div\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;em\u0026gt;Want to learn more about our culture? Click \u0026lt;a href=\u0026quot;https://2394300.fs1.hubspotusercontent-na1.net/hubfs/2394300/HR%20files/CultureBook-En-screen.pdf\u0026quot; target=\u0026quot;_blank\u0026quot;\u0026gt;HERE\u0026lt;/a\u0026gt;.\u0026lt;/em\u0026gt;\u0026lt;/p\u0026gt;\n\u0026lt;p\u0026gt;\u0026lt;strong\u0026gt;Please note: \u0026lt;/strong\u0026gt;An understanding of English\u0026amp;nbsp;is essential at Poka, as it is the language used during company-wide meetings and many internal communications, ensuring effective collaboration and decision-making. In addition, as Poka operates in an international environment, English is the primary language for communicating with our clients and partners, enabling us to better serve them and represent Poka in a professional manner.\u0026lt;/p\u0026gt;\u0026lt;/div\u0026gt;","departments":[{"id":4046827004,"name":"Revenue Operations / Opérations des revenues","child_ids":[],"parent_id":null}],"offices":[{"id":4027731004,"name":"Poka HQ","location":"Québec, Quebec, Canada","child_ids":[],"parent_id":null},{"id":4027732004,"name":"Poka Montreal","location":"Montréal, Quebec, Canada","child_ids":[],"parent_id":null}],"ai_disclaimer":null,"include_ai_disclaimer":null,"ai_opt_out_request_url":null}],"meta":{"total":8}} | |
| \ No newline at end of file | ||
added
tests/fixtures/poka/expected.json
+75 −0
@@ -0,0 +1,75 @@ | ||
| 1 | +{ | |
| 2 | + "count": 5, | |
| 3 | + "jobs": [ | |
| 4 | + { | |
| 5 | + "uid": "poka:6103227004", | |
| 6 | + "url": "https://job-boards.greenhouse.io/poka/jobs/6103227004", | |
| 7 | + "title": "Marketing AI & Analytics Strategist", | |
| 8 | + "employer": "Poka", | |
| 9 | + "city": "", | |
| 10 | + "location_label": "Montreal, Canada", | |
| 11 | + "salary_min": null, | |
| 12 | + "salary_max": null, | |
| 13 | + "salary_unit": null, | |
| 14 | + "employment_type": null, | |
| 15 | + "ats": "greenhouse", | |
| 16 | + "desc_len": 7044 | |
| 17 | + }, | |
| 18 | + { | |
| 19 | + "uid": "poka:6103724004", | |
| 20 | + "url": "https://job-boards.greenhouse.io/poka/jobs/6103724004", | |
| 21 | + "title": "Customer Marketing Manager", | |
| 22 | + "employer": "Poka", | |
| 23 | + "city": "", | |
| 24 | + "location_label": "Montreal, Canada", | |
| 25 | + "salary_min": null, | |
| 26 | + "salary_max": null, | |
| 27 | + "salary_unit": null, | |
| 28 | + "employment_type": null, | |
| 29 | + "ats": "greenhouse", | |
| 30 | + "desc_len": 6992 | |
| 31 | + }, | |
| 32 | + { | |
| 33 | + "uid": "poka:6114006004", | |
| 34 | + "url": "https://job-boards.greenhouse.io/poka/jobs/6114006004", | |
| 35 | + "title": "Accounting Analyst", | |
| 36 | + "employer": "Poka", | |
| 37 | + "city": "", | |
| 38 | + "location_label": "Québec, Quebec, Canada, Montréal, Quebec, Canada", | |
| 39 | + "salary_min": null, | |
| 40 | + "salary_max": null, | |
| 41 | + "salary_unit": null, | |
| 42 | + "employment_type": null, | |
| 43 | + "ats": "greenhouse", | |
| 44 | + "desc_len": 4419 | |
| 45 | + }, | |
| 46 | + { | |
| 47 | + "uid": "poka:6115034004", | |
| 48 | + "url": "https://job-boards.greenhouse.io/poka/jobs/6115034004", | |
| 49 | + "title": "Sales Enablement Specialist", | |
| 50 | + "employer": "Poka", | |
| 51 | + "city": "", | |
| 52 | + "location_label": "Quebec, Canada", | |
| 53 | + "salary_min": null, | |
| 54 | + "salary_max": null, | |
| 55 | + "salary_unit": null, | |
| 56 | + "employment_type": null, | |
| 57 | + "ats": "greenhouse", | |
| 58 | + "desc_len": 5664 | |
| 59 | + }, | |
| 60 | + { | |
| 61 | + "uid": "poka:6134510004", | |
| 62 | + "url": "https://job-boards.greenhouse.io/poka/jobs/6134510004", | |
| 63 | + "title": "Frontend Developer ", | |
| 64 | + "employer": "Poka", | |
| 65 | + "city": "", | |
| 66 | + "location_label": "Québec & Montréal ", | |
| 67 | + "salary_min": null, | |
| 68 | + "salary_max": null, | |
| 69 | + "salary_unit": null, | |
| 70 | + "employment_type": null, | |
| 71 | + "ats": "greenhouse", | |
| 72 | + "desc_len": 4152 | |
| 73 | + } | |
| 74 | + ] | |
| 75 | +} | |
| \ No newline at end of file | ||
added
tests/fixtures/poka/index.json
+9 −0
@@ -0,0 +1,9 @@ | ||
| 1 | +{ | |
| 2 | + "0b773b59b34e454db34d": { | |
| 3 | + "method": "GET", | |
| 4 | + "url": "https://boards-api.greenhouse.io/v1/boards/poka/jobs?content=true", | |
| 5 | + "status": 200, | |
| 6 | + "content_type": "application/json", | |
| 7 | + "file": "0b773b59b34e454db34d.json" | |
| 8 | + } | |
| 9 | +} | |
| \ No newline at end of file | ||
added
tests/test_connectors.py
+79 −0
@@ -0,0 +1,79 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : tests/test_connectors.py | |
| 6 | +# Rôle : Tests de régression des connecteurs (rejeu des fixtures hors ligne) | |
| 7 | +# Ré-enregistrer après un changement de site : run.py record <source> | |
| 8 | +# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | +# ============================================================================= | |
| 10 | +import json | |
| 11 | +import re | |
| 12 | + | |
| 13 | +import pytest | |
| 14 | + | |
| 15 | +from jobka.fixtures import FIXTURES_DIR, recorded_sources, replay_connector, snapshot | |
| 16 | + | |
| 17 | +SOURCES = recorded_sources() | |
| 18 | + | |
| 19 | +pytestmark = pytest.mark.skipif( | |
| 20 | + not SOURCES, reason="aucune fixture enregistrée (run.py record <source>)") | |
| 21 | + | |
| 22 | + | |
| 23 | +@pytest.fixture(scope="module") | |
| 24 | +def parsed(): | |
| 25 | + """Rejoue chaque connecteur une seule fois par session de test.""" | |
| 26 | + cache = {} | |
| 27 | + | |
| 28 | + def _get(source_id): | |
| 29 | + if source_id not in cache: | |
| 30 | + cache[source_id] = replay_connector(source_id).fetch() | |
| 31 | + return cache[source_id] | |
| 32 | + | |
| 33 | + return _get | |
| 34 | + | |
| 35 | + | |
| 36 | +@pytest.mark.parametrize("source_id", SOURCES) | |
| 37 | +def test_parsing_matches_snapshot(source_id, parsed): | |
| 38 | + """Le parsing des fixtures reproduit exactement l'instantané enregistré.""" | |
| 39 | + expected = json.loads( | |
| 40 | + (FIXTURES_DIR / source_id / "expected.json").read_text(encoding="utf-8")) | |
| 41 | + actual = snapshot(parsed(source_id)) | |
| 42 | + assert actual["count"] == expected["count"] | |
| 43 | + assert actual["jobs"] == expected["jobs"] | |
| 44 | + | |
| 45 | + | |
| 46 | +@pytest.mark.parametrize("source_id", SOURCES) | |
| 47 | +def test_jobs_are_valid(source_id, parsed): | |
| 48 | + """Contraintes de schéma : uid uniques, URLs absolues, salaires plausibles.""" | |
| 49 | + postings = parsed(source_id) | |
| 50 | + assert postings, "le connecteur ne retourne aucune offre depuis la fixture" | |
| 51 | + | |
| 52 | + uids = [p.uid for p in postings] | |
| 53 | + assert len(uids) == len(set(uids)), "external_id en double" | |
| 54 | + | |
| 55 | + for p in postings: | |
| 56 | + assert p.source == source_id | |
| 57 | + assert p.external_id | |
| 58 | + assert p.title, f"offre sans titre : {p.uid}" | |
| 59 | + assert p.employer, f"offre sans employeur : {p.uid}" | |
| 60 | + assert re.match(r"^https?://", p.url), f"URL non absolue : {p.url!r}" | |
| 61 | + if p.salary_min is not None: | |
| 62 | + assert p.salary_unit in ("hour", "day", "week", "biweek", "month", "year") | |
| 63 | + assert p.salary_max is None or p.salary_max >= p.salary_min | |
| 64 | + # jamais de valeur inventée : chaîne vide ou None, pas de « N/A » | |
| 65 | + assert (p.employment_type or "") not in ("N/A", "n/a", "?") | |
| 66 | + | |
| 67 | + | |
| 68 | +@pytest.mark.parametrize("source_id", SOURCES) | |
| 69 | +def test_finalize_is_safe(source_id, parsed): | |
| 70 | + """finalize() ne lève pas et produit des valeurs canoniques.""" | |
| 71 | + for p in parsed(source_id): | |
| 72 | + f = p.finalize() | |
| 73 | + assert f.work_mode in (None, "presentiel", "hybride", "teletravail") | |
| 74 | + assert f.employment_type in (None, "temps_plein", "temps_partiel", | |
| 75 | + "contractuel", "stage", "saisonnier") | |
| 76 | + if f.date_posted is not None: | |
| 77 | + assert re.fullmatch(r"\d{4}-\d{2}-\d{2}", f.date_posted) | |
| 78 | + if f.lat is not None: | |
| 79 | + assert 44.5 <= f.lat <= 63.0 and -80.0 <= f.lng <= -56.0 | |
added
tests/test_normalize.py
+178 −0
@@ -0,0 +1,178 @@ | ||
| 1 | +# ============================================================================= | |
| 2 | +# Job·Ka — Groupe KA | |
| 3 | +# Auteur : Simon-Pierre Boucher | |
| 4 | +# Contact : contact@spboucher.ai | |
| 5 | +# Fichier : tests/test_normalize.py | |
| 6 | +# Rôle : Tests unitaires de la couche de normalisation (salaires, dates, | |
| 7 | +# mode/type d'emploi, lieux, catégories) | |
| 8 | +# Créé : 2026-08-17 Modifié : 2026-08-17 | |
| 9 | +# ============================================================================= | |
| 10 | +import datetime as dt | |
| 11 | + | |
| 12 | +from jobka.normalize import ( | |
| 13 | + categorize, | |
| 14 | + clean_html, | |
| 15 | + is_quebec_location, | |
| 16 | + parse_date, | |
| 17 | + parse_employment_type, | |
| 18 | + parse_location, | |
| 19 | + parse_salary, | |
| 20 | + parse_work_mode, | |
| 21 | + salary_to_hourly, | |
| 22 | + salary_to_yearly, | |
| 23 | +) | |
| 24 | + | |
| 25 | +TODAY = dt.date(2026, 8, 17) | |
| 26 | + | |
| 27 | + | |
| 28 | +# --- salaires ---------------------------------------------------------------- | |
| 29 | + | |
| 30 | +def test_salary_hourly_range(): | |
| 31 | + assert parse_salary("25,00 $ à 30,00 $ de l'heure") == (25.0, 30.0, "hour") | |
| 32 | + | |
| 33 | + | |
| 34 | +def test_salary_hourly_english(): | |
| 35 | + assert parse_salary("$22.50 - $27.75 per hour") == (22.5, 27.75, "hour") | |
| 36 | + | |
| 37 | + | |
| 38 | +def test_salary_yearly_thousands(): | |
| 39 | + assert parse_salary("70 000 $ à 90 000 $ par année") == (70000.0, 90000.0, "year") | |
| 40 | + | |
| 41 | + | |
| 42 | +def test_salary_yearly_english_commas(): | |
| 43 | + assert parse_salary("$85,000 - $105,000 annually") == (85000.0, 105000.0, "year") | |
| 44 | + | |
| 45 | + | |
| 46 | +def test_salary_k_notation(): | |
| 47 | + assert parse_salary("80k$ - 100k$ /an") == (80000.0, 100000.0, "year") | |
| 48 | + | |
| 49 | + | |
| 50 | +def test_salary_single_value(): | |
| 51 | + lo, hi, unit = parse_salary("Salaire : 28,50 $/h") | |
| 52 | + assert (lo, hi, unit) == (28.5, 28.5, "hour") | |
| 53 | + | |
| 54 | + | |
| 55 | +def test_salary_infers_unit_from_magnitude(): | |
| 56 | + assert parse_salary("55 000 $")[2] == "year" | |
| 57 | + assert parse_salary("26,75 $")[2] == "hour" | |
| 58 | + | |
| 59 | + | |
| 60 | +def test_salary_rejects_hours_of_work(): | |
| 61 | + # « 35 heures/semaine » n'est PAS un salaire | |
| 62 | + assert parse_salary("35 heures par semaine") == (None, None, None) | |
| 63 | + | |
| 64 | + | |
| 65 | +def test_salary_rejects_empty_and_garbage(): | |
| 66 | + assert parse_salary("") == (None, None, None) | |
| 67 | + assert parse_salary("Salaire concurrentiel") == (None, None, None) | |
| 68 | + | |
| 69 | + | |
| 70 | +def test_salary_conversions(): | |
| 71 | + assert salary_to_yearly(25.0, "hour") == 52000.0 | |
| 72 | + assert salary_to_hourly(52000.0, "year") == 25.0 | |
| 73 | + assert salary_to_yearly(5000.0, "month") == 60000.0 | |
| 74 | + assert salary_to_yearly(None, "hour") is None | |
| 75 | + | |
| 76 | + | |
| 77 | +# --- dates --------------------------------------------------------------------- | |
| 78 | + | |
| 79 | +def test_date_iso_passthrough(): | |
| 80 | + assert parse_date("2026-09-01") == "2026-09-01" | |
| 81 | + assert parse_date("2026-06-16T05:06:55-04:00") == "2026-06-16" | |
| 82 | + | |
| 83 | + | |
| 84 | +def test_date_epoch_ms(): | |
| 85 | + # Lever : createdAt en millisecondes | |
| 86 | + assert parse_date(1755000000000) == dt.date.fromtimestamp(1755000000).isoformat() | |
| 87 | + | |
| 88 | + | |
| 89 | +def test_date_french(): | |
| 90 | + assert parse_date("1er juillet 2026", TODAY) == "2026-07-01" | |
| 91 | + assert parse_date("déc. 2026", TODAY) == "2026-12-01" | |
| 92 | + | |
| 93 | + | |
| 94 | +def test_date_english(): | |
| 95 | + assert parse_date("July 1st, 2026", TODAY) == "2026-07-01" | |
| 96 | + | |
| 97 | + | |
| 98 | +def test_date_relative(): | |
| 99 | + assert parse_date("Posted 3 Days Ago", TODAY) == "2026-08-14" | |
| 100 | + assert parse_date("il y a 2 semaines", TODAY) == "2026-08-03" | |
| 101 | + assert parse_date("Posted Today", TODAY) == "2026-08-17" | |
| 102 | + assert parse_date("Posted Yesterday", TODAY) == "2026-08-16" | |
| 103 | + | |
| 104 | + | |
| 105 | +def test_date_never_invented(): | |
| 106 | + assert parse_date("bientôt") is None | |
| 107 | + assert parse_date("") is None | |
| 108 | + assert parse_date(None) is None | |
| 109 | + | |
| 110 | + | |
| 111 | +# --- mode / type --------------------------------------------------------------- | |
| 112 | + | |
| 113 | +def test_work_mode(): | |
| 114 | + assert parse_work_mode("Télétravail") == "teletravail" | |
| 115 | + assert parse_work_mode("Remote - QC") == "teletravail" | |
| 116 | + assert parse_work_mode("Mode hybride (3 jours au bureau)") == "hybride" | |
| 117 | + assert parse_work_mode("Sur place") == "presentiel" | |
| 118 | + assert parse_work_mode("Montréal") is None | |
| 119 | + | |
| 120 | + | |
| 121 | +def test_work_mode_hybrid_wins(): | |
| 122 | + assert parse_work_mode("hybride avec télétravail partiel") == "hybride" | |
| 123 | + | |
| 124 | + | |
| 125 | +def test_employment_type(): | |
| 126 | + assert parse_employment_type("Temps plein") == "temps_plein" | |
| 127 | + assert parse_employment_type("Full time") == "temps_plein" | |
| 128 | + assert parse_employment_type("Permanent") == "temps_plein" | |
| 129 | + assert parse_employment_type("Temps partiel") == "temps_partiel" | |
| 130 | + assert parse_employment_type("Contrat 12 mois") == "contractuel" | |
| 131 | + assert parse_employment_type("Stage été 2027") == "stage" | |
| 132 | + assert parse_employment_type("Emploi saisonnier") == "saisonnier" | |
| 133 | + assert parse_employment_type("") is None | |
| 134 | + | |
| 135 | + | |
| 136 | +# --- lieux ----------------------------------------------------------------------- | |
| 137 | + | |
| 138 | +def test_location_montreal(): | |
| 139 | + loc = parse_location("Montréal, QC, Canada") | |
| 140 | + assert loc["city"] == "Montréal" | |
| 141 | + assert loc["region"] == "Québec" | |
| 142 | + | |
| 143 | + | |
| 144 | +def test_location_quebec_city_english(): | |
| 145 | + assert parse_location("Quebec City, Quebec")["city"] == "Québec" | |
| 146 | + | |
| 147 | + | |
| 148 | +def test_location_postal_code(): | |
| 149 | + loc = parse_location("1500 rue Exemple, Lévis G6V 4Z2") | |
| 150 | + assert loc["postal_code"] == "G6V 4Z2" | |
| 151 | + assert loc["city"] == "Lévis" | |
| 152 | + | |
| 153 | + | |
| 154 | +def test_is_quebec_location(): | |
| 155 | + assert is_quebec_location("Montréal, QC") | |
| 156 | + assert is_quebec_location("Saint-Laurent, Quebec, Canada") | |
| 157 | + assert is_quebec_location("Sherbrooke") | |
| 158 | + assert not is_quebec_location("Toronto, ON") | |
| 159 | + assert not is_quebec_location("Richmond, BC") | |
| 160 | + assert not is_quebec_location("Paris, IDF, France") | |
| 161 | + assert not is_quebec_location("") | |
| 162 | + | |
| 163 | + | |
| 164 | +# --- catégories / HTML ------------------------------------------------------------ | |
| 165 | + | |
| 166 | +def test_categorize(): | |
| 167 | + assert categorize("Développeur logiciel senior") == "TI" | |
| 168 | + assert categorize("Infirmière clinicienne") == "Santé" | |
| 169 | + assert categorize("Conseiller en ressources humaines") == "RH" | |
| 170 | + assert categorize("Poste mystère") == "" | |
| 171 | + | |
| 172 | + | |
| 173 | +def test_clean_html(): | |
| 174 | + html = "<p>Bonjour !</p><ul><li>Point 1</li><li>Point 2</li></ul>" | |
| 175 | + txt = clean_html(html) | |
| 176 | + assert "Bonjour !" in txt | |
| 177 | + assert "Point 1" in txt and "Point 2" in txt | |
| 178 | + assert "<" not in txt | |
| 179 | ||