fix(tradein/browser): дожидаться QRATOR PoW-челленджа Авито вместо тихой заглушки #3046

Merged
lekss361 merged 2 commits from fix/3045-browser-pow-challenge-wait into main 2026-08-21 17:01:25 +00:00
2 changed files with 105 additions and 8 deletions
Showing only changes of commit 5c0fd78c1f - Show all commits

View file

@ -971,6 +971,37 @@ def _is_ban_page(html: str) -> bool:
return any(marker in lower for marker in _BAN_MARKERS) return any(marker in lower for marker in _BAN_MARKERS)
# Маркеры исключения playwright «страница прямо сейчас перезагружается». Ловим по
# тексту, а не по типу: сервис не импортирует playwright напрямую (page приходит
# уже готовым), а Error/TimeoutError у него не образуют отдельной иерархии для
# этого случая.
_NAVIGATION_RACE_MARKERS: tuple[str, ...] = (
"execution context was destroyed",
"most likely because of a navigation",
"page is navigating",
)
async def _content_during_navigation(page: object) -> str | None:
"""`page.content()`, устойчивый к перезагрузке страницы под руками.
PoW-челлендж перезагружает себя сам (`window.location = location.href`), и
вызов content(), попавший ровно в этот момент, кидает «Execution context was
destroyed». Для нас это НЕ ошибка, а признак того, что перезагрузка та
самая, которую мы ждём, идёт прямо сейчас. Возвращаем None = «ещё не
устоялось, опроси снова», а не роняем фетч на самом успешном исходе.
Всё остальное (закрытая страница, упавший браузер) пробрасываем как есть.
"""
try:
return await page.content() # type: ignore[attr-defined]
except Exception as exc: # noqa: BLE001 — тип не импортируем, различаем по тексту
text = str(exc).lower()
if any(marker in text for marker in _NAVIGATION_RACE_MARKERS):
return None
raise
async def _wait_out_pow_challenge(page: object, provider: str, url: str) -> str: async def _wait_out_pow_challenge(page: object, provider: str, url: str) -> str:
"""Опрашивает page.content() пока не исчезнут маркеры PoW-челленджа. """Опрашивает page.content() пока не исчезнут маркеры PoW-челленджа.
@ -985,13 +1016,13 @@ async def _wait_out_pow_challenge(page: object, provider: str, url: str) -> str:
""" """
poll_interval_ms = 1000 poll_interval_ms = 1000
elapsed_ms = 0 elapsed_ms = 0
html: str = await page.content() # type: ignore[attr-defined] html: str | None = await _content_during_navigation(page)
while _is_pow_challenge(html) and elapsed_ms < BROWSER_CHALLENGE_WAIT_MS: while (html is None or _is_pow_challenge(html)) and elapsed_ms < BROWSER_CHALLENGE_WAIT_MS:
await page.wait_for_timeout(poll_interval_ms) # type: ignore[attr-defined] await page.wait_for_timeout(poll_interval_ms) # type: ignore[attr-defined]
elapsed_ms += poll_interval_ms elapsed_ms += poll_interval_ms
html = await page.content() # type: ignore[attr-defined] html = await _content_during_navigation(page)
if _is_pow_challenge(html): if html is None or _is_pow_challenge(html):
raise ChallengeTimeoutError( raise ChallengeTimeoutError(
f"tradein-browser[{provider}]: PoW-челлендж не снялся за " f"tradein-browser[{provider}]: PoW-челлендж не снялся за "
f"{BROWSER_CHALLENGE_WAIT_MS}мс url={url!r}" f"{BROWSER_CHALLENGE_WAIT_MS}мс url={url!r}"
@ -1005,7 +1036,19 @@ async def _wait_out_pow_challenge(page: object, provider: str, url: str) -> str:
) )
if BROWSER_WAIT_MS > 0: if BROWSER_WAIT_MS > 0:
await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined] await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined]
return await page.content() # type: ignore[attr-defined] settled = await _content_during_navigation(page)
if settled is None:
# Догидрация совпала с ещё одной навигацией — даём один короткий добор
# вместо того, чтобы падать: контент уже не challenge, гонка чисто
# техническая.
await page.wait_for_timeout(poll_interval_ms) # type: ignore[attr-defined]
settled = await _content_during_navigation(page)
if settled is None:
raise ChallengeTimeoutError(
f"tradein-browser[{provider}]: челлендж снят, но страница не устоялась "
f"(навигация не прекращается) url={url!r}"
)
return settled
async def _fetch_once( async def _fetch_once(

View file

@ -69,9 +69,14 @@ class _ChallengePage:
После исчерпания списка повторяет последний элемент (имитирует «страница После исчерпания списка повторяет последний элемент (имитирует «страница
осталась в этом состоянии»). Фиксирует goto/wait_for_timeout-вызовы для осталась в этом состоянии»). Фиксирует goto/wait_for_timeout-вызовы для
проверки, что бюджет ожидания не тратится там, где не должен. проверки, что бюджет ожидания не тратится там, где не должен.
Элемент последовательности может быть исключением тогда content() его
поднимает. Это нужно, чтобы воспроизвести гонку с self-reload челленджа:
playwright кидает «Execution context was destroyed» ровно в момент той
перезагрузки, которую мы ждём, и на моках без этого дефект не виден.
""" """
def __init__(self, html_sequence: list[str]) -> None: def __init__(self, html_sequence: list[str | Exception]) -> None:
self._html_sequence = html_sequence self._html_sequence = html_sequence
self._call_count = 0 self._call_count = 0
self.goto_urls: list[str] = [] self.goto_urls: list[str] = []
@ -89,9 +94,11 @@ class _ChallengePage:
async def content(self) -> str: async def content(self) -> str:
idx = min(self._call_count, len(self._html_sequence) - 1) idx = min(self._call_count, len(self._html_sequence) - 1)
html = self._html_sequence[idx] item = self._html_sequence[idx]
self._call_count += 1 self._call_count += 1
return html if isinstance(item, Exception):
raise item
return item
async def close(self) -> None: async def close(self) -> None:
self.closed += 1 self.closed += 1
@ -217,3 +224,50 @@ def test_fetch_once_normal_page_without_markers_unaffected(
assert page.closed == 1 assert page.closed == 1
assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS] assert page.wait_for_timeout_calls == [server.BROWSER_WAIT_MS]
assert page.goto_urls == ["https://www.avito.ru/card/1"] assert page.goto_urls == ["https://www.avito.ru/card/1"]
# ── гонка с self-reload челленджа (#3045, найдено при ревью ветки) ──────────────
#
# Челлендж перезагружает страницу САМ. Вызов page.content(), попавший ровно в этот
# момент, кидает «Execution context was destroyed» — то есть цикл ожидания падал бы
# именно на успешном исходе, ради которого написан. На моках без явной имитации
# это не воспроизводится, поэтому тесты ниже поднимают исключение из content().
_NAV_RACE = RuntimeError(
"Execution context was destroyed, most likely because of a navigation."
)
def test_navigation_race_during_reload_is_not_a_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""content() упал на перезагрузке → опрашиваем дальше, отдаём настоящий HTML."""
page = _ChallengePage([_CHALLENGE_HTML, _NAV_RACE, _REAL_HTML])
_install(monkeypatch, page)
html = asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
assert html == _REAL_HTML
def test_permanent_navigation_race_raises_challenge_timeout(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Навигация не прекращается → своя ошибка, а не сырое исключение playwright."""
page = _ChallengePage([_CHALLENGE_HTML, _NAV_RACE])
_install(monkeypatch, page)
with pytest.raises(server.ChallengeTimeoutError):
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))
def test_unrelated_content_error_still_propagates(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Глушим ТОЛЬКО гонку навигации; упавший браузер должен всплыть как есть."""
boom = RuntimeError("Target page, context or browser has been closed")
page = _ChallengePage([_CHALLENGE_HTML, boom])
_install(monkeypatch, page)
with pytest.raises(RuntimeError, match="has been closed"):
asyncio.run(server._fetch_once("avito", "https://www.avito.ru/x"))