gendesign/tradein-mvp/browser/test_server_anchor_origin_switch.py
bot-backend 58e18e6fec
All checks were successful
CI Trade-In / changes (pull_request) Successful in 9s
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 1m28s
CI Trade-In / backend-tests (pull_request) Successful in 4m58s
fix(tradein/avito): браузерный путь ходил на каждую карточку холодным (#3251)
Пройденный QRATOR proof-of-work выбрасывался после каждой карточки: `reuse_context`
во всём репозитории передавал ЕДИНСТВЕННЫЙ вызов — domclick_detail_backfill.py:346.
Значит каждый /fetch Авито шёл через sidecar'овский browser.new_page(), то есть
новый изолированный context с пустой банкой кук. В логе прод-сайдкара это видно
прямо: «PoW-челлендж снят за ~1000мс» печатается на КАЖДОЙ успешной карточке —
челлендж решается заново каждый раз, а не один раз на прогон.

Эффект той же правки у Домклика измерен и записан в domclick_detail_backfill.py:331
— 26 последовательных фетчей сайдкара дали 100% блоков, те же карточки в тёплом
контексте 5/5 за ~2с.

Второй холод — сам заход: providers/avito/detail.py звал fetch(full_url) голым,
без origin и без Referer, тогда как Домклик (#3247) идёт fetch(card_url,
origin=SERP, referer=origin). Существующий для Авито прогрев (warm_up_session)
живёт только в curl-пути и с 22.08 в проде мёртв — AVITO_DETAIL_BACKFILL_USE_CURL
выставлен в false.

Что сделано:
- avito_detail_backfill: reuse_context=True + request_context_reset() РОВНО один
  раз за прогон и только на AvitoBlockedError. Не на AvitoSidecarUnavailableError
  (подтип AvitoRateLimitedError — отказ нашего тракта, не бан площадки) и не на
  AvitoListingGoneError. Зеркалит #3212: сброс на каждый блок сам себя
  поддерживает — пропуск живёт в context'е, сброс его выбрасывает, повторная
  проверка с того же IP снова блокируется, одна осечка даёт каскад.
- fetch_detail: необязательные origin/browser_referer (имя referer уже занято под
  Referer curl-пути, это разные фетчеры и разные поля). Дефолт None → payload и
  поведение city_sweep/pipeline/admin не меняются.
- _serp_origin_for: городская SERP из URL карточки, хост берётся из самого url.

Попутно — дефект якорной вкладки сайдкара, найденный при переносе. _ensure_anchor_page
отдавала True на ЛЮБУЮ живую вкладку, не сверяя её с запрошенным origin. А origin у
обоих caller'ов выводится ИЗ URL карточки и меняется вместе с городом (Авито —
сегмент пути, Домклик — поддомен). После первой же карточки другого города Referer
называл выдачу, которую этот контекст никогда не открывал: ни куки её, ни тайминга,
площадка видит заявленный переход без единого следа. Ровно то, что #3258 запретил
делать фолбэкам якорного поиска. Добавлен _anchor_origins: origin сменился — вкладка
переоткрывается. Чинит и Домклик тоже.

Заход через поиск Яндекса (BROWSER_ANCHOR_VIA_SEARCH) для Авито НЕ включается —
расширять этот список без отдельного замера запрещает комментарий у самой константы.
Хранилище авторизованных сессий Авито (#3179/#3180) этой правкой не заменяется.

База для сравнения снята ДО выката и записана в #3251: доля блоков от попыток
46.9% / 74.2% / 96.4% / 69.8% / 52.9% / 62.3% по суткам 25-30.08. Сравнивать после
деплоя по доле блоков и обогащению за сутки, а НЕ по статусу прогона: ratio-критерий
обрывает КАЖДЫЙ прогон, и статус banned про площадку ничего не говорит.

Тесты: backend 5150 passed / 37 skipped, сайдкар 211 passed (было 206 + 5 новых на
переезд якоря), ruff чист.

Refs #3251, #3180, #3118, #3212, #3247, #3258
2026-08-30 11:47:15 +03:00

140 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""test_server_anchor_origin_switch.py — якорь обязан стоять на ЗАПРОШЕННОМ origin.
Контракт формулируется от того, чего требует площадка, а не от того, что делал код:
Referer целевой навигации называет страницу выдачи, поэтому эта выдача обязана быть
в контексте реально открыта. До этой правки `_ensure_anchor_page` возвращала True на
ЛЮБУЮ живую вкладку, не сверяя её origin с запрошенным. Оба нынешних caller'а выводят
origin из URL карточки, и он меняется вместе с городом (Авито — сегмент пути, Домклик —
поддомен), так что после первой же карточки другого города Referer называл выдачу,
которой этот контекст никогда не открывал: ни куки её, ни тайминга. Ровно тот вид
«заявленного, но не случившегося перехода», который запретил #3258.
camoufox НЕ запускается — context/page подделаны.
Запуск (из tradein-mvp/browser/)::
python -m pytest test_server_anchor_origin_switch.py -q
"""
from __future__ import annotations
import asyncio
import importlib.util
from pathlib import Path
from typing import Any
import pytest
_SERVER_PATH = Path(__file__).resolve().parent / "server.py"
_spec = importlib.util.spec_from_file_location("tradein_browser_server_anchor_switch", _SERVER_PATH)
assert _spec is not None and _spec.loader is not None
server = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(server)
_PROVIDER = "avito"
_EKB = "https://www.avito.ru/ekaterinburg/kvartiry/prodam"
_PYSHMA = "https://www.avito.ru/verhnyaya_pyshma/kvartiry/prodam"
@pytest.fixture(autouse=True)
def _reset_state() -> None:
server._contexts = {}
server._anchor_pages = {}
server._anchor_origins = {}
class _Page:
def __init__(self) -> None:
self.goto_urls: list[str] = []
self.closed = 0
self.url = ""
def is_closed(self) -> bool:
return self.closed > 0
async def route(self, pattern: str, handler: Any) -> None:
return None
async def goto(self, url: str, **kwargs: Any) -> None:
self.goto_urls.append(url)
self.url = url
async def wait_for_timeout(self, ms: int) -> None:
return None
async def close(self) -> None:
self.closed += 1
class _Context:
"""new_page() отдаёт КАЖДЫЙ раз новую вкладку — как настоящий BrowserContext."""
def __init__(self) -> None:
self.pages: list[_Page] = []
async def new_page(self) -> _Page:
page = _Page()
self.pages.append(page)
return page
async def close(self) -> None:
return None
def test_same_origin_reuses_the_open_tab() -> None:
"""Повторный запрос того же origin не платит за навигацию второй раз."""
ctx = _Context()
server._contexts[_PROVIDER] = ctx
assert asyncio.run(server._ensure_anchor_page(_PROVIDER, _EKB)) is True
assert asyncio.run(server._ensure_anchor_page(_PROVIDER, _EKB)) is True
assert len(ctx.pages) == 1, "вторая вкладка не нужна — origin тот же"
assert ctx.pages[0].goto_urls == [_EKB]
assert server._anchor_origins[_PROVIDER] == _EKB
def test_changed_origin_reopens_the_tab_on_the_new_origin() -> None:
"""Другой город — якорь ОБЯЗАН переехать, иначе Referer называет чужую выдачу."""
ctx = _Context()
server._contexts[_PROVIDER] = ctx
assert asyncio.run(server._ensure_anchor_page(_PROVIDER, _EKB)) is True
assert asyncio.run(server._ensure_anchor_page(_PROVIDER, _PYSHMA)) is True
assert len(ctx.pages) == 2, "на новый origin поднимается новая вкладка"
assert ctx.pages[0].closed == 1, "старая вкладка не должна течь"
assert ctx.pages[1].goto_urls == [_PYSHMA]
assert server._anchor_origins[_PROVIDER] == _PYSHMA
assert server._anchor_pages[_PROVIDER] is ctx.pages[1]
def test_dead_tab_is_replaced_even_on_the_same_origin() -> None:
"""Вкладку закрыли извне (recycle/краш) — поднимаем заново, не отдаём мёртвую."""
ctx = _Context()
server._contexts[_PROVIDER] = ctx
assert asyncio.run(server._ensure_anchor_page(_PROVIDER, _EKB)) is True
asyncio.run(ctx.pages[0].close())
assert asyncio.run(server._ensure_anchor_page(_PROVIDER, _EKB)) is True
assert len(ctx.pages) == 2
assert server._anchor_pages[_PROVIDER] is ctx.pages[1]
def test_closing_context_forgets_the_anchor_origin() -> None:
"""Сброс context'а обнуляет и память об origin — иначе новый context унаследует
чужое значение и вернёт «годен» на вкладку, которой уже нет."""
ctx = _Context()
server._contexts[_PROVIDER] = ctx
assert asyncio.run(server._ensure_anchor_page(_PROVIDER, _EKB)) is True
asyncio.run(server._close_reusable_context(_PROVIDER))
assert _PROVIDER not in server._anchor_origins
assert _PROVIDER not in server._anchor_pages
def test_no_context_means_no_anchor() -> None:
"""Без переиспользуемого context'а держать вкладку негде — прежнее поведение."""
assert asyncio.run(server._ensure_anchor_page(_PROVIDER, _EKB)) is False