fix(tradein/browser): снятый PoW-челлендж отдавался с протухшим статусом, и карточка выбрасывалась (#3283)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
CI / changes (pull_request) Successful in 11s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 1m27s

Сайдкар уже пережидает челлендж QRATOR и уже его снимает, но
_last_response_status[provider] остаётся статусом challenge-страницы: он
пишется один раз, из ответа первого goto, а _wait_out_pow_challenge его не
обновляет. Наружу уходило {"html": <нормальная карточка>, "status": 439}, и
клиент avito/detail.py браковал её по одному коду, не заглянув в HTML.

Прод, прогон 5606 — одни и те же объявления:
  13:15:23 сайдкар: PoW снят за ~9000мс, догидрация  ...8242031370
  13:15:28 скрапер: BLOCKED #1  HTTP 439              ...8242031370
  13:22:33 сайдкар: PoW снят за ~3000мс, догидрация  ...8272722582
  13:22:37 скрапер: BLOCKED #11 HTTP 439              ...8272722582
Минимум 3 блока из 7 в прогоне — выброшенные страницы, уже вытащенные.

Ловить «свежий» статус нечем: PoW-страница чинит себя своим
window.location-редиректом, Response на который мы не наблюдаем, а
собственный page.reload() включается только после 8с — при снятии за 1-9с он
обычно не срабатывает вовсе. Поэтому на успешном выходе статус сбрасывается в
None («неизвестен»), а не тащится протухшим. Клиент None блоком не считает
(detail.py: `if status is not None`) и падает на html-эвристики, которые на
настоящем контенте не срабатывают.

Правка в общей функции, поэтому чинит и ветку DomClick. Провал ожидания
(ChallengeTimeoutError) остаётся отказом: сброс стоит после всех raise.

Клиента править не потребовалось — test_3044_avito_439_classified_as_block
зелёный без изменений.

Тесты сайдкара: 219 passed.
This commit is contained in:
bot-backend 2026-08-31 17:46:45 +03:00
parent 9219ab6507
commit 67a136c95c
2 changed files with 59 additions and 0 deletions

View file

@ -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

View file

@ -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(["<html>Доступ ограничен: проблема с IP</html>"], [_Response(200)])