[ka2] fix connecteur gestion_mj: contournement du challenge sgcaptcha SiteGround via proxy résidentiel Oxylabs — même cause et même fix que boreal_abitibi (8923045), citiluxx (d17f5fe), cromwell (8351131), deschenes_pepin (f8aca03), gestion_habitation (8b0660b), gimcote (12df764), appartements_rimouski (af2be7c) et lbm (5decd97), hébergeur SiteGround identique (9e connecteur touché). Les syncs du 2026-09-13 03:03 et 04:44 ont rapporté found=0 ok x2 (médiane 9, stale) : SiteGround sert son anti-bot sgcaptcha (HTTP 202, 166 octets, en-tête sg-captcha: challenge, meta refresh vers /.well-known/sgcaptcha/) à l'IP datacenter du nœud — un 2xx sans HTTPError dont le body HTML fait lever .json(), avalé par le `except Exception: return listings` de fetch() en « 0 trouvé ok », invisible du disjoncteur. Vérifié live : IP directe → 202 challenge ; IP résidentielle Oxylabs CA → 200, 2 090 octets JSON, les 9 posts du CPT a-louer présents. Fix minimal, pattern appartements_rimouski : _get_html() détecte le challenge (en-tête sg-captcha ou marqueur /.well-known/sgcaptcha/) et bascule la session sur pr.oxylabs.io:7777 en session collante -cc-CA (API wp-json + fiches détail partagent l'IP), erreur franche si le challenge persiste — l'appel API (liste unique, pas de pagination) lève désormais au lieu de retourner [] (fini le zéro silencieux, ingest journalise ok=0), idem si la réponse wp-json n'est plus une liste ; _fetch_detail() passe aussi par _get_html() pour ne pas empoisonner detail_cache avec un payload vide (clé figée jamais invalidée) ; les posts gardent le continue (résultats partiels acceptables). Le chemin direct reste tenté d'abord (gratuit si SiteGround déflague l'IP). Testé : fetch() venv → 9 annonces (studios 1 400-1 600 $, 4½ 1 650 $, 5½ 2 199 $ — 9155 Meilleur, 9820 Verville, 9050 av. du Parc, 4 photos chacune — médiane 9 restaurée), run.py sync gestion_mj → found=9 ok, pm2 restart lou-ka-sync, site :8095 → 200.
1 changed file +50 −6
modified
louka/connectors/gestion_mj.py
+50 −6
@@ -12,10 +12,20 @@ | ||
| 12 | 12 | # chambre(s) », « 900 pi² », « 02/03/2025 ») → chambres/typologie dérivées |
| 13 | 13 | # du TITRE (« Studio moderne », « Appartement 2/3 chambres »), icônes |
| 14 | 14 | # reléguées en details. Granularité : une annonce par unité (post CPT). |
| 15 | +# 2026-09-13 : SiteGround sert son anti-bot sgcaptcha (202 + challenge JS, | |
| 16 | +# en-tête sg-captcha) aux IP datacenter — invisible du wrapper résilient | |
| 17 | +# (2xx) ; on bascule alors la session sur un proxy résidentiel Oxylabs CA | |
| 18 | +# (même pattern que boreal_abitibi/citiluxx/cromwell/deschenes_pepin/ | |
| 19 | +# gestion_habitation/gimcote/appartements_rimouski/lbm, hébergeur | |
| 20 | +# identique). | |
| 15 | 21 | # ----------------------------------------------------------------------------- |
| 16 | 22 | from __future__ import annotations |
| 17 | 23 | |
| 24 | +import json | |
| 25 | +import os | |
| 18 | 26 | import re |
| 27 | +import time | |
| 28 | +from urllib.parse import quote | |
| 19 | 29 | |
| 20 | 30 | from bs4 import BeautifulSoup |
| 21 | 31 | |
@@ -26,6 +36,16 @@ BASE = "https://gestionmj.com" | ||
| 26 | 36 | API_URL = (f"{BASE}/wp-json/wp/v2/a-louer?per_page=100" |
| 27 | 37 | "&_fields=id,slug,link,title,modified") |
| 28 | 38 | |
| 39 | +# page interstitielle sgcaptcha SiteGround (meta refresh vers /.well-known/…) | |
| 40 | +_SG_MARKER = "/.well-known/sgcaptcha/" | |
| 41 | + | |
| 42 | + | |
| 43 | +def _sg_challenge(resp) -> bool: | |
| 44 | + """True si la réponse est le challenge anti-bot SiteGround (202 + JS).""" | |
| 45 | + if "challenge" in str(resp.headers.get("sg-captcha", "")).lower(): | |
| 46 | + return True | |
| 47 | + return _SG_MARKER in (resp.text or "")[:600] | |
| 48 | + | |
| 29 | 49 | BED_TITLE_RE = re.compile(r"(\d)\s*chambres?", re.I) |
| 30 | 50 | IMG_RE = re.compile(r"https://gestionmj\.com/wp-content/uploads/" |
| 31 | 51 | r'[^"\s\\)]+\.(?:jpe?g|png|webp)', re.I) |
@@ -35,14 +55,36 @@ class GestionMJConnector(BaseConnector): | ||
| 35 | 55 | source_id = "gestion_mj" |
| 36 | 56 | request_delay = 0.7 |
| 37 | 57 | |
| 58 | + def _enable_residential_proxy(self) -> bool: | |
| 59 | + """Route toute la session via Oxylabs résidentiel CA (session collante).""" | |
| 60 | + endpoint = os.environ.get("OXYLABS_PROXY") | |
| 61 | + user = os.environ.get("OXYLABS_PROXY_USER") | |
| 62 | + pwd = os.environ.get("OXYLABS_PROXY_PASS") | |
| 63 | + if not (endpoint and user and pwd): | |
| 64 | + return False | |
| 65 | + puser = f"{user}-cc-CA-sessid-gestionmj{int(time.time())}-sesstime-10" | |
| 66 | + proxy = f"http://{quote(puser, safe='')}:{quote(pwd, safe='')}@{endpoint}" | |
| 67 | + self.session.proxies = {"http": proxy, "https": proxy} | |
| 68 | + self.session.verify = False # CA MITM du proxy Oxylabs | |
| 69 | + return True | |
| 70 | + | |
| 71 | + def _get_html(self, url: str) -> str: | |
| 72 | + """GET avec contournement du challenge sgcaptcha (proxy résidentiel).""" | |
| 73 | + resp = self.get(url) | |
| 74 | + if not _sg_challenge(resp): | |
| 75 | + return resp.text | |
| 76 | + if not self.session.proxies and self._enable_residential_proxy(): | |
| 77 | + resp = self.get(url) | |
| 78 | + if not _sg_challenge(resp): | |
| 79 | + return resp.text | |
| 80 | + raise RuntimeError(f"challenge sgcaptcha non contourné — {url}") | |
| 81 | + | |
| 38 | 82 | def fetch(self) -> list[Listing]: |
| 39 | 83 | listings: list[Listing] = [] |
| 40 | − try: | |
| 41 | − posts = self.get(API_URL).json() | |
| 42 | − except Exception: | |
| 43 | − return listings | |
| 84 | + # échec franc sur challenge/panne (fini le « 0 trouvé ok » silencieux) | |
| 85 | + posts = json.loads(self._get_html(API_URL)) | |
| 44 | 86 | if not isinstance(posts, list): |
| 45 | − return listings | |
| 87 | + raise RuntimeError("réponse wp-json inattendue (pas une liste)") | |
| 46 | 88 | |
| 47 | 89 | for post in posts: |
| 48 | 90 | try: |
@@ -93,7 +135,9 @@ class GestionMJConnector(BaseConnector): | ||
| 93 | 135 | |
| 94 | 136 | def _fetch_detail(self, url: str) -> dict: |
| 95 | 137 | out: dict = {} |
| 96 | − html = self.get(url).text | |
| 138 | + # _get_html lève sur challenge : pas de {} vide dans detail_cache | |
| 139 | + # (clé figée jamais invalidée) | |
| 140 | + html = self._get_html(url) | |
| 97 | 141 | soup = BeautifulSoup(html, "html.parser") |
| 98 | 142 | |
| 99 | 143 | el = soup.select_one("h2.price") |
| 100 | 144 | |