[ka2] fix connecteur appartements_rimouski: 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) et gimcote (12df764), hébergeur SiteGround identique (7e connecteur touché). Les syncs du 2026-09-13 02:13→02:40 ont rapporté found=0 ok x5 (médiane 16-17, disjoncteur de dérive déclenché puis médiane érodée à 0) : SiteGround sert son anti-bot sgcaptcha (HTTP 202, 186 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, la soupe sans lien /logements/ devenait « 0 trouvé ok », invisible du disjoncteur. Vérifié live : IP directe → 202 challenge ; IP résidentielle Oxylabs CA → 200, 229 Ko, les 16 cartes /logements/ présentes. Fix minimal, pattern gimcote : _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 + fiches détail partagent l'IP), erreur franche si le challenge persiste — la page de liste (unique, pas de pagination) lève désormais au lieu de retourner 0 carte (fini le zéro silencieux, ingest journalise ok=0) ; _fetch_detail() passe aussi par _get_html() pour ne pas empoisonner detail_cache avec un payload vide (clé figée jamais invalidée) ; les fiches gardent le except autour de detail() (résultats partiels acceptables). Le chemin direct reste tenté d'abord (gratuit si SiteGround déflague l'IP). Testé : fetch() venv → 16 annonces (studio 675 $ → 6½ 2 300 $, photos 4-13, Rimouski/Le Bic — médiane 16 restaurée), pytest fixtures 2 passed, run.py sync appartements_rimouski → found=16 ok, pm2 restart lou-ka-sync, site :8095 → 200.
1 changed file +47 −2
modified
louka/connectors/appartements_rimouski.py
+47 −2
@@ -12,13 +12,21 @@ | ||
| 12 | 12 | # BD) : description complète, quartier (« Quartier : Rimouski-Est »), |
| 13 | 13 | # galerie et GPS via le JSON-LD schema.org/House (lat/lng inversés à la |
| 14 | 14 | # source : corrigés). robots.txt : Disallow /pro/* seulement, sitemap XML. |
| 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, hébergeur identique). | |
| 15 | 20 | # ----------------------------------------------------------------------------- |
| 16 | 21 | from __future__ import annotations |
| 17 | 22 | |
| 18 | 23 | import hashlib |
| 19 | 24 | import html as htmllib |
| 20 | 25 | import json |
| 26 | +import os | |
| 21 | 27 | import re |
| 28 | +import time | |
| 29 | +from urllib.parse import quote | |
| 22 | 30 | |
| 23 | 31 | from bs4 import BeautifulSoup |
| 24 | 32 | |
@@ -28,6 +36,16 @@ from .base import BaseConnector | ||
| 28 | 36 | BASE = "https://www.appartementsrimouski.com" |
| 29 | 37 | LIST_URL = f"{BASE}/choisir-mon-logement" |
| 30 | 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 | + | |
| 31 | 49 | _PRICE_RE = re.compile(r"\d[\d\s,.]*\$\s*/\s*mois", re.I) |
| 32 | 50 | # type explicite dans le titre (« 3 1/2 », « Studio », « Chambre », « Loft ») |
| 33 | 51 | _HALF_RE = re.compile(r"(\d+)\s*(?:½|1/2)") |
@@ -62,8 +80,33 @@ class AppartementsRimouskiConnector(BaseConnector): | ||
| 62 | 80 | request_delay = 0.6 |
| 63 | 81 | max_details = 30 # garde-fou fiches détail (vraies requêtes par sync) |
| 64 | 82 | |
| 83 | + def _enable_residential_proxy(self) -> bool: | |
| 84 | + """Route toute la session via Oxylabs résidentiel CA (session collante).""" | |
| 85 | + endpoint = os.environ.get("OXYLABS_PROXY") | |
| 86 | + user = os.environ.get("OXYLABS_PROXY_USER") | |
| 87 | + pwd = os.environ.get("OXYLABS_PROXY_PASS") | |
| 88 | + if not (endpoint and user and pwd): | |
| 89 | + return False | |
| 90 | + puser = f"{user}-cc-CA-sessid-rimouski{int(time.time())}-sesstime-10" | |
| 91 | + proxy = f"http://{quote(puser, safe='')}:{quote(pwd, safe='')}@{endpoint}" | |
| 92 | + self.session.proxies = {"http": proxy, "https": proxy} | |
| 93 | + self.session.verify = False # CA MITM du proxy Oxylabs | |
| 94 | + return True | |
| 95 | + | |
| 96 | + def _get_html(self, url: str) -> str: | |
| 97 | + """GET avec contournement du challenge sgcaptcha (proxy résidentiel).""" | |
| 98 | + resp = self.get(url) | |
| 99 | + if not _sg_challenge(resp): | |
| 100 | + return resp.text | |
| 101 | + if not self.session.proxies and self._enable_residential_proxy(): | |
| 102 | + resp = self.get(url) | |
| 103 | + if not _sg_challenge(resp): | |
| 104 | + return resp.text | |
| 105 | + raise RuntimeError(f"challenge sgcaptcha non contourné — {url}") | |
| 106 | + | |
| 65 | 107 | def fetch(self) -> list[Listing]: |
| 66 | − html = self.get(LIST_URL).text | |
| 108 | + # échec franc sur challenge (fini le « 0 trouvé ok » silencieux) | |
| 109 | + html = self._get_html(LIST_URL) | |
| 67 | 110 | soup = BeautifulSoup(html, "html.parser") |
| 68 | 111 | |
| 69 | 112 | self._fetched = 0 |
@@ -159,7 +202,9 @@ class AppartementsRimouskiConnector(BaseConnector): | ||
| 159 | 202 | if self._fetched >= self.max_details: |
| 160 | 203 | raise RuntimeError("budget de fiches détail atteint") |
| 161 | 204 | self._fetched += 1 |
| 162 | − html = self.get(url).text | |
| 205 | + # _get_html lève sur challenge : pas de {} vide dans detail_cache | |
| 206 | + # (clé figée jamais invalidée) | |
| 207 | + html = self._get_html(url) | |
| 163 | 208 | soup = BeautifulSoup(html, "html.parser") |
| 164 | 209 | out: dict = {} |
| 165 | 210 | |
| 166 | 211 | |