From 9cbdc5dfbe0789a8a6bcf7cc9793dab3d98ae5f4 Mon Sep 17 00:00:00 2001 From: lekss361 Date: Mon, 31 Aug 2026 14:54:00 +0000 Subject: [PATCH] =?UTF-8?q?fix(tradein/browser):=20=D1=81=D0=BD=D1=8F?= =?UTF-8?q?=D1=82=D1=8B=D0=B9=20PoW-=D1=87=D0=B5=D0=BB=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B4=D0=B6=20=D0=BE=D1=82=D0=B4=D0=B0=D0=B2=D0=B0=D0=BB=D1=81?= =?UTF-8?q?=D1=8F=20=D1=81=20=D0=BF=D1=80=D0=BE=D1=82=D1=83=D1=85=D1=88?= =?UTF-8?q?=D0=B8=D0=BC=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=D0=BE=D0=BC?= =?UTF-8?q?,=20=D0=B8=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=B2=D1=8B=D0=B1=D1=80=D0=B0=D1=81=D1=8B=D0=B2=D0=B0=D0=BB?= =?UTF-8?q?=D0=B0=D1=81=D1=8C=20(#3283)=20(#3306)=20Co-authored-by:=20leks?= =?UTF-8?q?s361=20=20Co-committed-by:=20lekss361?= =?UTF-8?q?=20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/browser/server.py | 22 +++++++++++ .../browser/test_server_http_status.py | 37 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index 8b03f042..29e1f251 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -1783,6 +1783,10 @@ async def _wait_out_pow_challenge( После снятия челленджа даём странице догидрироваться тем же BROWSER_WAIT_MS, каким ждём обычную навигацию (второй таймаут не изобретаем). + + ПОБОЧНЫЙ ЭФФЕКТ: на успешном выходе сбрасывает `_last_response_status[provider]` + в None — статус challenge-ответа к этому моменту протух и не описывает + устоявшуюся страницу (обоснование — в комментарии у самой строки, #3283). """ poll_interval_ms = 1000 elapsed_ms = 0 @@ -1851,6 +1855,24 @@ async def _wait_out_pow_challenge( f"tradein-browser[{provider}]: челлендж снят, но страница не устоялась " f"(навигация не прекращается) url={url!r}" ) + # #3283: _last_response_status[provider] на этот момент всё ещё хранит статус + # ОТВЕТА НА CHALLENGE-СТРАНИЦУ (goto/подзапрос ДО вызова этой функции, напр. + # 439/401) — сама PoW-страница чинит себя self-reload'ом (`window.location = + # location.href`), Response на который мы не наблюдаем (опрашиваем content(), + # не подписаны на page.on("response")), а наш собственный page.reload() в цикле + # выше (после BROWSER_CHALLENGE_RELOAD_AFTER_MS) — лишь один из нескольких + # возможных источников устоявшегося content(), тоже не гарантированно последний. + # Замер прода (прогон 5606): рукопожатие снималось за 1-9с — БЫСТРЕЕ, чем успевал + # сработать наш reload (порог 8с) — то есть в типичном случае достоверного + # ответа на устоявшийся контент у нас нет вовсе. Отдать наверх протухший статус + # challenge-страницы как статус УСТОЯВШЕГОСЯ content() — воскресить #3283: + # avito/detail.py трактует 403/439 как блок площадки и выбрасывает уже + # вытащенную карточку. None — «статус неизвестен», клиент это НЕ считает блоком + # (см. providers/avito/detail.py: `if status is not None: ...`) и падает на + # html-эвристики, которые на настоящем контенте не сработают. Действует и на + # DomClick-ветку (is_pending=... выше) — там статус на успешном пути и раньше + # не проверялся при классификации блока, только логировался как диагностика. + _last_response_status[provider] = None return settled diff --git a/tradein-mvp/browser/test_server_http_status.py b/tradein-mvp/browser/test_server_http_status.py index 958331ee..c13d0e12 100644 --- a/tradein-mvp/browser/test_server_http_status.py +++ b/tradein-mvp/browser/test_server_http_status.py @@ -241,6 +241,43 @@ def test_fetch_once_still_waits_out_challenge_on_200(monkeypatch: pytest.MonkeyP assert page.wait_for_timeout_calls # ожидание челленджа реально шло +def test_fetch_once_clears_stale_challenge_status_once_settled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """#3283: снятый челлендж не должен отдать наверх статус challenge-страницы. + + Замер прода (прогон 5606): сайдкар пишет `_last_response_status` ОДИН раз, из + ответа на challenge-страницу (тут — 439), и никогда его не обновляет после + self-reload'а, которым PoW чинит себя сам. Клиент (avito/detail.py) трактует + 403/439 как блок площадки НЕ глядя в HTML и выбрасывает уже вытащенную карточку. + После этой правки статус обязан стать None («неизвестен») — клиент такой статус + блоком не считает. + """ + page = _Page([_CHALLENGE_HTML, _REAL_HTML], [_Response(439)]) + _install(monkeypatch, page) + + html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x")) + + assert html == _REAL_HTML + assert server._last_response_status["avito"] is None + + +def test_fetch_once_challenge_timeout_keeps_status_as_is(monkeypatch: pytest.MonkeyPatch) -> None: + """Челлендж НЕ снялся → ChallengeTimeoutError, статус ответа НЕ обнуляем. + + Отказ остаётся отказом (требование задачи #3283): return settled — единственное + место, где мы решаем, что status протух; на ветке таймаута до него не доходит. + """ + monkeypatch.setattr(server, "BROWSER_CHALLENGE_WAIT_MS", 1000) + page = _Page([_CHALLENGE_HTML], [_Response(439)]) # челлендж висит вечно + _install(monkeypatch, page) + + with pytest.raises(server.ChallengeTimeoutError): + asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x")) + + assert server._last_response_status["avito"] == 439 + + def test_fetch_once_ban_marker_still_raises_on_200(monkeypatch: pytest.MonkeyPatch) -> None: """Ветка _is_ban_page осталась: «проблема с IP» с кодом 200 — по-прежнему бан.""" page = _Page(["Доступ ограничен: проблема с IP"], [_Response(200)])