fix(tradein/avito): браузерный путь ходил на каждую карточку холодным (#3251) #3267

Merged
lekss361 merged 1 commit from fix/3180-avito-warm-context into main 2026-08-30 08:55:34 +00:00
7 changed files with 729 additions and 5 deletions

View file

@ -73,6 +73,7 @@ from scraper_kit.orchestration.pipeline import CITY_LOCATIONS, ban_kind_of_excep
from scraper_kit.providers._base import DEFAULT_IMPERSONATE, DOCUMENT_HEADERS, http_proxies
from scraper_kit.providers.avito.detail import (
_AVITO_WARM_SEARCH_URL,
_serp_origin_for,
build_warmed_session,
fetch_detail,
research_in_session,
@ -293,6 +294,13 @@ async def run_avito_detail_backfill(
# Замер 27.08: прогон 5098 — mode=browser, proxy_lease_id=None,
# 5 блоков подряд из 5 попыток, enriched=0. При этом пул здоров
# (4 узла, все ok), а тот же URL через прокси отдаёт 200 и 3.3 МБ.
# reuse_context=True (#3180/#3251): без него sidecar's browser.new_page()
# создаёт НОВЫЙ изолированный context на КАЖДЫЙ /fetch — пройденный
# QRATOR-PoW предыдущей карточки выбрасывается, и следующий запрос снова
# холодный. У DomClick (#3118) это измеренно давало 100% блоков (26/26)
# на изолированных контекстах против 5/5 успехов в тёплом контексте.
# Сброс сожжённого context'а — ниже, через bf.request_context_reset()
# (#3212: один раз за прогон, не на каждый блок — см. except-ветку).
_cfg = RealScraperConfig()
browser_fetcher = BrowserFetcher(
source="avito",
@ -302,6 +310,7 @@ async def run_avito_detail_backfill(
# Без environment отказ «пул пуст» на этом пути мёртв — фетчер
# молча ушёл бы на env-прокси сайдкара (#2616 шаг 1).
environment=_cfg.environment,
reuse_context=True,
)
await browser_fetcher.__aenter__()
own_browser = True
@ -439,6 +448,13 @@ async def run_avito_detail_backfill(
abort_reason: str | None = None
do_sleep = False
items_since_warm = 0
# #3251: сброс переиспользуемого browser-context'а (reuse_context=True выше)
# разрешён РОВНО один раз за прогон — зеркалит domclick_detail_backfill (#3212).
# Сброс на КАЖДЫЙ блок сам себя поддерживает: пройденный QRATOR-PoW живёт в
# context'е, сброс его выбрасывает, повторная проверка с того же IP сразу
# после принятой снова блокируется — одна осечка превращается в необратимый
# каскад блоков (см. except-ветку ниже).
context_reset_used = False
# Перепись причин (блоки + отказы) — переживает пересоздание контейнера,
# в отличие от логов; см. _failure_signature.
failure_census: Counter[str] = Counter()
@ -544,6 +560,14 @@ async def run_avito_detail_backfill(
# #1950: hard-timeout — зависший fetch (browser hang / curl-stall) не
# должен блокировать loop навсегда (иначе budget-guard/heartbeat молчат
# и run reaped как zombie). wait_for отменяет fetch → TimeoutError.
# #3251: same-site SERP-якорь. Смысл он имеет ТОЛЬКО в browser-
# режиме: сайдкар держит выдачу открытой якорной вкладкой и шлёт
# её как Referer целевой навигации. На curl/own-session пути
# сессия прогрета warm-batch'ем (см. referer= ниже, это ДРУГОЕ
# поле и другой фетчер) — там origin остаётся None и fetch_detail
# ведёт себя ровно как раньше. None также если source_url не
# распарсился на город+категорию (см. docstring _serp_origin_for).
serp_origin = _serp_origin_for(source_url) if browser_mode else None
enrichment = await asyncio.wait_for(
fetch_detail(
item_url,
@ -558,6 +582,8 @@ async def run_avito_detail_backfill(
# напрямую). Не влияет на use_curl=True (прод-дефолт) —
# там reconnect_on_block=False уже гасит backconnect.
config=RealScraperConfig(),
origin=serp_origin,
browser_referer=serp_origin,
),
timeout=fetch_timeout_s,
)
@ -627,6 +653,22 @@ async def run_avito_detail_backfill(
failure_census[_failure_signature(e)] += 1
block_ban_kinds[ban_kind_of_exception(e)] += 1
do_sleep = False
# #3251: сброс тёплого browser-context'а — ровно один раз за прогон,
# и только на настоящий бан ПЛОЩАДКОЙ (AvitoBlockedError и подтипы:
# AvitoContentBlockedError, AvitoWarmupCookiesMissingError). НЕ на
# AvitoSidecarUnavailableError (подтип AvitoRateLimitedError) — это
# отказ НАШЕГО тракта, площадка тут ни при чём, context цел и сбрасывать
# его незачем. См. domclick_detail_backfill.py (#3212) — тот же приём:
# сброс на каждый блок сам себя поддерживает (пройденный QRATOR-PoW
# живёт в context'е, сброс его выбрасывает → следующая проверка с того
# же IP снова блокируется).
if (
browser_fetcher is not None
and isinstance(e, AvitoBlockedError)
and not context_reset_used
):
context_reset_used = True
browser_fetcher.request_context_reset()
logger.warning(
"avito_detail_backfill: run_id=%d BLOCKED #%d/%d (consecutive=%d): %s",
run_id,

View file

@ -0,0 +1,83 @@
"""fetch_detail(#3251): same-site SERP origin/referer в browser-режиме Avito.
Переносит на Avito рецепт, измеренно поднявший добор карточек у DomClick (#3247/
#3118): переход на карточку идёт из SERP-выдачи (origin), а не голым goto(url).
Контракт формулируется от того, чего требует площадка (переход должен выглядеть
как реальная навигация с выдачи), а не от того, что уже делает код см. #3262,
где обратный подход закрепил баг на месяцы.
_serp_origin_for чистый URL-хелпер, тестируется отдельно ниже без сети/моков.
"""
from __future__ import annotations
import os
from unittest.mock import AsyncMock, MagicMock
import pytest
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db")
from scraper_kit.providers.avito.detail import _serp_origin_for, fetch_detail
_MIN_HTML = '<html><body><div data-marker="item-view/item-id">№ 1234567890</div></body></html>'
# ── fetch_detail: origin/browser_referer прокидываются в browser_fetcher.fetch ──
@pytest.mark.asyncio
async def test_fetch_detail_passes_origin_and_referer_when_given() -> None:
"""Площадка должна увидеть переход С ВЫДАЧИ: origin/referer уходят в fetch()."""
bf = MagicMock()
bf.fetch = AsyncMock(return_value=_MIN_HTML)
card_url = "https://www.avito.ru/ekaterinburg/kvartiry/2-k._kvartira_1234567890"
origin = "https://www.avito.ru/ekaterinburg/kvartiry/prodam"
await fetch_detail(card_url, browser_fetcher=bf, origin=origin, browser_referer=origin)
bf.fetch.assert_called_once_with(card_url, origin=origin, referer=origin)
@pytest.mark.asyncio
async def test_fetch_detail_omits_origin_and_referer_when_not_given() -> None:
"""Дефолт (origin/browser_referer не переданы) — payload как раньше, без ключей.
Это путь ВСЕХ прочих browser-вызовов (city_sweep, pipeline, admin) они не
знают о #3251 и не должны получить изменённый payload.
"""
bf = MagicMock()
bf.fetch = AsyncMock(return_value=_MIN_HTML)
card_url = "https://www.avito.ru/ekaterinburg/kvartiry/2-k._kvartira_1234567890"
await fetch_detail(card_url, browser_fetcher=bf)
bf.fetch.assert_called_once_with(card_url, origin=None, referer=None)
# ── _serp_origin_for: чистый URL-хелпер ─────────────────────────────────────────
def test_serp_origin_for_normal_card_url() -> None:
url = "https://www.avito.ru/ekaterinburg/kvartiry/2-k._kvartira_45_m_kv_1234567890"
assert _serp_origin_for(url) == "https://www.avito.ru/ekaterinburg/kvartiry/prodam"
def test_serp_origin_for_other_city_and_host() -> None:
"""Хост берётся из card_url, не хардкодится 'www.avito.ru'."""
url = "https://m.avito.ru/tyumen/kvartiry/1-k._kvartira_30_m_kv_987654321"
assert _serp_origin_for(url) == "https://m.avito.ru/tyumen/kvartiry/prodam"
@pytest.mark.parametrize(
"garbage",
[
"not a url at all",
"https://www.avito.ru/",
"https://www.avito.ru/ekaterinburg",
"",
"avito.ru/ekaterinburg/kvartiry/foo", # без scheme -> netloc пуст
],
)
def test_serp_origin_for_garbage_returns_none(garbage: str) -> None:
assert _serp_origin_for(garbage) is None

View file

@ -735,6 +735,10 @@ async def test_backfill_fetch_timeout_skips_and_continues() -> None:
referer: object = None,
reconnect_on_block: bool = True,
config: object = None,
# Стаб терпит рост сигнатуры fetch_detail: тест про hard-timeout, а не про
# набор аргументов. Без этого любой новый kwarg (напр. origin/browser_referer
# из #3251) валит его TypeError'ом, хотя к таймауту отношения не имеет.
**_extra: object,
):
call_urls.append(url)
if len(call_urls) == 1:

View file

@ -0,0 +1,394 @@
"""Тёплый переиспользуемый browser-context для Avito detail-бэкфилла (#3180/#3251).
Переносит на Avito рецепт, измеренно поднявший добор карточек у DomClick (#3118):
sidecar's browser.new_page() создаёт НОВЫЙ изолированный context на КАЖДЫЙ /fetch —
пройденный QRATOR-подобный анти-бот проход площадки выбрасывается вместе с ним, и
каждая следующая карточка снова холодная. reuse_context=True держит ОДИН sidecar-
context живым на весь прогон; сброс сожжённого context'а оркестратор просит через
bf.request_context_reset() как и DomClick, ровно один раз за прогон (#3212), а не
на каждый обнаруженный блок (иначе сброс сам себя поддерживает см. domclick
docstring). Reset относится ТОЛЬКО к реальному бану площадкой (AvitoBlockedError и
подтипы), не к отказу нашего тракта (AvitoSidecarUnavailableError) и не к мёртвому
листингу (AvitoListingGoneError) площадка тут ни при чём в обоих случаях, context
цел.
Контракт тестов сформулирован от требования площадки/архитектуры (что ДОЛЖНО
происходить при блоке/не-блоке), а не от текущей реализации см. #3262, где
обратный подход закрепил баг тестами на месяцы.
Зеркалит tests/test_3118_domclick_warm_context.py (module-level patch-target
строки, helpers).
"""
from __future__ import annotations
import os
import sys
from unittest.mock import AsyncMock, MagicMock, patch
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
_wp_mock = MagicMock()
sys.modules.setdefault("weasyprint", _wp_mock)
import pytest # noqa: E402
from scraper_kit.avito_exceptions import ( # noqa: E402
AvitoBlockedError,
AvitoContentBlockedError,
AvitoListingGoneError,
AvitoSidecarUnavailableError,
)
from app.core import shutdown as _sd # noqa: E402
from app.tasks.avito_detail_backfill import run_avito_detail_backfill # noqa: E402
_FETCH = "app.tasks.avito_detail_backfill.fetch_detail"
_SAVE = "app.tasks.avito_detail_backfill.save_detail_enrichment"
_RUNS = "app.tasks.avito_detail_backfill.runs_mod"
_SLEEP = "app.tasks.avito_detail_backfill.asyncio.sleep"
_SETTINGS = "app.tasks.avito_detail_backfill.settings"
_SESSION = "app.tasks.avito_detail_backfill.AsyncSession"
_SCRAPER = "app.tasks.avito_detail_backfill.AvitoScraper"
_BROWSER_FETCHER = "app.tasks.avito_detail_backfill.BrowserFetcher"
@pytest.fixture(autouse=True)
def _reset_shutdown() -> None:
"""shutdown -- module-global Event: чистим вокруг каждого теста (изоляция #1182)."""
_sd.reset_shutdown()
yield
_sd.reset_shutdown()
def _fake_settings(**overrides: object) -> MagicMock:
defaults: dict[str, object] = {
"scraper_fetch_mode": "browser",
"avito_detail_backfill_use_curl": False,
"detail_backfill_block_ratio_window": 20,
"detail_backfill_block_ratio_threshold": 0.7,
"browser_http_endpoint": "http://browser:9000",
}
defaults.update(overrides)
return MagicMock(**defaults)
def _make_snapshot(n: int, *, absolute: bool = True) -> list[dict]:
if absolute:
return [
{
"id": i + 1,
"source_url": (
f"https://www.avito.ru/ekaterinburg/kvartiry/"
f"1-k._kvartira_{i + 1}"
),
}
for i in range(n)
]
return [{"id": i + 1, "source_url": f"/items/{i + 1}"} for i in range(n)]
def _mock_db(snapshot: list[dict]) -> MagicMock:
db = MagicMock()
sel = MagicMock()
sel.mappings.return_value.all.return_value = snapshot
db.execute.return_value = sel
return db
def _mock_browser_fetcher_cls() -> tuple[MagicMock, AsyncMock]:
"""Возвращает (класс-мок, instance-мок) -- instance с реальным request_context_reset."""
instance = AsyncMock()
instance.__aenter__ = AsyncMock(return_value=instance)
instance.__aexit__ = AsyncMock(return_value=False)
# request_context_reset -- синхронный метод (не awaited caller'ом).
instance.request_context_reset = MagicMock()
return MagicMock(return_value=instance), instance
# ── BrowserFetcher(reuse_context=True) ──────────────────────────────────────────
@pytest.mark.asyncio
async def test_backfill_constructs_browser_fetcher_with_reuse_context() -> None:
"""BrowserFetcher поднимается с reuse_context=True (#3180) -- тёплый sidecar-context."""
snapshot = _make_snapshot(1)
db = _mock_db(snapshot)
runs = MagicMock()
mock_fetch = AsyncMock(return_value=MagicMock())
fake_settings = _fake_settings()
mock_bf_cls, _instance = _mock_browser_fetcher_cls()
with (
patch(_SETTINGS, fake_settings),
patch(_SESSION),
patch(_SCRAPER),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER, mock_bf_cls),
patch(_FETCH, mock_fetch),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
):
await run_avito_detail_backfill(
db, run_id=200, params={"batch_size": 10, "budget_sec": 3600}
)
mock_bf_cls.assert_called_once()
_, call_kwargs = mock_bf_cls.call_args
assert call_kwargs["source"] == "avito"
assert call_kwargs["reuse_context"] is True
# ── reset: ровно один раз за прогон, только на реальный бан площадкой ──────────
@pytest.mark.asyncio
async def test_context_reset_requested_at_most_once_per_run() -> None:
"""3 подряд AvitoBlockedError -> request_context_reset() запрошен ровно 1 раз.
Зеркалит domclick #3212: сброс на КАЖДЫЙ блок сам себя поддерживает (пройденный
анти-бот проход живёт в context'е, сброс его выбрасывает -> следующая проверка с
того же IP снова блокируется) -- одна осечка становится необратимым каскадом.
"""
snapshot = _make_snapshot(10)
db = _mock_db(snapshot)
runs = MagicMock()
blocked_exc = AvitoBlockedError("firewall/soft-block")
mock_fetch = AsyncMock(side_effect=blocked_exc)
fake_settings = _fake_settings()
mock_bf_cls, instance = _mock_browser_fetcher_cls()
with (
patch(_SETTINGS, fake_settings),
patch(_SESSION),
patch(_SCRAPER),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER, mock_bf_cls),
patch(_FETCH, mock_fetch),
patch(_SLEEP, new_callable=AsyncMock),
):
result = await run_avito_detail_backfill(
db,
run_id=201,
params={"batch_size": 10, "budget_sec": 3600, "max_consecutive_blocks": 3},
)
assert result.blocked == 3
assert instance.request_context_reset.call_count == 1, (
"ожидалась ровно одна попытка начать с чистого листа за весь прогон"
)
@pytest.mark.asyncio
async def test_content_blocked_subtype_also_requests_reset() -> None:
"""AvitoContentBlockedError (подтип AvitoBlockedError) тоже считается блоком площадки."""
snapshot = _make_snapshot(3)
db = _mock_db(snapshot)
runs = MagicMock()
mock_fetch = AsyncMock(side_effect=AvitoContentBlockedError("0 карточек, captcha"))
fake_settings = _fake_settings()
mock_bf_cls, instance = _mock_browser_fetcher_cls()
with (
patch(_SETTINGS, fake_settings),
patch(_SESSION),
patch(_SCRAPER),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER, mock_bf_cls),
patch(_FETCH, mock_fetch),
patch(_SLEEP, new_callable=AsyncMock),
):
result = await run_avito_detail_backfill(
db,
run_id=202,
params={"batch_size": 10, "budget_sec": 3600, "max_consecutive_blocks": 5},
)
assert result.blocked == 3
instance.request_context_reset.assert_called_once()
@pytest.mark.asyncio
async def test_sidecar_unavailable_does_not_request_context_reset() -> None:
"""AvitoSidecarUnavailableError -- НАШ тракт, площадка ни при чём: reset НЕ звать.
Context цел, сбрасывать его на отказе сайдкара (503/timeout ДО площадки) незачем
и вредно -- анти-бот проход площадки внутри context'а тут ни при чём.
"""
snapshot = _make_snapshot(3)
db = _mock_db(snapshot)
runs = MagicMock()
mock_fetch = AsyncMock(side_effect=AvitoSidecarUnavailableError("browser unavailable"))
fake_settings = _fake_settings()
mock_bf_cls, instance = _mock_browser_fetcher_cls()
with (
patch(_SETTINGS, fake_settings),
patch(_SESSION),
patch(_SCRAPER),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER, mock_bf_cls),
patch(_FETCH, mock_fetch),
patch(_SLEEP, new_callable=AsyncMock),
):
result = await run_avito_detail_backfill(
db,
run_id=203,
params={"batch_size": 10, "budget_sec": 3600, "max_consecutive_blocks": 5},
)
assert result.blocked == 3
instance.request_context_reset.assert_not_called()
@pytest.mark.asyncio
async def test_listing_gone_does_not_request_context_reset() -> None:
"""AvitoListingGoneError (мёртвый 404-листинг) -- не блок, reset НЕ звать."""
snapshot = _make_snapshot(3)
db = _mock_db(snapshot)
runs = MagicMock()
mock_fetch = AsyncMock(side_effect=AvitoListingGoneError("404 gone"))
fake_settings = _fake_settings()
mock_bf_cls, instance = _mock_browser_fetcher_cls()
with (
patch(_SETTINGS, fake_settings),
patch(_SESSION),
patch(_SCRAPER),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER, mock_bf_cls),
patch(_FETCH, mock_fetch),
patch(_SLEEP, new_callable=AsyncMock),
):
result = await run_avito_detail_backfill(
db, run_id=204, params={"batch_size": 10, "budget_sec": 3600}
)
assert result.gone == 3
assert result.blocked == 0
instance.request_context_reset.assert_not_called()
@pytest.mark.asyncio
async def test_successful_fetch_does_not_request_context_reset() -> None:
"""Успешный прогон без блоков -> request_context_reset() ни разу не вызывается."""
snapshot = _make_snapshot(3)
db = _mock_db(snapshot)
runs = MagicMock()
mock_fetch = AsyncMock(return_value=MagicMock())
fake_settings = _fake_settings()
mock_bf_cls, instance = _mock_browser_fetcher_cls()
with (
patch(_SETTINGS, fake_settings),
patch(_SESSION),
patch(_SCRAPER),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER, mock_bf_cls),
patch(_FETCH, mock_fetch),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
):
result = await run_avito_detail_backfill(
db, run_id=205, params={"batch_size": 10, "budget_sec": 3600}
)
assert result.blocked == 0
instance.request_context_reset.assert_not_called()
# ── origin/browser_referer wiring в fetch_detail ────────────────────────────────
@pytest.mark.asyncio
async def test_backfill_passes_serp_origin_to_fetch_detail_in_browser_mode() -> None:
"""browser-режим: fetch_detail получает origin=browser_referer=SERP-URL карточки."""
snapshot = _make_snapshot(1, absolute=True)
db = _mock_db(snapshot)
runs = MagicMock()
mock_fetch = AsyncMock(return_value=MagicMock())
fake_settings = _fake_settings()
mock_bf_cls, _instance = _mock_browser_fetcher_cls()
with (
patch(_SETTINGS, fake_settings),
patch(_SESSION),
patch(_SCRAPER),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER, mock_bf_cls),
patch(_FETCH, mock_fetch),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
):
await run_avito_detail_backfill(
db, run_id=206, params={"batch_size": 10, "budget_sec": 3600}
)
mock_fetch.assert_called_once()
_, kwargs = mock_fetch.call_args
expected = "https://www.avito.ru/ekaterinburg/kvartiry/prodam"
assert kwargs.get("origin") == expected
assert kwargs.get("browser_referer") == expected
@pytest.mark.asyncio
async def test_backfill_omits_origin_when_url_unparseable() -> None:
"""source_url без города/категории (relative path) -> origin/browser_referer=None.
Хелпер не смог вывести SERP-URL -> caller НЕ выдумывает его, просто не передаёт
(fetch_detail тогда идёт как до #3251).
"""
snapshot = _make_snapshot(1, absolute=False)
db = _mock_db(snapshot)
runs = MagicMock()
mock_fetch = AsyncMock(return_value=MagicMock())
fake_settings = _fake_settings()
mock_bf_cls, _instance = _mock_browser_fetcher_cls()
with (
patch(_SETTINGS, fake_settings),
patch(_SESSION),
patch(_SCRAPER),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER, mock_bf_cls),
patch(_FETCH, mock_fetch),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
):
await run_avito_detail_backfill(
db, run_id=207, params={"batch_size": 10, "budget_sec": 3600}
)
mock_fetch.assert_called_once()
_, kwargs = mock_fetch.call_args
assert kwargs.get("origin") is None
assert kwargs.get("browser_referer") is None
@pytest.mark.asyncio
async def test_curl_mode_does_not_pass_origin() -> None:
"""use_curl=True (прод-дефолт warm-batch путь) -- origin/browser_referer НЕ передаются.
Тот путь уже прогрет warm-batch'ем (referer=), origin -- ТОЛЬКО browser-режим (см.
fetch_detail docstring): в curl-режиме передача origin стоила бы лишней полной
навигации без реального смысла (browser_fetcher там вообще не участвует).
"""
snapshot = _make_snapshot(1, absolute=True)
db = _mock_db(snapshot)
runs = MagicMock()
mock_fetch = AsyncMock(return_value=MagicMock())
fake_settings = _fake_settings(avito_detail_backfill_use_curl=True)
with (
patch(_SETTINGS, fake_settings),
patch(_SESSION),
patch(_SCRAPER),
patch(_RUNS, runs),
patch(_BROWSER_FETCHER) as mock_bf_cls,
patch(_FETCH, mock_fetch),
patch(
"app.tasks.avito_detail_backfill.build_warmed_session",
AsyncMock(return_value=AsyncMock()),
),
patch(_SAVE, return_value=True),
patch(_SLEEP, new_callable=AsyncMock),
):
await run_avito_detail_backfill(
db, run_id=208, params={"batch_size": 10, "budget_sec": 3600}
)
mock_bf_cls.assert_not_called()
mock_fetch.assert_called_once()
_, kwargs = mock_fetch.call_args
assert kwargs.get("origin") is None
assert kwargs.get("browser_referer") is None

View file

@ -746,6 +746,16 @@ _contexts: dict[str, object] = {}
# Живёт только при reuse_context=True: без переиспользуемого context'а держать
# вкладку негде и незачем.
_anchor_pages: dict[str, object] = {}
# provider -> origin, НА КОТОРОМ якорная вкладка стоит СЕЙЧАС. Без этой памяти
# _ensure_anchor_page отдавала любую живую вкладку как годную, не глядя на
# запрошенный origin, — а origin у обоих caller'ов выводится ИЗ URL карточки и
# меняется вместе с городом (Авито: /ekaterinburg/... vs /verhnyaya_pyshma/...;
# Домклик: ekaterinburg.domclick.ru vs иной поддомен). Тогда Referer целевой
# навигации называл выдачу, которую этот контекст НИКОГДА не открывал: ни куки
# её, ни тайминга — площадка видит заявленный переход без единого следа. Ровно
# то, что #3258 запретил делать фолбэкам якорного поиска. Origin сменился —
# вкладку переоткрываем на новый.
_anchor_origins: dict[str, str] = {}
_page_counters: dict[str, int] = {} # provider → страниц с момента launch'а
_locks: dict[str, asyncio.Lock] = {} # provider → Lock (весь доступ к инстансу)
_retry_tasks: dict[str, asyncio.Task[None]] = {} # provider → фоновая retry-задача
@ -867,11 +877,28 @@ async def _ensure_anchor_page(provider: str, origin: str) -> bool:
page = _anchor_pages.get(provider)
if page is not None:
try:
if not page.is_closed(): # type: ignore[attr-defined]
return True
alive = not page.is_closed() # type: ignore[attr-defined]
except Exception:
pass
alive = False
# Живая вкладка годится ТОЛЬКО если стоит на запрошенном origin: иначе
# Referer назовёт выдачу, которой в этом контексте не открывали (см.
# комментарий у _anchor_origins). Чужой origin — закрываем и поднимаем
# заново; это дороже на одну навигацию, но честно.
if alive and _anchor_origins.get(provider) == origin:
return True
if alive:
logger.info(
"tradein-browser[%s]: якорь стоял на %s, запрошен %s — переоткрываем",
provider,
_anchor_origins.get(provider),
origin,
)
try:
await page.close() # type: ignore[attr-defined]
except Exception:
pass
_anchor_pages.pop(provider, None)
_anchor_origins.pop(provider, None)
try:
page = await ctx.new_page() # type: ignore[attr-defined]
@ -907,6 +934,7 @@ async def _ensure_anchor_page(provider: str, origin: str) -> bool:
return False
_anchor_pages[provider] = page
_anchor_origins[provider] = origin
logger.info("tradein-browser[%s]: якорная вкладка открыта на %s", provider, origin)
return True
@ -921,6 +949,7 @@ async def _close_reusable_context(provider: str) -> None:
# Якорная вкладка принадлежит этому context'у и умирает вместе с ним — важно
# снять ссылку ДО close(), иначе следующий запрос возьмёт закрытую страницу.
_anchor_pages.pop(provider, None)
_anchor_origins.pop(provider, None)
ctx = _contexts.pop(provider, None)
if ctx is None:
return

View file

@ -0,0 +1,140 @@
"""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

View file

@ -467,6 +467,30 @@ def _is_detail_not_found(html: str) -> bool:
return "страница не найдена" in head or "ошибка 404" in head
def _serp_origin_for(card_url: str) -> str | None:
"""URL городской SERP-выдачи для карточки — same-site якорь origin/referer (#3251).
Из '<scheme>://<host>/<city>/<category>/<slug>...' достаёт первые ДВА сегмента
пути (город, категория) и добавляет фиксированный хвост '/prodam' так Avito
формирует URL списка объявлений о продаже. Хост берётся из самого card_url (НЕ
хардкодится 'www.avito.ru' сайдкар мог прийти с мобильного/иного поддомена).
Путь короче двух сегментов, url без scheme/netloc, либо не распарсился
возвращает None: caller тогда просто не передаёт origin/referer дальше
(поведение как до #3251, ровно один голый goto(url)).
"""
try:
parsed = urlparse(card_url)
except ValueError:
return None
if not parsed.scheme or not parsed.netloc:
return None
segments = [s for s in parsed.path.split("/") if s]
if len(segments) < 2:
return None
return f"{parsed.scheme}://{parsed.netloc}/{segments[0]}/{segments[1]}/prodam"
# ── fetch_detail ──────────────────────────────────────────────────────────────
async def fetch_detail(
item_url: str,
@ -476,17 +500,25 @@ async def fetch_detail(
referer: str | None = None,
reconnect_on_block: bool = True,
config: ScraperConfig | None = None,
origin: str | None = None,
browser_referer: str | None = None,
) -> DetailEnrichment:
"""GET <avito>/{item_url} → parse HTML via selectolax → DetailEnrichment.
Если browser_fetcher передан использует браузерный fetch (browser mode).
Если cffi_session не передана создаёт новую (impersonate=DEFAULT_IMPERSONATE).
referer если задан, шлётся в Referer-заголовке detail-GET'а (warm-batch #1551:
referer = URL avito-search страницы, на которой прогрелась сессия).
referer = URL avito-search страницы, на которой прогрелась сессия). Это ТОЛЬКО
curl-путь НЕ путать с browser_referer ниже, они разных фетчеров и разных полей.
reconnect_on_block если False, 403/firewall поднимает AvitoBlockedError СРАЗУ
(без холодных reconnect'ов — ими займётся outer-loop через rebuild+rewarm прогретой
сессии); 429 после short-retry поднимает AvitoRateLimitedError. Дефолт True
сохраняет старое поведение own-session / shared-session (run_avito_city_sweep) путей.
origin / browser_referer (#3180, #3251) — ТОЛЬКО browser-режим: same-site SERP-якорь,
на который сайдкар зайдёт ПЕРЕД карточкой (см. BrowserFetcher.fetch), и Referer
целевой навигации. Рецепт зеркалит DomClick (#3247): переход на карточку логически
и есть переход из выдачи. None (дефолт) оба поля не кладутся в /fetch payload,
поведение остальных вызовов (city_sweep, pipeline, admin) не меняется НИ НА БАЙТ.
Raises:
httpx.HTTPError если status != 200 (через raise_for_status-like).
ValueError если item_id не извлечён из HTML.
@ -503,7 +535,7 @@ async def fetch_detail(
# дальше, НЕ трогая curl.
full_url = item_url if item_url.startswith("http") else urljoin(AVITO_BASE, item_url)
try:
html = await browser_fetcher.fetch(full_url)
html = await browser_fetcher.fetch(full_url, origin=origin, referer=browser_referer)
except (AvitoBlockedError, AvitoRateLimitedError):
raise
except Exception as exc: