diff --git a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py index e5c3452a..f7248f4d 100644 --- a/tradein-mvp/backend/app/tasks/avito_detail_backfill.py +++ b/tradein-mvp/backend/app/tasks/avito_detail_backfill.py @@ -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, diff --git a/tradein-mvp/backend/tests/scrapers/test_3251_avito_fetch_detail_origin.py b/tradein-mvp/backend/tests/scrapers/test_3251_avito_fetch_detail_origin.py new file mode 100644 index 00000000..667d22ed --- /dev/null +++ b/tradein-mvp/backend/tests/scrapers/test_3251_avito_fetch_detail_origin.py @@ -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 = '
."""
+ data = _extract_json('{"result": {"items": []}}')
+ assert data == {"result": {"items": []}}
+
+
+def test_garbage_without_markers_is_a_parse_error_not_a_block() -> None:
+ """Мусор без маркеров — ValueError, а не блок: узел за это банить нельзя."""
+ with pytest.raises(ValueError, match="JSON"):
+ _extract_json("что-то пошло не так")
diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py
index c1fbf0ea..8b03f042 100644
--- a/tradein-mvp/browser/server.py
+++ b/tradein-mvp/browser/server.py
@@ -747,6 +747,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-задача
@@ -868,11 +878,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]
@@ -908,6 +935,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
@@ -922,6 +950,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
diff --git a/tradein-mvp/browser/test_server_anchor_origin_switch.py b/tradein-mvp/browser/test_server_anchor_origin_switch.py
new file mode 100644
index 00000000..f68db8da
--- /dev/null
+++ b/tradein-mvp/browser/test_server_anchor_origin_switch.py
@@ -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
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py
index 02144cfd..731cc468 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/detail.py
@@ -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).
+
+ Из '://///...' достаёт первые ДВА сегмента
+ пути (город, категория) и добавляет фиксированный хвост '/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 /{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:
diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py
index ac458a2a..441ba041 100644
--- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py
+++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/domclick/serp.py
@@ -106,8 +106,32 @@ def _extract_json(html: str) -> dict[str, Any]:
DomClickBlockedError: если ответ содержит QRATOR/captcha маркеры.
ValueError: если JSON не найден или не является dict.
"""
- # Сканируем ВЕСЬ ответ (а не только первые 4096B): block-маркер может
- # стоять за пределами head в крупных challenge-страницах.
+ # Порядок важен: СНАЧАЛА пробуем разобрать JSON, и только если не вышло — ищем
+ # маркеры блока (#3267). Раньше было наоборот, и маркеры искались подстрокой по
+ # ВСЕМУ телу, включая пользовательские описания объявлений. Прод 30.08, прогон
+ # 5363: продавец написал в описании квартиры «Система защиты от протечек» —
+ # подстрока совпала с маркером «система защиты», ответ на 107 КБ с двадцатью
+ # валидными офферами был объявлен блок-страницей, свип оборвал все корзины и
+ # забанил живой узел пула на 6 часов. Совпасть так же могут «captcha» и «qrator»
+ # — в тексте объявления, в имени агентства, в ссылке.
+ #
+ # Разобранный JSON нужной формы блок-страницей быть не может: QRATOR отдаёт HTML.
+ # Поэтому валидный разбор — сам по себе доказательство, что блока нет, и никакие
+ # маркеры его переспорить не должны. Тот же порядок уже применён в detail.py
+ # (маркеры смотрят ТОЛЬКО когда __SSR_STATE__ не найден).
+ start = html.find("{")
+ end = html.rfind("}")
+ if start != -1 and end != -1 and end > start:
+ try:
+ data = json.loads(html[start : end + 1])
+ except json.JSONDecodeError:
+ data = None
+ if isinstance(data, dict):
+ return data # type: ignore[return-value]
+
+ # JSON не разобрался — вот теперь проверяем, не блок ли это. Сканируем ВЕСЬ ответ
+ # (а не только первые 4096 Б): block-маркер может стоять за пределами head в
+ # крупных challenge-страницах.
html_lower = html.lower()
if any(m in html_lower for m in DOMCLICK_BLOCK_MARKERS):
raise DomClickBlockedError(
@@ -115,15 +139,9 @@ def _extract_json(html: str) -> dict[str, Any]:
f"(markers checked: {DOMCLICK_BLOCK_MARKERS[:2]})"
)
- start = html.find("{")
- end = html.rfind("}")
if start == -1 or end == -1 or end <= start:
raise ValueError(f"No JSON object found in BFF response (len={len(html)})")
-
- data = json.loads(html[start : end + 1])
- if not isinstance(data, dict):
- raise ValueError(f"BFF response JSON is not a dict: {type(data)}")
- return data # type: ignore[return-value]
+ raise ValueError(f"BFF response is not a JSON dict (len={len(html)})")
# ── URL builders ──────────────────────────────────────────────────────────────