[ka6] fix connecteur h2o_innovation: le babillard fr_CA du tenant ADP WFN a cessé d'exposer requisitionLocations (liste+détail, 0/11) entre 00:06 et 02:35 EDT le 2026-10-23 — même nuit et même mécanique que bento_sushi (e0743907) — le filtre Québec rejetait les 10 offres pourtant toujours publiées (mêmes itemID). Particularité H2O vs bento: le babillard en_US du MÊME tenant expose encore ses locations (19/19) mais ne partage que ~3 itemID avec fr_CA, et les descriptions ne publient pas d'adresse complète (seulement « usine située à Ham-Nord », « bureau de Québec »). Correctif scoped au connecteur (_keep + _resolve_blind): une réquisition sans location passe le filtre puis son lieu est résolu sans rien inventer — 1) offre active connue en base = ville/code postal qu'ADP exposait avant la panne (offre revalidée live par la liste), 2) jointure par itemID sur le babillard en_US (locations ADP live; hors-QC écartée — cas réel « L'ingénieur.e de procédés » Burlington ON), 3) ville citée dans la description de l'employeur validée par region_for_city. Une offre sans marqueur QC est écartée; chemin normal repris tel quel si ADP ré-expose les locations fr_CA (fallback dormant). Sync isolé rejoué: ok=1 found=10 (= médiane 10.0) en 20,8 s, 10 actives avec ville/postal/région/GPS 100 %. 108 tests verts, pm2 restart job-ka-sync OK, site local 200, www.job-ka.com 200. Fiches h2o_innovation.md/ats-adp.md/INDEX.md régénérées (1131 autres fiches restaurées, hors périmètre).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 changed files +138 −20
modified
docs/connecteurs/INDEX.md
+2 −2
@@ -5,14 +5,14 @@ Auteur : Simon-Pierre Boucher | |||||
| 5 | Contact : contact@spboucher.ai | 5 | Contact : contact@spboucher.ai | ||
| 6 | Fichier : docs/connecteurs/INDEX.md | 6 | Fichier : docs/connecteurs/INDEX.md | ||
| 7 | Rôle : Index généré de la documentation des connecteurs (ATS, employeurs, portails, dépôt) | 7 | Rôle : Index généré de la documentation des connecteurs (ATS, employeurs, portails, dépôt) | ||
| 8 | −Généré : 2026-10-23 05:25 EDT par scripts/gen_connector_docs.py — NE PAS ÉDITER, | 8 | +Généré : 2026-10-23 05:43 EDT par scripts/gen_connector_docs.py — NE PAS ÉDITER, | ||
| 9 | relancer le script pour régénérer. | 9 | relancer le script pour régénérer. | ||
| 10 | ============================================================================= | 10 | ============================================================================= | ||
| 11 | --> | 11 | --> | ||
| 12 | 12 | ||||
| 13 | # Job·Ka — Connecteurs (index) | 13 | # Job·Ka — Connecteurs (index) | ||
| 14 | 14 | ||||
| 15 | −> Index généré le 2026-10-23 05:25 EDT par `scripts/gen_connector_docs.py` (rejouable). Croise `data/sources.json`, l'introspection de `jobka/connectors/*.py` et la base live `data/jobka.db`. | 15 | +> Index généré le 2026-10-23 05:43 EDT par `scripts/gen_connector_docs.py` (rejouable). Croise `data/sources.json`, l'introspection de `jobka/connectors/*.py` et la base live `data/jobka.db`. | ||
| 16 | 16 | ||||
| 17 | **1104 employeurs directs** connectés via **25 plateformes ATS** + **3 portails agrégateurs** + le canal de **dépôt direct** — **25937 offres actives** (23091 directes, 2846 portails ; 162 masquées comme doublons inter-sources, soit 25775 visibles). 1134 fiches. | 17 | **1104 employeurs directs** connectés via **25 plateformes ATS** + **3 portails agrégateurs** + le canal de **dépôt direct** — **25937 offres actives** (23091 directes, 2846 portails ; 162 masquées comme doublons inter-sources, soit 25775 visibles). 1134 fiches. | ||
| 18 | 18 | ||||
modified
docs/connecteurs/ats-adp.md
+5 −5
@@ -5,7 +5,7 @@ Auteur : Simon-Pierre Boucher | |||||
| 5 | Contact : contact@spboucher.ai | 5 | Contact : contact@spboucher.ai | ||
| 6 | Fichier : docs/connecteurs/ats-adp.md | 6 | Fichier : docs/connecteurs/ats-adp.md | ||
| 7 | Rôle : Fiche plateforme ATS adp — mécanique partagée + tableau des 17 tenants | 7 | Rôle : Fiche plateforme ATS adp — mécanique partagée + tableau des 17 tenants | ||
| 8 | −Généré : 2026-10-23 05:25 EDT par scripts/gen_connector_docs.py — NE PAS ÉDITER, | 8 | +Généré : 2026-10-23 05:43 EDT par scripts/gen_connector_docs.py — NE PAS ÉDITER, | ||
| 9 | relancer le script pour régénérer. | 9 | relancer le script pour régénérer. | ||
| 10 | ============================================================================= | 10 | ============================================================================= | ||
| 11 | --> | 11 | --> | ||
@@ -46,12 +46,12 @@ Généré : 2026-10-23 05:25 EDT par scripts/gen_connector_docs.py — NE PAS | |||||
| 46 | | [Strathmore](strathmore.md) | 11 | 45 % | 100 % | 9 % | 2026-10-23 03:15 | ✅ | | 46 | | [Strathmore](strathmore.md) | 11 | 45 % | 100 % | 9 % | 2026-10-23 03:15 | ✅ | | ||
| 47 | | [Quincaillerie Richelieu](richelieu.md) | 11 | 0 % | 100 % | 0 % | 2026-10-23 03:04 | ✅ | | 47 | | [Quincaillerie Richelieu](richelieu.md) | 11 | 0 % | 100 % | 0 % | 2026-10-23 03:04 | ✅ | | ||
| 48 | | [Cook It](cookit.md) | 11 | 64 % | 100 % | 0 % | 2026-10-23 04:26 | ✅ | | 48 | | [Cook It](cookit.md) | 11 | 64 % | 100 % | 0 % | 2026-10-23 04:26 | ✅ | | ||
| 49 | −| [Canadian Royalties](canadian_royalties.md) | 10 | 40 % | 100 % | 0 % | 2026-10-23 04:03 | ✅ | | 49 | +| [Canadian Royalties](canadian_royalties.md) | 10 | 40 % | 100 % | 0 % | 2026-10-23 05:35 | ✅ | | ||
| 50 | −| [H2O Innovation](h2o_innovation.md) | 10 | 10 % | 100 % | 0 % | 2026-10-23 04:58 | ✅ | | 50 | +| [H2O Innovation](h2o_innovation.md) | 10 | 10 % | 100 % | 0 % | 2026-10-23 05:43 | ✅ | | ||
| 51 | −| [Bento Sushi](bento_sushi.md) | 8 | 88 % | 100 % | 0 % | 2026-10-23 05:25 | ✅ | | 51 | +| [Bento Sushi](bento_sushi.md) | 8 | 88 % | 100 % | 0 % | 2026-10-23 05:33 | ✅ | | ||
| 52 | | [MSI Gestion immobilière](msi_gestion.md) | 6 | 100 % | 100 % | 50 % | 2026-10-23 02:55 | ✅ | | 52 | | [MSI Gestion immobilière](msi_gestion.md) | 6 | 100 % | 100 % | 50 % | 2026-10-23 02:55 | ✅ | | ||
| 53 | | [Cultures Gen V](cultures_genv.md) | 5 | 20 % | 100 % | 0 % | 2026-10-23 04:26 | ✅ | | 53 | | [Cultures Gen V](cultures_genv.md) | 5 | 20 % | 100 % | 0 % | 2026-10-23 04:26 | ✅ | | ||
| 54 | −| [Abritek](abritek.md) | 5 | 100 % | 100 % | 0 % | 2026-10-23 03:53 | ✅ | | 54 | +| [Abritek](abritek.md) | 5 | 100 % | 100 % | 0 % | 2026-10-23 05:26 | ✅ | | ||
| 55 | | [Mitsubishi HC Capital Canada](mitsubishi_hc_capital.md) | 1 | 0 % | 100 % | 0 % | 2026-10-23 02:54 | ✅ | | 55 | | [Mitsubishi HC Capital Canada](mitsubishi_hc_capital.md) | 1 | 0 % | 100 % | 0 % | 2026-10-23 02:54 | ✅ | | ||
| 56 | | [Osgoode Properties](osgoode_properties.md) | 1 | 100 % | 100 % | 0 % | 2026-10-23 02:57 | ✅ | | 56 | | [Osgoode Properties](osgoode_properties.md) | 1 | 100 % | 100 % | 0 % | 2026-10-23 02:57 | ✅ | | ||
| 57 | | [Niobec (Magris Performance Materials)](niobec.md) | 1 | 100 % | 100 % | 0 % | 2026-10-23 02:56 | ✅ | | 57 | | [Niobec (Magris Performance Materials)](niobec.md) | 1 | 100 % | 100 % | 0 % | 2026-10-23 02:56 | ✅ | | ||
modified
docs/connecteurs/h2o_innovation.md
+7 −7
@@ -5,7 +5,7 @@ Auteur : Simon-Pierre Boucher | |||||
| 5 | Contact : contact@spboucher.ai | 5 | Contact : contact@spboucher.ai | ||
| 6 | Fichier : docs/connecteurs/h2o_innovation.md | 6 | Fichier : docs/connecteurs/h2o_innovation.md | ||
| 7 | Rôle : Fiche connecteur — H2O Innovation (employeur direct, ATS adp) | 7 | Rôle : Fiche connecteur — H2O Innovation (employeur direct, ATS adp) | ||
| 8 | −Généré : 2026-10-14 18:40 EDT par scripts/gen_connector_docs.py — NE PAS ÉDITER, | 8 | +Généré : 2026-10-23 05:43 EDT par scripts/gen_connector_docs.py — NE PAS ÉDITER, | ||
| 9 | relancer le script pour régénérer. | 9 | relancer le script pour régénérer. | ||
| 10 | ============================================================================= | 10 | ============================================================================= | ||
| 11 | --> | 11 | --> | ||
@@ -38,7 +38,7 @@ Généré : 2026-10-14 18:40 EDT par scripts/gen_connector_docs.py — NE PAS | |||||
| 38 | 38 | ||||
| 39 | ## Champs → schéma | 39 | ## Champs → schéma | ||
| 40 | 40 | ||||
| 41 | −Complétude mesurée sur les **10 offres actives** de la source (base live, 2026-10-14 18:40 EDT). | 41 | +Complétude mesurée sur les **10 offres actives** de la source (base live, 2026-10-23 05:43 EDT). | ||
| 42 | 42 | ||||
| 43 | | Champ (`jobs.*`) | Rôle | Complétude | | 43 | | Champ (`jobs.*`) | Rôle | Complétude | | ||
| 44 | |---|---|---| | 44 | |---|---|---| | ||
@@ -64,7 +64,7 @@ Complétude mesurée sur les **10 offres actives** de la source (base live, 2026 | |||||
| 64 | | `apply_url` — Candidature | lien de candidature directe | 0 % | | 64 | | `apply_url` — Candidature | lien de candidature directe | 0 % | | ||
| 65 | | `gps` — GPS | lat/lng (géocodage sans invention) | 100 % | | 65 | | `gps` — GPS | lat/lng (géocodage sans invention) | 100 % | | ||
| 66 | 66 | ||||
| 67 | −Exemple réel : *Technicien.ne de service* — Ham-Nord · ∅ · parue le 2026-10-07. | 67 | +Exemple réel : *Programmeur.e automatisation et contrôles* — Ham-Nord · ∅ · parue le 2026-10-23. | ||
| 68 | 68 | ||||
| 69 | ## Fréquence & budgets | 69 | ## Fréquence & budgets | ||
| 70 | 70 | ||||
@@ -79,15 +79,15 @@ Exemple réel : *Technicien.ne de service* — Ham-Nord · ∅ · parue le 2026- | |||||
| 79 | 79 | ||||
| 80 | ## Volumétrie & complétude live | 80 | ## Volumétrie & complétude live | ||
| 81 | 81 | ||||
| 82 | −*(mesuré le 2026-10-14 18:40 EDT dans `data/jobka.db`)* | 82 | +*(mesuré le 2026-10-23 05:43 EDT dans `data/jobka.db`)* | ||
| 83 | 83 | ||||
| 84 | −- **Offres en base** : 17 (dont **10 actives**, 0 masquées comme doublons `dup_of`) | 84 | +- **Offres en base** : 19 (dont **10 actives**, 0 masquées comme doublons `dup_of`) | ||
| 85 | - **Salaire** : 1/10 actives (10 %) | 85 | - **Salaire** : 1/10 actives (10 %) | ||
| 86 | - **GPS** : 10/10 actives (100 %) | 86 | - **GPS** : 10/10 actives (100 %) | ||
| 87 | - **Mode** : 0/10 actives (0 %) | 87 | - **Mode** : 0/10 actives (0 %) | ||
| 88 | - **Parution** : 10/10 actives (100 %) | 88 | - **Parution** : 10/10 actives (100 %) | ||
| 89 | −- **Dernier sync** : 2026-10-14 15:17 — trouvées 10, +0, ~0, -0 | 89 | +- **Dernier sync** : 2026-10-23 05:43 — trouvées 10, +0, ~10, -0 | ||
| 90 | −- **Syncs enregistrés** (`sync_log`) : 485 | 90 | +- **Syncs enregistrés** (`sync_log`) : 562 | ||
| 91 | 91 | ||||
| 92 | ## Erreurs & dépannage | 92 | ## Erreurs & dépannage | ||
| 93 | 93 | ||||
modified
jobka/connectors/h2o_innovation.py
+124 −6
@@ -5,8 +5,8 @@ | |||||
| 5 | # Fichier : jobka/connectors/h2o_innovation.py | 5 | # Fichier : jobka/connectors/h2o_innovation.py | ||
| 6 | # Rôle : Connecteur H2O Innovation — traitement de l'eau, Québec — ADP WFN | 6 | # Rôle : Connecteur H2O Innovation — traitement de l'eau, Québec — ADP WFN | ||
| 7 | # + repli « détail seulement » quand l'API liste échoue | 7 | # + repli « détail seulement » quand l'API liste échoue | ||
| 8 | −# (maintenances nocturnes récurrentes d'ADP) | 8 | +# + résolution de lieu quand ADP n'expose plus requisitionLocations | ||
| 9 | −# Créé : 2026-08-25 Modifié : 2026-08-30 | 9 | +# Créé : 2026-08-25 Modifié : 2026-10-23 | ||
| 10 | # ============================================================================= | 10 | # ============================================================================= | ||
| 11 | """H2O Innovation — ADP Workforce Now, avec repli de revalidation. | 11 | """H2O Innovation — ADP Workforce Now, avec repli de revalidation. | ||
| 12 | 12 | ||||
@@ -18,7 +18,7 @@ column 1 » du sync de 22:19 (29/08). Comme le cycle complet met ~3 h entre | |||||
| 18 | deux passages de la source, un seul passage raté pendant la fenêtre suffit à | 18 | deux passages de la source, un seul passage raté pendant la fenêtre suffit à | ||
| 19 | dépasser le seuil stale de 4 h. | 19 | dépasser le seuil stale de 4 h. | ||
| 20 | 20 | ||||
| 21 | −D'où le repli ci-dessous (même pattern que cultures_genv.py / | 21 | +D'où le repli « détail seulement » (même pattern que cultures_genv.py / | ||
| 22 | groupe_nordik.py / ciena.py) : si la pagination liste échoue en 5xx ou en | 22 | groupe_nordik.py / ciena.py) : si la pagination liste échoue en 5xx ou en | ||
| 23 | non-JSON, on revalide une à une les offres actives connues en base via l'API | 23 | non-JSON, on revalide une à une les offres actives connues en base via l'API | ||
| 24 | détail job-requisitions/<itemID> (qui renvoie la réquisition complète : | 24 | détail job-requisitions/<itemID> (qui renvoie la réquisition complète : | ||
@@ -29,13 +29,45 @@ sorry.adp.com), l'erreur d'origine est propagée pour que le sync reste un | |||||
| 29 | échec (inventaire préservé). Limite assumée : le repli ne découvre pas les | 29 | échec (inventaire préservé). Limite assumée : le repli ne découvre pas les | ||
| 30 | NOUVELLES offres — le chemin liste normal reprend seul au cycle suivant la | 30 | NOUVELLES offres — le chemin liste normal reprend seul au cycle suivant la | ||
| 31 | fin de la maintenance. | 31 | fin de la maintenance. | ||
| 32 | + | ||||
| 33 | +Chronologie (mission ka6 2026-10-23) : entre le sync vert de 00:06 EDT | ||||
| 34 | +(found=10) et celui de 02:35 (found=0), le babillard fr_CA du tenant a CESSÉ | ||||
| 35 | +d'exposer `requisitionLocations` (liste ET détail, 0/11 — même nuit et même | ||||
| 36 | +mécanique que bento_sushi e0743907), alors que les 10 offres QC restent | ||||
| 37 | +publiées (mêmes itemID). Particularité H2O : le babillard en_US du MÊME | ||||
| 38 | +tenant expose encore ses locations (19/19), mais ne partage que ~3 itemID | ||||
| 39 | +avec le babillard fr_CA ; et les descriptions ne publient pas d'adresse | ||||
| 40 | +complète, seulement des mentions comme « usine située à Ham-Nord » ou | ||||
| 41 | +« bureau de Québec ». | ||||
| 42 | + | ||||
| 43 | +Adaptation (`_keep` + `_resolve_blind`) : une réquisition sans location | ||||
| 44 | +passe le filtre plateforme, puis son lieu est résolu sans rien inventer, | ||||
| 45 | +dans l'ordre : | ||||
| 46 | +1. offre ACTIVE CONNUE en base → ville/code postal qu'ADP exposait avant la | ||||
| 47 | + panne (l'offre est revalidée live : même itemID toujours publié) ; | ||||
| 48 | +2. jointure par itemID sur le babillard en_US du tenant (locations ADP | ||||
| 49 | + live) — une offre localisée hors Québec y est ÉCARTÉE (cas réel : | ||||
| 50 | + « L'ingénieur.e de procédés », Burlington ON) ; | ||||
| 51 | +3. ville citée dans la description publiée par l'employeur, validée contre | ||||
| 52 | + les municipalités du Québec (region_for_city). | ||||
| 53 | +Une offre sans aucun marqueur québécois est écartée. Si ADP ré-expose les | ||||
| 54 | +locations fr_CA, le chemin normal reprend tel quel (fallback dormant). | ||||
| 32 | """ | 55 | """ | ||
| 33 | from __future__ import annotations | 56 | from __future__ import annotations | ||
| 34 | 57 | ||||
| 58 | +import re | ||||
| 59 | + | ||||
| 35 | import requests | 60 | import requests | ||
| 36 | 61 | ||||
| 62 | +from ..regions import region_for_city | ||||
| 37 | from ..schema import JobPosting, clean_html | 63 | from ..schema import JobPosting, clean_html | ||
| 38 | −from .adp import _API, ADPWorkforceNowConnector | 64 | +from .adp import PAGE_SIZE, _API, ADPWorkforceNowConnector | ||
| 65 | + | ||||
| 66 | +# « …usine située à Ham-Nord », « …bureau de Québec », « …équipe à Ham-Nord » | ||||
| 67 | +_RX_DESC_CITY = re.compile( | ||||
| 68 | + r"(?:situ[ée]{1,2}s?\s+à|bas[ée]{1,2}s?\s+à|équipe\s+à" | ||||
| 69 | + r"|(?:bureau|usine|succursale|bureaux)\s+(?:de\s+|d['’]))\s*" | ||||
| 70 | + r"([A-ZÀ-ÿ][A-Za-zà-ÿ'’\-]+(?:[ -][A-ZÀ-ÿ][A-Za-zà-ÿ'’\-]+)*)") | ||||
| 39 | 71 | ||||
| 40 | 72 | ||||
| 41 | class H2oInnovationConnector(ADPWorkforceNowConnector): | 73 | class H2oInnovationConnector(ADPWorkforceNowConnector): | ||
@@ -46,7 +78,7 @@ class H2oInnovationConnector(ADPWorkforceNowConnector): | |||||
| 46 | 78 | ||||
| 47 | def fetch(self) -> list[JobPosting]: | 79 | def fetch(self) -> list[JobPosting]: | ||
| 48 | try: | 80 | try: | ||
| 49 | − return super().fetch() | 81 | + jobs = super().fetch() | ||
| 50 | except (requests.RequestException, ValueError) as exc: | 82 | except (requests.RequestException, ValueError) as exc: | ||
| 51 | resp = getattr(exc, "response", None) | 83 | resp = getattr(exc, "response", None) | ||
| 52 | if resp is not None and resp.status_code < 500: | 84 | if resp is not None and resp.status_code < 500: | ||
@@ -54,7 +86,93 @@ class H2oInnovationConnector(ADPWorkforceNowConnector): | |||||
| 54 | jobs = self._fetch_known_jobs() | 86 | jobs = self._fetch_known_jobs() | ||
| 55 | if jobs is None: | 87 | if jobs is None: | ||
| 56 | raise # API détail cassée aussi : vraie panne totale | 88 | raise # API détail cassée aussi : vraie panne totale | ||
| 57 | − return jobs | 89 | + return self._resolve_blind(jobs) | ||
| 90 | + | ||||
| 91 | + def _keep(self, locations: list[dict]) -> bool: | ||||
| 92 | + # Sans location (panne requisitionLocations du 2026-10-23), impossible | ||||
| 93 | + # de trancher ici : on laisse passer et _resolve_blind tranchera. | ||||
| 94 | + if not locations: | ||||
| 95 | + return True | ||||
| 96 | + return super()._keep(locations) | ||||
| 97 | + | ||||
| 98 | + def _known_locations(self) -> dict[str, tuple[str, str, str]]: | ||||
| 99 | + """itemID → (ville, code postal, libellé) des offres actives en base | ||||
| 100 | + — le lieu qu'ADP exposait lui-même avant la panne.""" | ||||
| 101 | + from .. import db | ||||
| 102 | + con = db.connect() | ||||
| 103 | + try: | ||||
| 104 | + rows = con.execute( | ||||
| 105 | + "SELECT external_id, city, postal_code, location_label " | ||||
| 106 | + "FROM jobs WHERE source=? AND active=1 AND city != ''", | ||||
| 107 | + (self.source_id,)).fetchall() | ||||
| 108 | + finally: | ||||
| 109 | + con.close() | ||||
| 110 | + return {str(eid): (city, postal or "", label or "") | ||||
| 111 | + for eid, city, postal, label in rows if eid} | ||||
| 112 | + | ||||
| 113 | + def _en_board_locations(self) -> dict[str, list[dict]]: | ||||
| 114 | + """itemID → locations du babillard en_US du tenant (locations ADP | ||||
| 115 | + live, encore exposées là alors que fr_CA est aveugle).""" | ||||
| 116 | + out: dict[str, list[dict]] = {} | ||||
| 117 | + skip = 0 | ||||
| 118 | + for _ in range(self.max_pages): | ||||
| 119 | + try: | ||||
| 120 | + data = self._get_json(_API, params=self._params( | ||||
| 121 | + {"lang": "en_US", "locale": "en_US", | ||||
| 122 | + "$top": str(PAGE_SIZE), "$skip": str(skip)})) | ||||
| 123 | + except (requests.RequestException, ValueError, RuntimeError): | ||||
| 124 | + return out # babillard en_US indisponible : signal absent | ||||
| 125 | + reqs = data.get("jobRequisitions") or [] | ||||
| 126 | + if not reqs: | ||||
| 127 | + break | ||||
| 128 | + for r in reqs: | ||||
| 129 | + iid = str(r.get("itemID") or "") | ||||
| 130 | + if iid: | ||||
| 131 | + out[iid] = self._locations(r) | ||||
| 132 | + skip += len(reqs) # l'API plafonne parfois sous $top | ||||
| 133 | + return out | ||||
| 134 | + | ||||
| 135 | + def _city_from_description(self, text: str) -> str: | ||||
| 136 | + """Ville QC citée dans la description de l'employeur, sinon ''.""" | ||||
| 137 | + for m in _RX_DESC_CITY.finditer(text or ""): | ||||
| 138 | + city = m.group(1).strip() | ||||
| 139 | + if region_for_city(city): | ||||
| 140 | + return city | ||||
| 141 | + return "" | ||||
| 142 | + | ||||
| 143 | + def _resolve_blind(self, jobs: list[JobPosting]) -> list[JobPosting]: | ||||
| 144 | + """Résout le lieu des offres sans location (rien n'est inventé).""" | ||||
| 145 | + if all(j.city for j in jobs): | ||||
| 146 | + return jobs # locations ADP normales : rien à faire | ||||
| 147 | + known = self._known_locations() | ||||
| 148 | + en_locs: dict[str, list[dict]] | None = None # paresseux (2e appel API) | ||||
| 149 | + kept: list[JobPosting] = [] | ||||
| 150 | + for job in jobs: | ||||
| 151 | + if job.city: | ||||
| 152 | + kept.append(job) | ||||
| 153 | + continue | ||||
| 154 | + row = known.get(job.external_id) | ||||
| 155 | + if row: # offre connue, revalidée live par la liste | ||||
| 156 | + job.city, job.postal_code, job.location_label = row | ||||
| 157 | + kept.append(job) | ||||
| 158 | + continue | ||||
| 159 | + if en_locs is None: | ||||
| 160 | + en_locs = self._en_board_locations() | ||||
| 161 | + locs = en_locs.get(job.external_id) | ||||
| 162 | + if locs: | ||||
| 163 | + qc = next((l for l in locs if l["prov"].upper() == "QC"), None) | ||||
| 164 | + if qc is None: | ||||
| 165 | + continue # localisée hors Québec : écartée | ||||
| 166 | + job.city, job.postal_code = qc["city"], qc["postal"] | ||||
| 167 | + job.location_label = qc["label"] | ||||
| 168 | + kept.append(job) | ||||
| 169 | + continue | ||||
| 170 | + city = self._city_from_description(job.description) | ||||
| 171 | + if not city: | ||||
| 172 | + continue # aucun marqueur québécois : écartée | ||||
| 173 | + job.city = city | ||||
| 174 | + kept.append(job) | ||||
| 175 | + return kept | ||||
| 58 | 176 | ||||
| 59 | def _known_ids(self) -> list[str]: | 177 | def _known_ids(self) -> list[str]: | ||
| 60 | """itemID (external_id) des offres actives de la source en base.""" | 178 | """itemID (external_id) des offres actives de la source en base.""" | ||
| 61 | 179 | ||||