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