[ka2] fix connecteur mercini: 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), lbm (5decd97) et gestion_mj (15d2bbb), hébergeur SiteGround identique (10e connecteur touché). Le sync du 2026-09-12 22:40 a rapporté found=0 ok (médiane 20.0, stale) : SiteGround sert son anti-bot sgcaptcha (HTTP 202, 181 octets, en-tête sg-captcha: challenge, meta refresh vers /.well-known/sgcaptcha/) à l'IP datacenter du nœud (76.70.60.50 flaguée « ipr ») — un 2xx sans HTTPError, avalé par le `except Exception: break` de fetch() en « 0 trouvé ok », invisible du disjoncteur. Vérifié live : IP directe → 202 challenge ; IP résidentielle Oxylabs CA → 200, 244 Ko, 24 blocs property_listing page 1. Fix minimal, pattern gimcote (liste paginée + detail_cache identiques) : _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 (liste paginée + fiches détail partagent l'IP), erreur franche si le challenge persiste — la 1re page de liste lève désormais au lieu de break (fini le zéro silencieux, ingest journalise ok=0), les pages suivantes gardent le break (résultats partiels acceptables) ; _fetch_detail() passe aussi par _get_html() pour ne pas empoisonner detail_cache avec un payload vide (clé figée jamais invalidée) ; les cartes gardent le continue (résultats partiels acceptables). Le chemin direct reste tenté d'abord (gratuit si SiteGround déflague l'IP). Testé : fetch() venv → 40 annonces (4½ Duberger 1 895 $, projet neuf Limoilou 1 595 $, 5½ Nicolet 1 550 $ — adresses complètes, 7-21 photos, médiane 20.0 largement couverte), pytest fixtures 2 passed, run.py sync mercini → found=40 ok (53 s), pm2 restart lou-ka-sync, site :8095 → 200.
1 changed file +52 −5
modified
louka/connectors/mercini.py
+52 −5
@@ -12,12 +12,19 @@ | ||
| 12 | 12 | # (n° de référence REF…, inclusions, animaux…), caractéristiques, galerie |
| 13 | 13 | # complète (#owl-demo). Les lat/lng du thème sont erronées (latitude négative |
| 14 | 14 | # identique partout) : ignorées, le géocodeur Lou-Ka prend le relais. |
| 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/…/gestion_mj, hébergeur identique). | |
| 15 | 19 | # ----------------------------------------------------------------------------- |
| 16 | 20 | from __future__ import annotations |
| 17 | 21 | |
| 18 | 22 | import hashlib |
| 23 | +import os | |
| 19 | 24 | import re |
| 25 | +import time | |
| 20 | 26 | import unicodedata |
| 27 | +from urllib.parse import quote | |
| 21 | 28 | |
| 22 | 29 | from bs4 import BeautifulSoup |
| 23 | 30 | |
@@ -27,6 +34,16 @@ from .base import BaseConnector | ||
| 27 | 34 | BASE = "https://groupemercini.com" |
| 28 | 35 | LIST_URL = f"{BASE}/appartements/" |
| 29 | 36 | |
| 37 | +# page interstitielle sgcaptcha SiteGround (meta refresh vers /.well-known/…) | |
| 38 | +_SG_MARKER = "/.well-known/sgcaptcha/" | |
| 39 | + | |
| 40 | + | |
| 41 | +def _sg_challenge(resp) -> bool: | |
| 42 | + """True si la réponse est le challenge anti-bot SiteGround (202 + JS).""" | |
| 43 | + if "challenge" in str(resp.headers.get("sg-captcha", "")).lower(): | |
| 44 | + return True | |
| 45 | + return _SG_MARKER in (resp.text or "")[:600] | |
| 46 | + | |
| 30 | 47 | # suffixe de redimensionnement WordPress (« -525x328.jpg » -> pleine taille) |
| 31 | 48 | _SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) |
| 32 | 49 | _BG_URL_RE = re.compile(r"background-image\s*:\s*url\(([^)]+)\)") |
@@ -48,14 +65,43 @@ class MerciniConnector(BaseConnector): | ||
| 48 | 65 | max_pages = 10 # garde-fou de pagination (3 pages actuellement) |
| 49 | 66 | max_details = 60 # garde-fou fiches détail (vraies requêtes par sync) |
| 50 | 67 | |
| 68 | + def _enable_residential_proxy(self) -> bool: | |
| 69 | + """Route toute la session via Oxylabs résidentiel CA (session collante).""" | |
| 70 | + endpoint = os.environ.get("OXYLABS_PROXY") | |
| 71 | + user = os.environ.get("OXYLABS_PROXY_USER") | |
| 72 | + pwd = os.environ.get("OXYLABS_PROXY_PASS") | |
| 73 | + if not (endpoint and user and pwd): | |
| 74 | + return False | |
| 75 | + puser = f"{user}-cc-CA-sessid-mercini{int(time.time())}-sesstime-10" | |
| 76 | + proxy = f"http://{quote(puser, safe='')}:{quote(pwd, safe='')}@{endpoint}" | |
| 77 | + self.session.proxies = {"http": proxy, "https": proxy} | |
| 78 | + self.session.verify = False # CA MITM du proxy Oxylabs (cf. _resilient) | |
| 79 | + return True | |
| 80 | + | |
| 81 | + def _get_html(self, url: str) -> str: | |
| 82 | + """GET avec contournement du challenge sgcaptcha (proxy résidentiel).""" | |
| 83 | + resp = self.get(url) | |
| 84 | + if not _sg_challenge(resp): | |
| 85 | + return resp.text | |
| 86 | + if not self.session.proxies and self._enable_residential_proxy(): | |
| 87 | + resp = self.get(url) | |
| 88 | + if not _sg_challenge(resp): | |
| 89 | + return resp.text | |
| 90 | + raise RuntimeError(f"challenge sgcaptcha non contourné — {url}") | |
| 91 | + | |
| 51 | 92 | def fetch(self) -> list[Listing]: |
| 52 | 93 | listings: dict[str, Listing] = {} |
| 53 | 94 | for page in range(1, self.max_pages + 1): |
| 54 | 95 | url = LIST_URL if page == 1 else f"{LIST_URL}page/{page}/" |
| 55 | − try: | |
| 56 | − html = self.get(url).text | |
| 57 | − except Exception: | |
| 58 | − break | |
| 96 | + if page == 1: | |
| 97 | + # la 1re page échoue franchement sur challenge (fini le | |
| 98 | + # « 0 trouvé ok » silencieux — ingest journalise ok=0) | |
| 99 | + html = self._get_html(url) | |
| 100 | + else: | |
| 101 | + try: | |
| 102 | + html = self._get_html(url) | |
| 103 | + except Exception: | |
| 104 | + break | |
| 59 | 105 | soup = BeautifulSoup(html, "html.parser") |
| 60 | 106 | cards = soup.select("div.property_listing") |
| 61 | 107 | if not cards: |
@@ -179,7 +225,8 @@ class MerciniConnector(BaseConnector): | ||
| 179 | 225 | if self._fetched >= self.max_details: |
| 180 | 226 | raise RuntimeError("budget de fiches détail atteint") |
| 181 | 227 | self._fetched += 1 |
| 182 | − html = self.get(url).text | |
| 228 | + # _get_html lève sur challenge : pas de {} vide dans detail_cache | |
| 229 | + html = self._get_html(url) | |
| 183 | 230 | soup = BeautifulSoup(html, "html.parser") |
| 184 | 231 | out: dict = {} |
| 185 | 232 | |
| 186 | 233 | |