SPB Git forge

spb/lou-ka

Public

Lou·Ka — tous les logements à louer du Québec, un seul endroit.

232commits 1branches 0releases
172.9 MBsize
maindefault branch
2 days agolast push
HTML 98.9% Python 0.6%

[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.

Simon-Pierre Boucher committed 12 days ago (Sep 13, 2026) parent 15d2bbb

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