[ka2] fix connecteur boreal_abitibi: contournement du challenge sgcaptcha SiteGround via proxy résidentiel Oxylabs. Le sync du 2026-09-13 01:07 échouait honnêtement (« ajax_object introuvable sur /locations/ », ok=0) : SiteGround sert désormais son anti-bot sgcaptcha (HTTP 202 + meta refresh vers /.well-known/sgcaptcha/, en-tête sg-captcha: challenge) à l'IP datacenter du nœud — un 2xx qui ne lève pas d'HTTPError, donc invisible du wrapper résilient (même angle mort que brio/Sucuri 195f19a). Vérifié live : le challenge frappe tout UA depuis notre IP (76.70.60.50 flaguée « ipr/ipc »), mais une IP résidentielle Oxylabs CA passe (200, 52 Ko, ajax_object présent) et le POST admin-ajax get_rents fonctionne sur la même session collante (nonce af45b697e0 → 1 rent). Fix minimal dans le connecteur : _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 (nonce + POST admin-ajax + fiches détail partagent l'IP, pattern fb_marketplace/_resilient), erreur franche si le challenge persiste ; _fetch_detail() lève aussi sur challenge pour ne pas empoisonner detail_cache avec un payload vide (clé figée jamais invalidée). Le chemin direct reste tenté d'abord (gratuit si SiteGround déflague l'IP). Testé : fetch() venv → 1 annonce (App. 205 Esplanade Val-d'Or, 3½, 1 465 $, dispo octobre, 11 photos — médiane 1.0), pytest fixtures 2 passed, run.py sync boreal_abitibi → found=1 ok, pm2 restart lou-ka-sync, site :8095 → 200.
1 changed file +42 −2
modified
louka/connectors/boreal_abitibi.py
+42 −2
@@ -13,13 +13,19 @@ | ||
| 13 | 13 | # parking, inclusions, description et galerie ; la mention « logements |
| 14 | 14 | # non-fumeur / aucun animal » du site alimente `pets`. |
| 15 | 15 | # robots.txt WP standard (admin-ajax.php explicitement permis), sitemap XML. |
| 16 | +# 2026-09-13 : SiteGround sert son anti-bot sgcaptcha (202 + challenge JS, | |
| 17 | +# en-tête sg-captcha) aux IP datacenter — invisible du wrapper résilient | |
| 18 | +# (2xx) ; on bascule alors la session sur un proxy résidentiel Oxylabs CA | |
| 19 | +# en session collante (le nonce et les POST admin-ajax partagent l'IP). | |
| 16 | 20 | # ----------------------------------------------------------------------------- |
| 17 | 21 | from __future__ import annotations |
| 18 | 22 | |
| 19 | 23 | import hashlib |
| 20 | 24 | import json |
| 25 | +import os | |
| 21 | 26 | import re |
| 22 | 27 | import time |
| 28 | +from urllib.parse import quote | |
| 23 | 29 | |
| 24 | 30 | from bs4 import BeautifulSoup |
| 25 | 31 | |
@@ -35,6 +41,15 @@ _SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I) | ||
| 35 | 41 | _AJAX_OBJECT_RE = re.compile(r"var ajax_object\s*=\s*(\{.*?\})\s*;?", re.S) |
| 36 | 42 | # « 4 et demi » (fiche) -> « 4 1/2 » pour la normalisation commune |
| 37 | 43 | _ET_DEMI_RE = re.compile(r"^(\d)\s*et\s*demi$", re.I) |
| 44 | +# page interstitielle sgcaptcha SiteGround (meta refresh vers /.well-known/…) | |
| 45 | +_SG_MARKER = "/.well-known/sgcaptcha/" | |
| 46 | + | |
| 47 | + | |
| 48 | +def _sg_challenge(resp) -> bool: | |
| 49 | + """True si la réponse est le challenge anti-bot SiteGround (202 + JS).""" | |
| 50 | + if "challenge" in str(resp.headers.get("sg-captcha", "")).lower(): | |
| 51 | + return True | |
| 52 | + return _SG_MARKER in (resp.text or "")[:600] | |
| 38 | 53 | |
| 39 | 54 | |
| 40 | 55 | class BorealAbitibiConnector(BaseConnector): |
@@ -53,9 +68,33 @@ class BorealAbitibiConnector(BaseConnector): | ||
| 53 | 68 | resp.raise_for_status() |
| 54 | 69 | return resp |
| 55 | 70 | |
| 71 | + def _enable_residential_proxy(self) -> bool: | |
| 72 | + """Route toute la session via Oxylabs résidentiel CA (session collante).""" | |
| 73 | + endpoint = os.environ.get("OXYLABS_PROXY") | |
| 74 | + user = os.environ.get("OXYLABS_PROXY_USER") | |
| 75 | + pwd = os.environ.get("OXYLABS_PROXY_PASS") | |
| 76 | + if not (endpoint and user and pwd): | |
| 77 | + return False | |
| 78 | + puser = f"{user}-cc-CA-sessid-boreal{int(time.time())}-sesstime-10" | |
| 79 | + proxy = f"http://{quote(puser, safe='')}:{quote(pwd, safe='')}@{endpoint}" | |
| 80 | + self.session.proxies = {"http": proxy, "https": proxy} | |
| 81 | + self.session.verify = False # CA MITM du proxy Oxylabs (cf. _resilient) | |
| 82 | + return True | |
| 83 | + | |
| 84 | + def _get_html(self, url: str) -> str: | |
| 85 | + """GET avec contournement du challenge sgcaptcha (proxy résidentiel).""" | |
| 86 | + resp = self.get(url) | |
| 87 | + if not _sg_challenge(resp): | |
| 88 | + return resp.text | |
| 89 | + if not self.session.proxies and self._enable_residential_proxy(): | |
| 90 | + resp = self.get(url) | |
| 91 | + if not _sg_challenge(resp): | |
| 92 | + return resp.text | |
| 93 | + raise RuntimeError(f"challenge sgcaptcha non contourné — {url}") | |
| 94 | + | |
| 56 | 95 | def fetch(self) -> list[Listing]: |
| 57 | 96 | # 1) page /locations/ : récupérer le nonce AJAX (grille vide côté serveur) |
| 58 | − html = self.get(LIST_URL).text | |
| 97 | + html = self._get_html(LIST_URL) | |
| 59 | 98 | m = _AJAX_OBJECT_RE.search(html) |
| 60 | 99 | if not m: |
| 61 | 100 | raise RuntimeError("ajax_object introuvable sur /locations/") |
@@ -173,7 +212,8 @@ class BorealAbitibiConnector(BaseConnector): | ||
| 173 | 212 | if self._fetched >= self.max_details: |
| 174 | 213 | raise RuntimeError("budget de fiches détail atteint") |
| 175 | 214 | self._fetched += 1 |
| 176 | − html = self.get(url).text | |
| 215 | + # lever sur challenge : sinon detail() mettrait en cache un payload vide | |
| 216 | + html = self._get_html(url) | |
| 177 | 217 | soup = BeautifulSoup(html, "html.parser") |
| 178 | 218 | out: dict = {} |
| 179 | 219 | |
| 180 | 220 | |