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 boreal_abitibi: rotation d'IP résidentielle collante + détection du challenge sgcaptcha sur le POST admin-ajax — durcissement du fix 8923045 contre la flakiness des IP Oxylabs flaguées (~1 run sur 2, nota gimcote). Le sync du 2026-09-14 03:49 a échoué franc (« challenge sgcaptcha non contourné » — IP collante flaguée, _get_html ne tentait qu'UNE IP à cause du guard `if not self.session.proxies`) et la reproduction live a exposé un 2e trou : le challenge peut frapper le POST admin-ajax get_rents seul (réponse HTML 202 avalée par raise_for_status → .json() lève JSONDecodeError opaque au lieu d'une erreur franche). Vérifié live : direct → 202 sg-captcha: challenge ; proxy collant frais → GET 200 + POST JSON rents ok. Fix minimal : _get_html tire jusqu'à 2 nouvelles sessions collantes (sessid en time_ns, fini les collisions) avant l'erreur franche ; _get_rents détecte le challenge (en-tête sg-captcha ou non-JSON) et fetch() relance la page UNE fois avec nouvelle IP + nouveau nonce ; erreur franche si le challenge persiste (pas de zéro silencieux). Le chemin direct reste tenté d'abord (gratuit si SiteGround déflague l'IP du nœud). Testé : fetch() venv → 1 annonce (1301 rue de l'Esplanade App. 205, Val-d'Or, 3½ 1 465 $/mois, dispo octobre, 11 photos — médiane 1.0 restaurée), pytest fixtures 2 passed, run.py sync boreal_abitibi → found=1 ok (3,1 s), pm2 restart lou-ka-sync, site :8095 → 200.

Simon-Pierre Boucher committed 11 days ago (Sep 14, 2026) parent edd968e

1 changed file +43 −13

modified louka/connectors/boreal_abitibi.py +43 −13
@@ -17,6 +17,10 @@
17 17 # en-tête sg-captcha) aux IP datacenter — invisible du wrapper résilient
18 18 # (2xx) ; on bascule alors la session sur un proxy résidentiel Oxylabs CA
19 19 # en session collante (le nonce et les POST admin-ajax partagent l'IP).
20 +# 2026-09-14 : le challenge frappe aussi les IP résidentielles (~1 run sur 2)
21 +# et parfois le POST admin-ajax seul (réponse HTML 202 → .json() lève) ;
22 +# on tourne donc l'IP collante (2 essais) et on relance le POST une fois
23 +# avec nouvelle IP + nouveau nonce avant l'erreur franche.
20 24 # -----------------------------------------------------------------------------
21 25 from __future__ import annotations
22 26
@@ -75,42 +79,68 @@ class BorealAbitibiConnector(BaseConnector):
75 79 pwd = os.environ.get("OXYLABS_PROXY_PASS")
76 80 if not (endpoint and user and pwd):
77 81 return False
78 − puser = f"{user}-cc-CA-sessid-boreal{int(time.time())}-sesstime-10"
82 + puser = f"{user}-cc-CA-sessid-boreal{time.time_ns()}-sesstime-10"
79 83 proxy = f"http://{quote(puser, safe='')}:{quote(pwd, safe='')}@{endpoint}"
80 84 self.session.proxies = {"http": proxy, "https": proxy}
81 85 self.session.verify = False # CA MITM du proxy Oxylabs (cf. _resilient)
82 86 return True
83 87
84 88 def _get_html(self, url: str) -> str:
85 − """GET avec contournement du challenge sgcaptcha (proxy résidentiel)."""
89 + """GET avec contournement du challenge sgcaptcha (proxy résidentiel).
90 +
91 + Une IP résidentielle collante peut elle-même être flaguée : on tire
92 + jusqu'à 2 nouvelles sessions collantes avant l'erreur franche.
93 + """
86 94 resp = self.get(url)
87 95 if not _sg_challenge(resp):
88 96 return resp.text
89 − if not self.session.proxies and self._enable_residential_proxy():
97 + for _ in range(2):
98 + if not self._enable_residential_proxy():
99 + break
90 100 resp = self.get(url)
91 101 if not _sg_challenge(resp):
92 102 return resp.text
93 103 raise RuntimeError(f"challenge sgcaptcha non contourné — {url}")
94 104
95 − def fetch(self) -> list[Listing]:
96 − # 1) page /locations/ : récupérer le nonce AJAX (grille vide côté serveur)
105 + def _nonce(self) -> str:
106 + """Nonce AJAX lu dans le HTML de /locations/ (grille vide côté serveur)."""
97 107 html = self._get_html(LIST_URL)
98 108 m = _AJAX_OBJECT_RE.search(html)
99 109 if not m:
100 110 raise RuntimeError("ajax_object introuvable sur /locations/")
101 − cfg = json.loads(m.group(1))
102 − nonce = cfg.get("nonce", "")
111 + return json.loads(m.group(1)).get("nonce", "")
103 112
104 − # 2) admin-ajax get_rents, paginé (l'inventaire complet, toutes villes)
113 + def _get_rents(self, nonce: str, page: int) -> dict:
114 + """Une page admin-ajax get_rents ; RuntimeError si challenge sgcaptcha."""
115 + resp = self._post(AJAX_URL, {
116 + "action": "get_rents", "nonce": nonce, "page": str(page),
117 + "posts_per_page": "12", "town": "", "show_all": "",
118 + })
119 + if _sg_challenge(resp):
120 + raise RuntimeError("challenge sgcaptcha sur admin-ajax get_rents")
121 + try:
122 + return resp.json()
123 + except ValueError as exc: # challenge servi sans en-tête sg-captcha
124 + raise RuntimeError(f"réponse non-JSON d'admin-ajax get_rents: {exc}")
125 +
126 + def fetch(self) -> list[Listing]:
127 + # 1) nonce AJAX, puis 2) admin-ajax get_rents paginé (toutes villes)
128 + nonce = self._nonce()
105 129 self._fetched = 0
106 130 listings: dict[str, Listing] = {}
107 131 page = 1
132 + retried = False
108 133 while page <= self.max_pages:
109 − data = {
110 − "action": "get_rents", "nonce": nonce, "page": str(page),
111 − "posts_per_page": "12", "town": "", "show_all": "",
112 − }
113 − payload = self._post(AJAX_URL, data).json()
134 + try:
135 + payload = self._get_rents(nonce, page)
136 + except RuntimeError:
137 + if retried:
138 + raise
139 + retried = True
140 + # IP flaguée en cours de route : nouvelle IP collante + nonce
141 + self._enable_residential_proxy()
142 + nonce = self._nonce()
143 + continue
114 144 for card_html in payload.get("rents") or []:
115 145 try:
116 146 self._parse_card(BeautifulSoup(card_html, "html.parser"),
117 147