[ka2] fix connecteur lbm: 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) et appartements_rimouski (af2be7c), hébergeur SiteGround identique (8e connecteur touché). Le sync du 2026-09-12 22:19 a rapporté found=0 ok (médiane 8.0, 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, 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, 161 Ko, les 3 pages projet (le-2922, le-3215, le-3537) présentes. 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 (accueil + pages projet partagent l'IP), erreur franche si le challenge persiste — la page d'accueil (liste des projets, pas de pagination) lève désormais au lieu de retourner [] (fini le zéro silencieux, ingest journalise ok=0) ; les pages projet gardent le continue (résultats partiels acceptables). Pas de detail_cache dans ce connecteur. Le chemin direct reste tenté d'abord (gratuit si SiteGround déflague l'IP). Testé : fetch() venv → 8 annonces (Le 3537 modèles 1-4 : 3½ 1 370 $ → 5½ 1 895 $, 15 photos ; typologies 4½/5½ Le 3215 et Le 2922 — médiane 8.0 restaurée), run.py sync lbm → found=8 ok, pm2 restart lou-ka-sync, site :8095 → 200.
1 changed file +45 −5
modified
louka/connectors/lbm.py
+45 −5
@@ -10,16 +10,34 @@ | ||
| 10 | 10 | # « À partir de X$/m », superficie, chambres, inclusions), les autres |
| 11 | 11 | # seulement les typologies offertes (« 4 1/2 et 5 1/2 », 1100 pi²). |
| 12 | 12 | # Granularité MODÈLE/TYPOLOGIE par immeuble, prix seulement où publié. |
| 13 | +# 2026-09-13 : SiteGround sert son anti-bot sgcaptcha (202 + challenge JS, | |
| 14 | +# en-tête sg-captcha) aux IP datacenter — invisible du wrapper résilient | |
| 15 | +# (2xx) ; on bascule alors la session sur un proxy résidentiel Oxylabs CA | |
| 16 | +# (même pattern que boreal_abitibi/citiluxx/cromwell/deschenes_pepin/ | |
| 17 | +# gestion_habitation/gimcote/appartements_rimouski, hébergeur identique). | |
| 13 | 18 | # ----------------------------------------------------------------------------- |
| 14 | 19 | from __future__ import annotations |
| 15 | 20 | |
| 21 | +import os | |
| 16 | 22 | import re |
| 23 | +import time | |
| 24 | +from urllib.parse import quote | |
| 17 | 25 | |
| 18 | 26 | from ..schema import Listing, normalize_unit_type |
| 19 | 27 | from .base import BaseConnector |
| 20 | 28 | |
| 21 | 29 | BASE = "https://grouperivenord.ca" |
| 22 | 30 | |
| 31 | +# page interstitielle sgcaptcha SiteGround (meta refresh vers /.well-known/…) | |
| 32 | +_SG_MARKER = "/.well-known/sgcaptcha/" | |
| 33 | + | |
| 34 | + | |
| 35 | +def _sg_challenge(resp) -> bool: | |
| 36 | + """True si la réponse est le challenge anti-bot SiteGround (202 + JS).""" | |
| 37 | + if "challenge" in str(resp.headers.get("sg-captcha", "")).lower(): | |
| 38 | + return True | |
| 39 | + return _SG_MARKER in (resp.text or "")[:600] | |
| 40 | + | |
| 23 | 41 | PROJECT_RE = re.compile(r'href="(https://grouperivenord\.ca/(le-[\w-]+)/)"') |
| 24 | 42 | HERO_RE = re.compile(r"Bienvenue au\s*\|?\s*(\d{2,5}[^|]{0,40}?)\|", re.I) |
| 25 | 43 | CITY_RE = re.compile(r"\|\s*((?:Saint|Sainte)e?-[A-Za-zÀ-ü’'-]+(?:-[A-Za-zÀ-ü’'-]+)*)\s*\|") |
@@ -57,16 +75,38 @@ class LbmConnector(BaseConnector): | ||
| 57 | 75 | source_id = "lbm" |
| 58 | 76 | request_delay = 0.6 |
| 59 | 77 | |
| 78 | + def _enable_residential_proxy(self) -> bool: | |
| 79 | + """Route toute la session via Oxylabs résidentiel CA (session collante).""" | |
| 80 | + endpoint = os.environ.get("OXYLABS_PROXY") | |
| 81 | + user = os.environ.get("OXYLABS_PROXY_USER") | |
| 82 | + pwd = os.environ.get("OXYLABS_PROXY_PASS") | |
| 83 | + if not (endpoint and user and pwd): | |
| 84 | + return False | |
| 85 | + puser = f"{user}-cc-CA-sessid-lbm{int(time.time())}-sesstime-10" | |
| 86 | + proxy = f"http://{quote(puser, safe='')}:{quote(pwd, safe='')}@{endpoint}" | |
| 87 | + self.session.proxies = {"http": proxy, "https": proxy} | |
| 88 | + self.session.verify = False # CA MITM du proxy Oxylabs | |
| 89 | + return True | |
| 90 | + | |
| 91 | + def _get_html(self, url: str) -> str: | |
| 92 | + """GET avec contournement du challenge sgcaptcha (proxy résidentiel).""" | |
| 93 | + resp = self.get(url) | |
| 94 | + if not _sg_challenge(resp): | |
| 95 | + return resp.text | |
| 96 | + if not self.session.proxies and self._enable_residential_proxy(): | |
| 97 | + resp = self.get(url) | |
| 98 | + if not _sg_challenge(resp): | |
| 99 | + return resp.text | |
| 100 | + raise RuntimeError(f"challenge sgcaptcha non contourné — {url}") | |
| 101 | + | |
| 60 | 102 | def fetch(self) -> list[Listing]: |
| 61 | 103 | listings: list[Listing] = [] |
| 62 | − try: | |
| 63 | − home = self.get(BASE + "/").text | |
| 64 | − except Exception: | |
| 65 | − return listings | |
| 104 | + # échec franc sur challenge/panne (fini le « 0 trouvé ok » silencieux) | |
| 105 | + home = self._get_html(BASE + "/") | |
| 66 | 106 | pages = list(dict.fromkeys(PROJECT_RE.findall(home))) |
| 67 | 107 | for page_url, slug in pages: |
| 68 | 108 | try: |
| 69 | − html = self.get(page_url).text | |
| 109 | + html = self._get_html(page_url) | |
| 70 | 110 | except Exception: |
| 71 | 111 | continue |
| 72 | 112 | flat = _flat(html) |
| 73 | 113 | |