SPB Git forge

spb/job-ka

Public
226commits 1branches 0releases
37.5 MBsize
maindefault branch
10 h agolast push
HTML 82.1% Python 14.6% TypeScript 1.9% CSS 1% JavaScript 0.5%

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>
Simon-Pierre Boucher committed 1 mo ago (Aug 18, 2026)

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>&nbsp;</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.&nbsp;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&nbsp;</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>&nbsp;</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&nbsp;</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>&nbsp;</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>&nbsp;<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&nbsp;</span><a style=\"font-size: 15px\" href=\"https://play.google.com/store/apps/details?id=com.mistplay.mistplay&amp;hl=en_CA&amp;gl=US\">ici</a><span style=\"font-size: 15px\">&nbsp;et suivez-nous sur&nbsp;</span><a style=\"font-size: 15px\" href=\"https://www.instagram.com/mistplay/?hl=en\">Instagram</a><span style=\"font-size: 15px\">,&nbsp;</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\">&nbsp;et&nbsp;</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) &amp; 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&nbsp;</span><a style=\"font-size: 15px\" href=\"https://play.google.com/store/apps/details?id=com.mistplay.mistplay&amp;hl=en_CA&amp;gl=US\">here</a><span style=\"font-size: 15px\">&nbsp;and follow us on&nbsp;</span><a style=\"font-size: 15px\" href=\"https://www.instagram.com/mistplay/?hl=en\">Instagram</a><span style=\"font-size: 15px\">,&nbsp;</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\">&nbsp;and&nbsp;</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) &amp; 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&nbsp;&nbsp;</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!&nbsp;</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&nbsp;</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>&nbsp;</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&nbsp;</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>&nbsp;</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 &amp; Development Allocation</p>\n</li>\n<li>\n<p>Bi-Annual Performance &amp; 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&nbsp;</span><a href=\"https://play.google.com/store/apps/details?id=com.mistplay.mistplay&amp;hl=en_CA&amp;gl=US\" style=\"font-size: 15px\">ici</a><span style=\"font-size: 15px\">&nbsp;et suivez-nous sur&nbsp;</span><a href=\"https://www.instagram.com/mistplay/?hl=en\" style=\"font-size: 15px\">Instagram</a><span style=\"font-size: 15px\">,&nbsp;</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\">&nbsp;et&nbsp;</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) &amp; 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&nbsp;</span><a href=\"https://play.google.com/store/apps/details?id=com.mistplay.mistplay&amp;hl=en_CA&amp;gl=US\" style=\"font-size: 15px\">here</a><span style=\"font-size: 15px\">&nbsp;and follow us on&nbsp;</span><a href=\"https://www.instagram.com/mistplay/?hl=en\" style=\"font-size: 15px\">Instagram</a><span style=\"font-size: 15px\">,&nbsp;</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\">&nbsp;and&nbsp;</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) &amp; 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'&eacute;quipe d'assistance de Mistplay en tant que Sp&eacute;cialiste de l'Exp&eacute;rience Client ! Notre objectif principal est de satisfaire les besoins et les d&eacute;sirs de nos utilisateurs, et nous nous effor&ccedil;ons constamment de d&eacute;passer leurs attentes. Si tu adores &eacute;laborer des strat&eacute;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&egrave;me de gestion des tickets par courriel (Zendesk).</p>\n</li>\n<li>\n<p>Apporter une assistance personnalis&eacute;e aux utilisateurs tout en assurant des r&eacute;ponses rapides qui s'alignent sur les KPI cibles de notre &eacute;quipe.</p>\n</li>\n<li>\n<p>R&eacute;pondre aux commentaires formul&eacute;s sur le Google Play Store et sur les r&eacute;seaux sociaux afin de garantir la satisfaction des utilisateurs et d'obtenir des notes &eacute;lev&eacute;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&eacute;seaux sociaux.</p>\n</li>\n<li>\n<p>R&eacute;soudre les probl&egrave;mes techniques et &eacute;tudier les profils des joueurs &agrave; l'aide d'outils Back-end.</p>\n</li>\n<li>\n<p>Se tenir au fait des proc&eacute;dures d'assistance les plus r&eacute;centes, des probl&egrave;mes dynamiques et des mises &agrave; jour de produits.</p>\n</li>\n<li>\n<p>Faire remonter &agrave; l'&eacute;quipe de d&eacute;veloppement les probl&egrave;mes r&eacute;cemment signal&eacute;s et les demandes de feedback des utilisateurs.</p>\n</li>\n<li>\n<p>R&eacute;diger et assurer le suivi des rapports sur les bogues techniques relatifs aux probl&egrave;mes r&eacute;currents.</p>\n</li>\n<li>\n<p>Agir en tant que repr&eacute;sentant sur des projets produits pointus et fournir une visibilit&eacute; &agrave; l'&eacute;quipe d'assistance.</p>\n</li>\n<li>\n<p>D&eacute;fendre fermement les int&eacute;r&ecirc;ts des joueurs.</p>\n</li>\n</ul>\n<p><br><strong>Profil recherch&eacute;:</strong></p>\n<ul>\n<li>\n<p>Tu poss&egrave;des de 1 &agrave; 3 ans d'exp&eacute;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&eacute;soudre les probl&egrave;mes et interpr&eacute;ter les donn&eacute;es de mani&egrave;re intuitive.</p>\n</li>\n<li>\n<p>Tu as la capacit&eacute; d'utiliser avec assurance divers outils et plateformes en parall&egrave;le.</p>\n</li>\n<li>\n<p>Tu es autonome, motiv&eacute;e et capable de respecter les d&eacute;lais.</p>\n</li>\n<li>\n<p>Tu poss&egrave;des de r&eacute;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&eacute;rience en mati&egrave;re de r&eacute;daction technique et de production de rapports.</p>\n</li>\n<li>\n<p>Tu poss&egrave;des de bonnes connaissances en informatique.</p>\n</li>\n<li>\n<p>Tu as de solides comp&eacute;tences en communication.</p>\n</li>\n<li>\n<p>Tu ma&icirc;trises parfaitement l'anglais et le fran&ccedil;ais &agrave; l'&eacute;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&eacute;en, est un plus.</p>\n</li>\n<li>\n<p>Une exp&eacute;rience de Zendesk ou d'Amazon DynamoDB est un atout suppl&eacute;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&rsquo;offrons :</strong></p>\n<p>&bull; R&eacute;gime d'assurance g&eacute;n&eacute;reux ;</p>\n<p>&bull; 4 semaines de vacances par an ;</p>\n<p>&bull; 1 semaine de bien-&ecirc;tre chaque ann&eacute;e (fermeture de l'entreprise) ;</p>\n<p>&bull; Jours de cong&eacute; individuels/pour maladie ;</p>\n<p>&bull; Contribution employeur au REER collectif &agrave; hauteur de 100 % (dans la limite de 5% par an) ;</p>\n<p>&bull; Allocation annuelle pour le fitness/la remise en forme ;</p>\n<p>&bull; Allocation mensuelle pour l'utilisation des transports en commun ou pour la connexion Internet chez vous ;</p>\n<p>&bull; Allocation pour l'apprentissage et le d&eacute;veloppement.<br><br>Les avantages ci-dessus sont sp&eacute;cifiques aux employ&eacute;s canadiens. Des offres &eacute;quivalentes sont disponibles pour les employ&eacute;s d'autres pays.</p>\n<p>Penses-tu que ce poste et cette &eacute;quipe pourraient &ecirc;tre faits pour toi ? Postule d&egrave;s maintenant pour d&eacute;marrer ta prochaine aventure professionnelle !</p>\n<h3 style=\"text-align: center;\"><strong>Customer Support Specialist&nbsp;&nbsp;</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!&nbsp;</p>\n<p><strong>What you&rsquo;ll do at Misplay</strong></p>\n<ul>\n<li>\n<p>Provide daily support to Mistplay&rsquo;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&rsquo;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&nbsp;</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>&nbsp;</p>\n<p><strong>What you&rsquo;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&nbsp;</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>&nbsp;</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 &amp; Development Allocation</p>\n</li>\n<li>\n<p>Bi-Annual Performance &amp; 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>&nbsp;</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.&nbsp;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>&nbsp;</div>\n<div>&nbsp;</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>&nbsp;</div>\n<div>&nbsp;</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>&nbsp;<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&nbsp;</span><a style=\"font-size: 15px\" href=\"https://play.google.com/store/apps/details?id=com.mistplay.mistplay&amp;hl=en_CA&amp;gl=US\">ici</a><span style=\"font-size: 15px\">&nbsp;et suivez-nous sur&nbsp;</span><a style=\"font-size: 15px\" href=\"https://www.instagram.com/mistplay/?hl=en\">Instagram</a><span style=\"font-size: 15px\">,&nbsp;</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\">&nbsp;et&nbsp;</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) &amp; 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&nbsp;</span><a style=\"font-size: 15px\" href=\"https://play.google.com/store/apps/details?id=com.mistplay.mistplay&amp;hl=en_CA&amp;gl=US\">here</a><span style=\"font-size: 15px\">&nbsp;and follow us on&nbsp;</span><a style=\"font-size: 15px\" href=\"https://www.instagram.com/mistplay/?hl=en\">Instagram</a><span style=\"font-size: 15px\">,&nbsp;</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\">&nbsp;and&nbsp;</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) &amp; 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>&nbsp;</p>\n<h4 style=\"text-align: center;\">Senior Customer Success Manager I (Contract)&nbsp;</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>&nbsp;</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>&nbsp;</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&nbsp;</span><a href=\"https://play.google.com/store/apps/details?id=com.mistplay.mistplay&amp;hl=en_CA&amp;gl=US\" style=\"font-size: 15px\">ici</a><span style=\"font-size: 15px\">&nbsp;et suivez-nous sur&nbsp;</span><a href=\"https://www.instagram.com/mistplay/?hl=en\" style=\"font-size: 15px\">Instagram</a><span style=\"font-size: 15px\">,&nbsp;</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\">&nbsp;et&nbsp;</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) &amp; 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&nbsp;</span><a href=\"https://play.google.com/store/apps/details?id=com.mistplay.mistplay&amp;hl=en_CA&amp;gl=US\" style=\"font-size: 15px\">here</a><span style=\"font-size: 15px\">&nbsp;and follow us on&nbsp;</span><a href=\"https://www.instagram.com/mistplay/?hl=en\" style=\"font-size: 15px\">Instagram</a><span style=\"font-size: 15px\">,&nbsp;</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\">&nbsp;and&nbsp;</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) &amp; 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&eacute;ussite client I (Senior Customer Success Manager I) au sein du d&eacute;partement LoyaltyPlay de Mistplay jouera un r&ocirc;le cl&eacute; dans le succ&egrave;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&egrave;verez du directeur de la r&eacute;ussite client. Votre r&ocirc;le sera d'assurer une int&eacute;gration harmonieuse des marques et de fournir des recommandations strat&eacute;giques et proactives qui am&eacute;liorent la satisfaction client et favorisent la fid&eacute;lisation.</p>\n<h4>Vos responsabilit&eacute;s chez Mistplay</h4>\n<ul>\n<li>\n<p>Agir en tant que liaison principale entre les clients et les &eacute;quipes internes pour assurer une communication claire et une ex&eacute;cution fluide des campagnes.</p>\n</li>\n<li>\n<p>Interagir directement avec les utilisateurs finaux et les &eacute;quipes de support client pour d&eacute;panner, v&eacute;rifier et transmettre les probl&egrave;mes de support du programme.</p>\n</li>\n<li>\n<p>D&eacute;velopper et maintenir des plans de parcours client, en assurant une ex&eacute;cution rapide et pr&eacute;cise de toutes les t&acirc;ches.</p>\n</li>\n<li>\n<p>Diriger les appels clients, y compris l'int&eacute;gration des clients, la formation, les agendas d'&eacute;quipes interfonctionnelles et les revues de performance.</p>\n</li>\n<li>\n<p>G&eacute;rer de mani&egrave;re proactive les probl&egrave;mes clients, les signalements et les attentes pour maintenir un niveau &eacute;lev&eacute; de satisfaction.</p>\n</li>\n<li>\n<p>&Eacute;tablir des relations solides avec les clients, en s'engageant activement pour &eacute;valuer leurs besoins et leur &eacute;tat de sant&eacute;.</p>\n</li>\n<li>\n<p>Conserver et faire cro&icirc;tre les comptes clients.</p>\n</li>\n<li>\n<p>Fournir des recommandations bas&eacute;es sur les donn&eacute;es pour am&eacute;liorer les strat&eacute;gies marketing des clients et augmenter le potentiel de revenus.</p>\n</li>\n<li>\n<p>Analyser les performances des campagnes, cr&eacute;er des rapports d&eacute;taill&eacute;s et pr&eacute;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 &eacute;quipes internes sur les solutions.</p>\n</li>\n<li>\n<p>Devenir un expert en la mati&egrave;re sur toutes les offres de LoyaltyPlay par Mistplay, en assurant une compr&eacute;hension approfondie des produits et services.</p>\n</li>\n<li>\n<p>Superviser le processus d'int&eacute;gration de bout en bout pour les nouveaux clients, en assurant une int&eacute;gration technique et op&eacute;rationnelle fluide.</p>\n</li>\n<li>\n<p>G&eacute;rer les outils op&eacute;rationnels tels que HubSpot et JIRA pour suivre les activit&eacute;s, trier les tickets et transmettre les probl&egrave;mes au besoin.</p>\n</li>\n<li>\n<p>Aider &agrave; l'assurance qualit&eacute; (AQ) des campagnes en arri&egrave;re-plan, au d&eacute;pannage et aux cas de support li&eacute;s aux utilisateurs finaux.</p>\n</li>\n<li>\n<p>S'associer aux ventes, aux produits et aux autres membres de l'&eacute;quipe de r&eacute;ussite client pour s'aligner sur les objectifs des clients et fournir des r&eacute;sultats exceptionnels.</p>\n</li>\n<li>\n<p>Fournir des commentaires aux &eacute;quipes internes pour am&eacute;liorer les offres en fonction des retours clients.</p>\n</li>\n<li>\n<p>&Eacute;laborer et partager les meilleures pratiques pour permettre une adoption r&eacute;ussie des solutions par les clients.</p>\n</li>\n</ul>\n<h4>Ce que vous apporterez &agrave; Mistplay</h4>\n<ul>\n<li>\n<p>Baccalaur&eacute;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&eacute;rience en gestion de comptes, en r&eacute;ussite client ou dans un r&ocirc;le similaire.</p>\n</li>\n<li>\n<p>Compr&eacute;hension approfondie des strat&eacute;gies et des outils de marketing num&eacute;rique, avec une expertise sp&eacute;cifique dans les secteurs de la restauration, du commerce de d&eacute;tail et des d&eacute;panneurs.</p>\n</li>\n<li>\n<p>Excellentes comp&eacute;tences organisationnelles avec la capacit&eacute; de g&eacute;rer plusieurs priorit&eacute;s et &eacute;ch&eacute;ances.</p>\n</li>\n<li>\n<p>Ma&icirc;trise des outils CRM (ex. HubSpot) et des syst&egrave;mes de billetterie (ex. JIRA).</p>\n</li>\n<li>\n<p>Esprit analytique avec de l'exp&eacute;rience dans le reporting de performance et la prise de d&eacute;cision bas&eacute;e sur les donn&eacute;es.</p>\n</li>\n<li>\n<p>Comp&eacute;tences exceptionnelles en communication et relations interpersonnelles, avec une attitude ax&eacute;e sur le client.</p>\n</li>\n<li>\n<p>Capacit&eacute; av&eacute;r&eacute;e &agrave; travailler de mani&egrave;re ind&eacute;pendante dans un environnement 100 % &agrave; distance.</p>\n</li>\n<li>\n<p>Une exp&eacute;rience dans les secteurs de la technologie publicitaire (ad tech), du jeu vid&eacute;o, des plateformes de fid&eacute;lisation, de la restauration ou des d&eacute;panneurs est pr&eacute;f&eacute;rable.</p>\n</li>\n<li>\n<p>Expertise technique en int&eacute;gration, onboarding et analyse de donn&eacute;es, avec un accent sur l'identification des opportunit&eacute;s de vente incitative (upsell).</p>\n</li>\n<li>\n<p>Forte familiarit&eacute; avec les processus d'assurance qualit&eacute; des campagnes de marketing de fid&eacute;lisation et les flux de travail de r&eacute;vision cr&eacute;ative.</p>\n</li>\n</ul>\n<p>&nbsp;</p>\n<h4 style=\"text-align: center;\">Senior Customer Success Manager I (Contract)&nbsp;</h4>\n<div>\n<p>The Senior Customer Success Manager I within Mistplay&rsquo;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&rsquo;ll do at Mistplay</strong></p>\n<p>&bull;Act as the primary liaison between customers and internal teams to ensure clear communication and seamless execution of campaigns.</p>\n<p>&bull;Interact directly with end-users and customer support teams to troubleshoot, verify, and escalate program support issues.</p>\n<p>&bull;Develop and maintain customer flight plans, ensuring timely and accurate execution of all tasks.</p>\n<p>&bull;Lead customer calls, including customer onboarding, training, cross-functional team agendas, and performance reviews.</p>\n<p>&bull;Proactively manage customer issues, escalations, and expectations to maintain high customer satisfaction.</p>\n<p>&bull;Build strong relationships with customers, actively engaging to assess their needs and health status.</p>\n<p>&bull;Retain and grow customer accounts.</p>\n<p>&bull;Provide data-driven recommendations to enhance client marketing strategies and increase revenue potential.</p>\n<p>&bull;Analyze campaign performance, create detailed reports, and present actionable insights to marketing leads.</p>\n<p>&bull;Identify strengths and weaknesses in customer implementations and collaborate with internal teams on solutions.</p>\n<p>&bull;Become a SME on all LoyaltyPlay by Mistplay offerings, ensuring a deep understanding of products and services.</p>\n<p>&bull;Oversee the end-to-end onboarding process for new customers, ensuring smooth technical and operational integration.</p>\n<p>&bull;Manage operational tools like HubSpot and JIRA to track activities, triage tickets, and escalate issues as needed.</p>\n<p>&bull;Assist in backend campaign QA, troubleshooting, and end-user support-related cases.</p>\n<p>&bull;Partner with Sales, Product, and other Client Success team members to align on client goals and deliver outstanding results.</p>\n<p>&bull;Provide feedback to internal teams to improve offerings based on customer input.</p>\n<p>&bull;Build and share best practices to enable successful adoption of solutions by customers.</p>\n<p>&nbsp;</p>\n<p><strong>What you&rsquo;ll bring to Mistplay</strong></p>\n<p>&bull;Bachelor&rsquo;s degree in Business, Marketing, Communications, or a related field.</p>\n<p>&bull;2+ years of experience in account management, customer success, or a similar role.</p>\n<p>&bull;Deep understanding of digital marketing strategies and tools, with specific expertise in the restaurant, retail, and convenience industries.</p>\n<p>&bull;Excellent organizational skills with the ability to manage multiple priorities and deadlines.</p>\n<p>&bull;Proficiency in CRM tools (e.g., HubSpot) and ticketing systems (e.g., JIRA).</p>\n<p>&bull;Analytical mindset with experience in performance reporting and data-driven decision-making.</p>\n<p>&bull;Exceptional communication and interpersonal skills, with a customer-first attitude.</p>\n<p>&bull;Demonstrates proven ability to work independently in a 100% remote environment.</p>\n<p>&bull;Experience in ad tech, gaming, loyalty platforms, restaurant, and/or convenience store industries is preferred.</p>\n<p>&bull;Technical expertise in onboarding, integrations, and data analysis, with an emphasis on identifying upsell opportunities.</p>\n<p>&bull;Strong familiarity with loyalty marketing campaign QA processes and creative review workflows.</p>\n</div>\n<p>&nbsp;</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>&nbsp;<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&nbsp;</span><a style=\"font-size: 15px\" href=\"https://play.google.com/store/apps/details?id=com.mistplay.mistplay&amp;hl=en_CA&amp;gl=US\">ici</a><span style=\"font-size: 15px\">&nbsp;et suivez-nous sur&nbsp;</span><a style=\"font-size: 15px\" href=\"https://www.instagram.com/mistplay/?hl=en\">Instagram</a><span style=\"font-size: 15px\">,&nbsp;</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\">&nbsp;et&nbsp;</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) &amp; 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&nbsp;</span><a style=\"font-size: 15px\" href=\"https://play.google.com/store/apps/details?id=com.mistplay.mistplay&amp;hl=en_CA&amp;gl=US\">here</a><span style=\"font-size: 15px\">&nbsp;and follow us on&nbsp;</span><a style=\"font-size: 15px\" href=\"https://www.instagram.com/mistplay/?hl=en\">Instagram</a><span style=\"font-size: 15px\">,&nbsp;</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\">&nbsp;and&nbsp;</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) &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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 &amp; 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>&nbsp;</h3>\n<h3>&nbsp;</h3>\n<h3><strong>Français:&nbsp;</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 &amp; 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 &amp; expertise!</p>\n<p>At Mistplay, we are very passionate about internal equity, &amp; ensuring all staff members are paid fairly &amp; equally for their position, based on internal &amp; external data. The final salary offered will vary based on candidate's capabilities, experience &amp; 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&nbsp;</span><a href=\"https://play.google.com/store/apps/details?id=com.mistplay.mistplay&amp;hl=en_CA&amp;gl=US\" style=\"font-size: 15px\">ici</a><span style=\"font-size: 15px\">&nbsp;et suivez-nous sur&nbsp;</span><a href=\"https://www.instagram.com/mistplay/?hl=en\" style=\"font-size: 15px\">Instagram</a><span style=\"font-size: 15px\">,&nbsp;</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\">&nbsp;et&nbsp;</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) &amp; 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&nbsp;</span><a href=\"https://play.google.com/store/apps/details?id=com.mistplay.mistplay&amp;hl=en_CA&amp;gl=US\" style=\"font-size: 15px\">here</a><span style=\"font-size: 15px\">&nbsp;and follow us on&nbsp;</span><a href=\"https://www.instagram.com/mistplay/?hl=en\" style=\"font-size: 15px\">Instagram</a><span style=\"font-size: 15px\">,&nbsp;</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\">&nbsp;and&nbsp;</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) &amp; 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&mdash;crafting high-converting, visually efficient video assets across paid social and programmatic channels (TikTok, Meta, YouTube, Unity, etc.).</p>\n<p>You&rsquo;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&rsquo;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 &amp; 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 &amp; 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 &amp; 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 &amp; 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&rsquo;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&mdash;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 &amp; 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 &amp; 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>&nbsp;</h3>\n<h3>&nbsp;</h3>\n<h3><strong>Fran&ccedil;ais:&nbsp;</strong></h3>\n<p>Nous sommes &agrave; la recherche d'un concepteur ou d'une conceptrice en animation (Senior Motion Designer) de haut niveau, ax&eacute; sur la performance, pour rejoindre notre studio de cr&eacute;ation. Dans ce r&ocirc;le, vous serez le moteur cr&eacute;atif de nos publicit&eacute;s vid&eacute;o d'acquisition d'utilisateurs (UA) &mdash; en cr&eacute;ant des &eacute;l&eacute;ments vid&eacute;o &agrave; fort taux de conversion et visuellement efficaces sur les m&eacute;dias sociaux payants et les canaux programmatiques (TikTok, Meta, YouTube, Unity, etc.).</p>\n<p>Vous combinerez des s&eacute;quences de contenu g&eacute;n&eacute;r&eacute; par les utilisateurs (UGC), du r&eacute;cit de jeu (gameplay), des animations fluides et des donn&eacute;es cr&eacute;atives pour accro&icirc;tre notre base d'utilisateurs mondiale.</p>\n<p>Vous &ecirc;tes de nature curieuse et vous avez h&acirc;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&eacute;o UA de bout en bout : Concevoir, sc&eacute;nariser, designer, animer et monter des publicit&eacute;s vid&eacute;o percutantes de format court, optimis&eacute;es pour les canaux d'acquisition mobile.</p>\n</li>\n<li>\n<p>It&eacute;ration cr&eacute;ative et mise &agrave; l'&eacute;chelle : Reprendre les concepts publicitaires gagnants et produire rapidement des variations de haute qualit&eacute; (accroches, appels &agrave; l'action, m&eacute;caniques de jeu, conception sonore, styles visuels) pour contrer la fatigue publicitaire et tester des hypoth&egrave;ses de performance.</p>\n</li>\n<li>\n<p>Collaboration avec les &eacute;quipes cr&eacute;atives et UA : Collaborer &eacute;troitement avec les concepteurs en animation, les strat&egrave;ges cr&eacute;atifs, les r&eacute;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 &agrave; l'aff&ucirc;t des tendances du jeu mobile, des styles de montage propres aux plateformes (style natif TikTok/Reels vs gameplay cin&eacute;matique) et des flux de travail technologiques cr&eacute;atifs/IA pour &eacute;lever continuellement notre production cr&eacute;ative.</p>\n</li>\n<li>\n<p>Mentorat et normes de qualit&eacute; : Aider &agrave; &eacute;tablir les standards de qualit&eacute; visuelle, de finition d'animation et d'efficacit&eacute; op&eacute;rationnelle au sein de l'&eacute;quipe cr&eacute;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&eacute;rience professionnelle en conception d'animation, avec une solide expertise en acquisition d'utilisateurs mobiles (UA), en marketing de r&eacute;ponse directe ou en cr&eacute;ation ax&eacute;e sur la performance.</p>\n</li>\n<li>\n<p>Ma&icirc;trise d'Adobe Creative Cloud (After Effects, Premiere Pro, Photoshop, Illustrator) avec une expertise approfondie en animation 2D, typographie, montage, r&eacute;cit visuel et texte cin&eacute;tique.</p>\n</li>\n<li>\n<p>Passionn&eacute;(e) de jeux mobiles : Une compr&eacute;hension approfondie des m&eacute;caniques de jeu mobile, du montage de gameplay et du langage visuel UI/UX mobile.</p>\n</li>\n<li>Connaissance des outils cr&eacute;atifs bas&eacute;s sur l'IA (Midjourney, Runway, Stable Diffusion) pour acc&eacute;l&eacute;rer la conceptualisation et la g&eacute;n&eacute;ration d'actifs.</li>\n<li>\n<p>Esprit ax&eacute; sur la performance : Vous ne vous contentez pas de cr&eacute;er de beaux visuels &mdash; vous comprenez pourquoi une accroche de 3 secondes fonctionne, comment le rythme visuel stimule la conversion et comment interpr&eacute;ter les donn&eacute;es de performance cr&eacute;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&eacute;s &agrave; la vid&eacute;o courte pens&eacute;e pour le mobile.</p>\n</li>\n<li>\n<p>Portfolio ou d&eacute;mo solide : Une d&eacute;mo robuste d&eacute;montrant des graphismes anim&eacute;s de haute qualit&eacute;, des publicit&eacute;s vid&eacute;o attrayantes et un r&eacute;cit visuel dynamique (particuli&egrave;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&eacute;rience en conception d'animation 3D (Cinema 4D, Blender ou Maya).</p>\n</li>\n<li>\n<p>Exp&eacute;rience avec les publicit&eacute;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>&nbsp;<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&nbsp;</span><a style=\"font-size: 15px;\" href=\"https://play.google.com/store/apps/details?id=com.mistplay.mistplay&amp;hl=en_CA&amp;gl=US\">here</a><span style=\"font-size: 15px;\">&nbsp;and follow us on&nbsp;</span><a style=\"font-size: 15px;\" href=\"https://www.instagram.com/mistplay/?hl=en\">Instagram</a><span style=\"font-size: 15px;\">,&nbsp;</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;\">&nbsp;and&nbsp;</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:&nbsp;</strong></div>\n<div>&nbsp;</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>&nbsp;</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>•&nbsp;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>&nbsp;</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>&nbsp;</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, &amp; based on base salary only, there is a bonus component &amp; 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.&nbsp;</p>\n<p>At Mistplay, we are very passionate about internal equity, &amp; ensuring all staff members are paid fairly &amp; equally for their position. The final salary offered will vary based on candidate experience &amp; 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!&nbsp;</p>\n<p>&nbsp;</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&nbsp;</span><a href=\"https://play.google.com/store/apps/details?id=com.mistplay.mistplay&amp;hl=en_CA&amp;gl=US\" style=\"font-size: 15px;\">here</a><span style=\"font-size: 15px;\">&nbsp;and follow us on&nbsp;</span><a href=\"https://www.instagram.com/mistplay/?hl=en\" style=\"font-size: 15px;\">Instagram</a><span style=\"font-size: 15px;\">,&nbsp;</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;\">&nbsp;and&nbsp;</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:&nbsp;</strong></div>\n<div>&nbsp;</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>&nbsp;</div>\n<div><strong>What You'll Do at Mistplay</strong>:</div>\n<div>&bull; You&rsquo;ll be part of the cross functional group responsible for the overall rewarding and the loyalty economy strategy for Mistplay.</div>\n<div>&bull; Create and implement machine learning driven rewards economy systems and core loops.</div>\n<div>&bull; Continuously analyze and optimize the economy strategies and implementations to enhance engagement and drive business outcomes.</div>\n<div>&bull; Drive the strategy on how machine learning and AI can further advance the optimization for better user experience and business performance.</div>\n<div>&bull; Distill complex model outputs into actionable strategic insights for executive stakeholders, advocating for AI-first approaches to loyalty and retention.</div>\n<div>&bull; 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>&bull;&nbsp;Serve as the primary architect for Mistplay&rsquo;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>&bull; Partner with Product, Analytics, Engineering, and Finance to translate high-level business goals into algorithmic constraints and incentive structures.</div>\n<div>&nbsp;</div>\n<div><strong>What You'll Bring to Mistplay</strong>:</div>\n<div>&bull; A combination of 8+ years in Data Science, Machine Learning, Quant, Econometrics, and/or Master&rsquo;s or Ph.D. in Data Science, Machine Learning, or a related quantitative field.</div>\n<div>&bull; Understanding of reinforcement learning (DeepQ, contextual bandits)</div>\n<div>&bull; Expertise in implementing machine learning models using Python and PyTorch, paired with strong data analysis skills using SQL and Python.</div>\n<div>&bull; Fast and agile approach to exploring and implementing solutions with a strategic focus on the core loop and economy design.</div>\n<div>&bull; 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>&bull; A passion for innovative AI solutions, with demonstrated knowledge in real-time predictive modeling.</div>\n<div>&bull; 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>&nbsp;</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&nbsp;!</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