[ka2] fix connecteur deschenes_pepin: contournement du challenge sgcaptcha SiteGround via proxy résidentiel Oxylabs — même cause et même fix que boreal_abitibi (8923045), citiluxx (d17f5fe) et cromwell (8351131), hébergeur SiteGround identique. Le sync du 2026-09-13 01:56 a rapporté found=0 ok=1 missed=5 (médiane 5.0, stale) : SiteGround sert son anti-bot sgcaptcha (HTTP 202, 174 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, 234 Ko, les 2 liens /projet/ (Le Blüm Longueuil + Sainte-Julie) présents. Fix minimal, pattern cromwell : _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 projet partagent l'IP), erreur franche si le challenge persiste — la page de liste lève désormais au lieu de retourner [] (fini le zéro silencieux, ingest journalise ok=0), les fiches projet gardent le continue (résultats partiels acceptables) ; _fetch_project() passe aussi par _get_html(). Le chemin direct reste tenté d'abord (gratuit si SiteGround déflague l'IP). Testé : fetch() venv → 5 annonces (Le Blüm Sainte-Julie 3½/4½, Le Blüm Longueuil 3½/4½/5½, photos 12-20, adresses complètes — médiane 5.0 restaurée), run.py sync deschenes_pepin → found=5 ok, pm2 restart lou-ka-sync, site :8095 → 200.
1 changed file +45 −5
modified
louka/connectors/deschenes_pepin.py
+45 −5
@@ -9,10 +9,17 @@ | ||
| 9 | 9 | # 5 ½ »), l'adresse du bureau d'information, la description et la galerie. |
| 10 | 10 | # Granularité : une annonce par projet × typologie (pas d'unités ni de prix |
| 11 | 11 | # publiés sur le site). |
| 12 | +# 2026-09-13 : SiteGround sert son anti-bot sgcaptcha (202 + challenge JS, | |
| 13 | +# en-tête sg-captcha) aux IP datacenter — invisible du wrapper résilient | |
| 14 | +# (2xx) ; on bascule alors la session sur un proxy résidentiel Oxylabs CA | |
| 15 | +# (même pattern que boreal_abitibi/citiluxx/cromwell, hébergeur identique). | |
| 12 | 16 | # ----------------------------------------------------------------------------- |
| 13 | 17 | from __future__ import annotations |
| 14 | 18 | |
| 19 | +import os | |
| 15 | 20 | import re |
| 21 | +import time | |
| 22 | +from urllib.parse import quote | |
| 16 | 23 | |
| 17 | 24 | from bs4 import BeautifulSoup |
| 18 | 25 | |
@@ -35,6 +42,16 @@ ADDR_RE = re.compile( | ||
| 35 | 42 | r"mont[ée]e|place)\s+[^,<>]{2,60},?\s*(?:Longueuil|Sainte-Julie|Ste-Julie)" |
| 36 | 43 | r"[^<>]{0,40}?(?:[A-Z]\d[A-Z]\s?\d[A-Z]\d)?", re.I) |
| 37 | 44 | |
| 45 | +# page interstitielle sgcaptcha SiteGround (meta refresh vers /.well-known/…) | |
| 46 | +_SG_MARKER = "/.well-known/sgcaptcha/" | |
| 47 | + | |
| 48 | + | |
| 49 | +def _sg_challenge(resp) -> bool: | |
| 50 | + """True si la réponse est le challenge anti-bot SiteGround (202 + JS).""" | |
| 51 | + if "challenge" in str(resp.headers.get("sg-captcha", "")).lower(): | |
| 52 | + return True | |
| 53 | + return _SG_MARKER in (resp.text or "")[:600] | |
| 54 | + | |
| 38 | 55 | |
| 39 | 56 | def _city_of(slug: str, text: str) -> str: |
| 40 | 57 | key = f"{slug} {text}".lower() |
@@ -48,12 +65,35 @@ class DeschenesPepinConnector(BaseConnector): | ||
| 48 | 65 | request_delay = 0.8 |
| 49 | 66 | max_projects = 12 # garde-fou de crawl |
| 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-deschenes{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: list[Listing] = [] |
| 53 | − try: | |
| 54 | − html = self.get(LIST_URL).text | |
| 55 | − except Exception: | |
| 56 | − return listings | |
| 94 | + # la page de liste échoue franchement (fini le « 0 trouvé ok » | |
| 95 | + # silencieux du challenge sgcaptcha — ingest journalise ok=0) | |
| 96 | + html = self._get_html(LIST_URL) | |
| 57 | 97 | |
| 58 | 98 | # Slugs des fiches projet référencées depuis la section « Louer » |
| 59 | 99 | slugs = list(dict.fromkeys(PROJECT_LINK_RE.findall(html))) |
@@ -66,7 +106,7 @@ class DeschenesPepinConnector(BaseConnector): | ||
| 66 | 106 | |
| 67 | 107 | def _fetch_project(self, slug: str) -> list[Listing]: |
| 68 | 108 | url = f"{BASE}/projet/{slug}/" |
| 69 | − html = self.get(url).text | |
| 109 | + html = self._get_html(url) | |
| 70 | 110 | soup = BeautifulSoup(html, "html.parser") |
| 71 | 111 | |
| 72 | 112 | h1 = soup.find("h1") |
| 73 | 113 | |