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
Owner

Шаг 2 из #3045. Меняет образ tradein-browser.

Проблема

Авито за QRATOR отдаёт proof-of-work челлендж: JS считает PoW, ставит куку pow_solved, через setTimeout(…, 3000) делает window.location = location.href — self-reload на тот же URL, и только после него приходит контент.

_fetch_once ждал фиксированные BROWSER_WAIT_MS (6 с) и забирал page.content(). На цепочку «PoW → таймер 3 с → перезагрузка → гидратация» этого хватало не всегда.

Замер на проде 2026-08-21: шесть разных карточек с органической навигацией (origin = выдача вторички) — 2 успеха, 4 челлендж-страницы по 7891 байт. Важно: ни одного бана, все отказы это «проверка безопасности». Площадка не блокирует — мы уходим раньше.

Хуже того, caller считал челлендж-страницу валидным HTML: парсер либо падал, либо молча ничего не находил. Отсюда и вердикты прогонов «причина НЕ установлена».

Что сделано

  • Детекторы _is_pow_challenge / _is_ban_page по маркерам, снятым живьём (startPow, «проверка безопасности» / «проблема с IP»). Маркеры вынесены в константы с пометкой, что могут протухнуть.
  • _wait_out_pow_challenge — опрос page.content() шагом ~1 с в пределах новой BROWSER_CHALLENGE_WAIT_MS (дефолт 30000), затем догидрация тем же BROWSER_WAIT_MS. wait_for_url не годится: челлендж перезагружает страницу на тот же адрес.
  • По истечении бюджета — ChallengeTimeoutError, а не тихая отдача заглушки.
  • Бан распознаётся отдельно и падает сразуBanPageDetectedError, без траты бюджета: ждать на заблокированном адресе нечего. Это принципиально разные ответы, и путать их дорого.
  • Провайдер-агностично по форме: включается только при наличии маркеров, для cian/yandex поведение не меняется.

Дефект, найденный при ревью ветки (второй коммит)

Первая версия опрашивала page.content() без защиты. Но челлендж перезагружает страницу сам, и вызов content(), попавший в момент перезагрузки, кидает:

Execution context was destroyed, most likely because of a navigation.

То есть цикл ронял фетч ровно на успешном исходе — в момент той самой перезагрузки, ради ожидания которой он и написан. На моках это не воспроизводилось: поддельная page навигацию не рвёт, поэтому все 12 исходных тестов были зелёными.

Добавлено:

  • _content_during_navigation() — возвращает None вместо исключения, если текст ошибки указывает на гонку с навигацией. Различаем по тексту, а не по типу: сервис не импортирует playwright, page приходит готовым объектом. Всё прочее (закрытая страница, упавший браузер) пробрасывается как есть.
  • Цикл трактует None как «ещё не устоялось, опроси снова».
  • Финальная догидрация тоже защищена: один короткий добор, затем внятная ошибка вместо падения на гонке.
  • Поддельная page в тестах научена поднимать исключение из content().

Тесты

tradein-mvp/browser/test_server_pow_challenge.py — 15 тестов: детекторы, ожидание челленджа, исчерпание бюджета, бан без траты бюджета, обычная страница без изменений, плюс три на гонку навигации (прохождение, вечная навигация, посторонняя ошибка).

Фальсификация: без правки второго коммита два теста на гонку падают именно с Execution context was destroyed — тем самым, что прилетело бы на проде.

  • pytest по всему сервису — 123 passed
  • ruff check — чисто

После деплоя

Образ tradein-browser пересобирается. Нужен замер доли успешных detail-фетчей Авито с органической навигацией — baseline до правки 2 из 6. Ожидание близко к сотне, поскольку блокировки не было.

Флаг avito_detail_backfill_use_curl не тронут — это шаг 3, отдельным PR после замера.

Refs #3045

Шаг 2 из #3045. Меняет образ **`tradein-browser`**. ## Проблема Авито за QRATOR отдаёт proof-of-work челлендж: JS считает PoW, ставит куку `pow_solved`, через `setTimeout(…, 3000)` делает `window.location = location.href` — self-reload на тот же URL, и только после него приходит контент. `_fetch_once` ждал фиксированные `BROWSER_WAIT_MS` (6 с) и забирал `page.content()`. На цепочку «PoW → таймер 3 с → перезагрузка → гидратация» этого хватало не всегда. **Замер на проде 2026-08-21:** шесть разных карточек с органической навигацией (`origin` = выдача вторички) — **2 успеха, 4 челлендж-страницы по 7891 байт**. Важно: ни одного бана, все отказы это «проверка безопасности». Площадка не блокирует — мы уходим раньше. Хуже того, caller считал челлендж-страницу валидным HTML: парсер либо падал, либо молча ничего не находил. Отсюда и вердикты прогонов «причина НЕ установлена». ## Что сделано - **Детекторы** `_is_pow_challenge` / `_is_ban_page` по маркерам, снятым живьём (`startPow`, «проверка безопасности» / «проблема с IP»). Маркеры вынесены в константы с пометкой, что могут протухнуть. - **`_wait_out_pow_challenge`** — опрос `page.content()` шагом ~1 с в пределах новой `BROWSER_CHALLENGE_WAIT_MS` (дефолт 30000), затем догидрация тем же `BROWSER_WAIT_MS`. `wait_for_url` не годится: челлендж перезагружает страницу на тот же адрес. - **По истечении бюджета — `ChallengeTimeoutError`**, а не тихая отдача заглушки. - **Бан распознаётся отдельно и падает сразу** — `BanPageDetectedError`, без траты бюджета: ждать на заблокированном адресе нечего. Это принципиально разные ответы, и путать их дорого. - Провайдер-агностично по форме: включается только при наличии маркеров, для cian/yandex поведение не меняется. ## Дефект, найденный при ревью ветки (второй коммит) Первая версия опрашивала `page.content()` **без защиты**. Но челлендж перезагружает страницу сам, и вызов `content()`, попавший в момент перезагрузки, кидает: ``` Execution context was destroyed, most likely because of a navigation. ``` То есть цикл ронял фетч **ровно на успешном исходе** — в момент той самой перезагрузки, ради ожидания которой он и написан. На моках это не воспроизводилось: поддельная `page` навигацию не рвёт, поэтому все 12 исходных тестов были зелёными. Добавлено: - `_content_during_navigation()` — возвращает `None` вместо исключения, если текст ошибки указывает на гонку с навигацией. Различаем по тексту, а не по типу: сервис не импортирует playwright, `page` приходит готовым объектом. Всё прочее (закрытая страница, упавший браузер) пробрасывается как есть. - Цикл трактует `None` как «ещё не устоялось, опроси снова». - Финальная догидрация тоже защищена: один короткий добор, затем внятная ошибка вместо падения на гонке. - Поддельная `page` в тестах научена поднимать исключение из `content()`. ## Тесты `tradein-mvp/browser/test_server_pow_challenge.py` — 15 тестов: детекторы, ожидание челленджа, исчерпание бюджета, бан без траты бюджета, обычная страница без изменений, плюс три на гонку навигации (прохождение, вечная навигация, посторонняя ошибка). **Фальсификация:** без правки второго коммита два теста на гонку падают именно с `Execution context was destroyed` — тем самым, что прилетело бы на проде. - `pytest` по всему сервису — **123 passed** - `ruff check` — чисто ## После деплоя Образ `tradein-browser` пересобирается. Нужен замер доли успешных detail-фетчей Авито с органической навигацией — **baseline до правки 2 из 6**. Ожидание близко к сотне, поскольку блокировки не было. Флаг `avito_detail_backfill_use_curl` **не тронут** — это шаг 3, отдельным PR после замера. Refs #3045
lekss361 added 2 commits 2026-08-21 16:58:26 +00:00
Живой замер 2026-08-21 (#3045): фиксированной паузы BROWSER_WAIT_MS (6с) не
хватает на цепочку «PoW-расчёт в JS → таймер 3с → self-reload → гидрация» —
4 из 6 карточек с органической навигацией отдавали 7891-байтную challenge-
страницу вместо контента (не бан, "проверка безопасности"). caller считал её
валидным HTML — парсер либо падал, либо молча ничего не находил.

_fetch_once теперь опрашивает page.content() (шаг ~1с, бюджет
BROWSER_CHALLENGE_WAIT_MS=30000) пока маркеры челленджа (startPow / "проверка
безопасности") не исчезнут, затем догидрируется тем же BROWSER_WAIT_MS. По
истечении бюджета — ChallengeTimeoutError вместо заглушки. wait_for_url не
годится: страница перезагружает саму себя, URL не меняется.

Бан-страница ("проблема с IP") распознаётся отдельно и падает сразу
(BanPageDetectedError), без траты бюджета ожидания — это не то же самое, что
челлендж, и ждать там нечего.

Провайдер-агностично по форме: включается только по факту маркеров в HTML,
cian/yandex/generic их никогда не отдают.
fix(tradein/browser): цикл ожидания челленджа падал ровно на успешном исходе
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 10s
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 53s
5c0fd78c1f
Найдено при ревью ветки. `_wait_out_pow_challenge` опрашивал `page.content()`
без защиты, а челлендж перезагружает страницу САМ (`window.location =
location.href`). Вызов content(), попавший в момент этой перезагрузки, кидает
«Execution context was destroyed, most likely because of a navigation» — то
есть цикл ронял фетч ровно тогда, когда проверка успешно пройдена и мы
дождались того, ради чего ждали.

На моках дефект не воспроизводился: поддельная page навигацию не рвёт.

Добавлено:
  - `_content_during_navigation()` — content(), возвращающий None вместо
    исключения, если текст ошибки указывает на гонку с навигацией. Различаем
    по тексту, а не по типу: сервис не импортирует playwright, page приходит
    готовым. Всё прочее (закрытая страница, упавший браузер) пробрасывается.
  - Цикл трактует None как «ещё не устоялось, опроси снова».
  - Финальная догидрация тоже защищена: один короткий добор, затем внятная
    ошибка вместо падения на гонке.
  - Поддельная page в тестах умеет поднимать исключение из content();
    три теста на гонку — прохождение, вечная навигация, посторонняя ошибка.

Фальсификация: без правки два новых теста падают именно с
`Execution context was destroyed`. Полный сьют сервиса — 123 passed.

Refs #3045
lekss361 merged commit 1d479d0150 into main 2026-08-21 17:01:25 +00:00
lekss361 deleted branch fix/3045-browser-pow-challenge-wait 2026-08-21 17:01:25 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3046
No description provided.