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
Пройденный 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
140 lines
5.8 KiB
Python
140 lines
5.8 KiB
Python
"""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
|