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
3 days agolast push
HTML 98.9% Python 0.6%

[ka2] fix connecteur gimcote: 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) et gestion_habitation (8b0660b), hébergeur SiteGround identique (6e connecteur touché). Le sync du 2026-09-12 21:51 a rapporté found=0 ok (médiane 79.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: break` de fetch() en « 0 trouvé ok », invisible du disjoncteur. Distinct de la panne du 2026-09-06 (vhost suspendu, 503 x-suspended-vhost) : celle-là est résolue, le connecteur était sain. Vérifié live : IP directe → 202 challenge ; IP résidentielle Oxylabs CA → 200, 148 Ko, 20 cartes item-listing-wrap page 1. Fix minimal, pattern gestion_habitation : _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). Le chemin direct reste tenté d'abord (gratuit si SiteGround déflague l'IP). Nota : une IP résidentielle collante peut elle-même être flaguée (1 run sur 2 en test) — l'échec est alors franc et le sync suivant retire une nouvelle IP. Testé : fetch() venv → 79 annonces (médiane 79.0 pile, prix 855-1395 $, photos 8-14, Québec/Lévis), pytest fixtures 2 passed, run.py sync gimcote → found=79 ok, pm2 restart lou-ka-sync, site :8095 → 200.

Simon-Pierre Boucher committed 13 days ago (Sep 13, 2026) parent 8b0660b

1 changed file +54 −5

modified louka/connectors/gimcote.py +54 −5
@@ -7,13 +7,21 @@
7 7 # complète d'images (attribut data-images). Les fiches détail (via cache BD)
8 8 # ajoutent description, caractéristiques, bloc « Détails » structuré
9 9 # (animaux, meublé, fumeur, stationnement) et coordonnées GPS.
10 +# 2026-09-13 : SiteGround sert son anti-bot sgcaptcha (202 + challenge JS,
11 +# en-tête sg-captcha) aux IP datacenter — invisible du wrapper résilient
12 +# (2xx) ; on bascule alors la session sur un proxy résidentiel Oxylabs CA
13 +# (même pattern que boreal_abitibi/citiluxx/cromwell/deschenes_pepin/
14 +# gestion_habitation, hébergeur identique).
10 15 # -----------------------------------------------------------------------------
11 16 from __future__ import annotations
12 17
13 18 import hashlib
14 19 import html as htmllib
15 20 import json
21 +import os
16 22 import re
23 +import time
24 +from urllib.parse import quote
17 25
18 26 from bs4 import BeautifulSoup
19 27
@@ -24,6 +32,16 @@ from .base import BaseConnector
24 32 BASE = "https://gimcote.com"
25 33 LIST_URL = f"{BASE}/property-type/appartement/"
26 34
35 +# page interstitielle sgcaptcha SiteGround (meta refresh vers /.well-known/…)
36 +_SG_MARKER = "/.well-known/sgcaptcha/"
37 +
38 +
39 +def _sg_challenge(resp) -> bool:
40 + """True si la réponse est le challenge anti-bot SiteGround (202 + JS)."""
41 + if "challenge" in str(resp.headers.get("sg-captcha", "")).lower():
42 + return True
43 + return _SG_MARKER in (resp.text or "")[:600]
44 +
27 45 _SIZE_SUFFIX = re.compile(r"-\d{2,4}x\d{2,4}(?=\.(?:jpg|jpeg|png|webp)$)", re.I)
28 46
29 47
@@ -66,14 +84,43 @@ class GimCoteConnector(BaseConnector):
66 84 max_pages = 20 # garde-fou de pagination
67 85 max_details = 150 # garde-fou fiches détail (vraies requêtes)
68 86
87 + def _enable_residential_proxy(self) -> bool:
88 + """Route toute la session via Oxylabs résidentiel CA (session collante)."""
89 + endpoint = os.environ.get("OXYLABS_PROXY")
90 + user = os.environ.get("OXYLABS_PROXY_USER")
91 + pwd = os.environ.get("OXYLABS_PROXY_PASS")
92 + if not (endpoint and user and pwd):
93 + return False
94 + puser = f"{user}-cc-CA-sessid-gimcote{int(time.time())}-sesstime-10"
95 + proxy = f"http://{quote(puser, safe='')}:{quote(pwd, safe='')}@{endpoint}"
96 + self.session.proxies = {"http": proxy, "https": proxy}
97 + self.session.verify = False # CA MITM du proxy Oxylabs (cf. _resilient)
98 + return True
99 +
100 + def _get_html(self, url: str) -> str:
101 + """GET avec contournement du challenge sgcaptcha (proxy résidentiel)."""
102 + resp = self.get(url)
103 + if not _sg_challenge(resp):
104 + return resp.text
105 + if not self.session.proxies and self._enable_residential_proxy():
106 + resp = self.get(url)
107 + if not _sg_challenge(resp):
108 + return resp.text
109 + raise RuntimeError(f"challenge sgcaptcha non contourné — {url}")
110 +
69 111 def fetch(self) -> list[Listing]:
70 112 listings: dict[str, Listing] = {}
71 113 for page in range(1, self.max_pages + 1):
72 114 url = LIST_URL if page == 1 else f"{LIST_URL}page/{page}/"
73 − try:
74 − html = self.get(url).text
75 − except Exception:
76 − break
115 + if page == 1:
116 + # la 1re page échoue franchement sur challenge (fini le
117 + # « 0 trouvé ok » silencieux — ingest journalise ok=0)
118 + html = self._get_html(url)
119 + else:
120 + try:
121 + html = self._get_html(url)
122 + except Exception:
123 + break
77 124 soup = BeautifulSoup(html, "html.parser")
78 125 cards = soup.select("div.item-listing-wrap")
79 126 if not cards:
@@ -203,7 +250,9 @@ class GimCoteConnector(BaseConnector):
203 250 if self._fetched >= self.max_details:
204 251 raise RuntimeError("budget de fiches détail atteint")
205 252 self._fetched += 1
206 − html = self.get(url).text
253 + # _get_html lève sur challenge : pas de {} vide dans detail_cache
254 + # (clé figée jamais invalidée)
255 + html = self._get_html(url)
207 256 soup = BeautifulSoup(html, "html.parser")
208 257 out: dict = {}
209 258
210 259