feat(tradein/browser): на выдачу площадки заходить через настоящий поиск Яндекса, а не голым goto
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
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 / changes (pull_request) Successful in 11s
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Successful in 1m27s

Якорная вкладка (#3244) открывала страницу выдачи Домклика без источника
перехода вообще. Передача referer на карточку (#3250) воспроизвела второй шаг
человеческого пути, но первый — «пришёл из поиска» — оставался невоспроизведённым.

Эталон — ручная сессия 29.08 через узел 10:
    yandex.ru → клик по результату → выдача Домклика (Referer https://yandex.ru/)
    → клик → карточка (Referer = URL выдачи)

Теперь якорная вкладка идёт на yandex.ru/search, ищет среди результатов ссылку на
хост origin и КЛИКАЕТ по ней. Referer уходит не потому, что мы его подставили, а
потому, что переход действительно был.

Яндекс на мобильных прокси капризен — наблюдалась капча вживую, — поэтому
предусмотрены три контролируемых исхода, и ни один не роняет прогон:
  - капча на выдаче либо упавшая навигация на Яндекс → прежний прямой goto(origin),
    без referer, поведение до этой правки байт в байт;
  - ссылки на хост в результатах нет → goto(origin, referer="https://yandex.ru/"):
    визит на Яндекс был настоящий, заголовок честный;
  - клик увёл не туда (редирект-обёртка Яндекса) → goto(origin, referer=URL выдачи),
    точный адрес, а не общий yandex.ru.

Включено ТОЛЬКО для Домклика (BROWSER_ANCHOR_VIA_SEARCH=domclick по умолчанию);
Авито, Циан и Яндекс не трогаем — их проверять отдельно по #3251. Пустое значение
переменной возвращает сегодняшнее поведение целиком.

Тесты: 196 passed в сайдкаре против 191 — пять сценариев: успешный клик, откат по
капче, откат по отсутствию ссылки, провайдер вне списка, падение навигации.
This commit is contained in:
bot-backend 2026-08-29 23:26:21 +03:00
parent e523c8949c
commit a107ab9950
2 changed files with 409 additions and 4 deletions

View file

@ -102,6 +102,24 @@ Per-provider модель (#1793):
AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты
все указывали на закрытые mobileproxy-аккаунты все указывали на закрытые mobileproxy-аккаунты
(407/connection refused, проверено вживую #2613). (407/connection refused, проверено вживую #2613).
BROWSER_ANCHOR_VIA_SEARCH CSV провайдеров, для которых якорная вкладка
(_ensure_anchor_page) заходит на origin ЧЕРЕЗ реальный
поиск yandex.ru, а не голым goto (#3251). Ручная сессия
29.08.2026 показала эталонный человеческий путь:
yandex.ru клик по результату выдача с Referer
yandex.ru. Дефолт "domclick" авито/циан/яндекс
проверяются отдельно в #3251, не включать здесь.
Пустая строка = выключено везде (полный откат к
прежнему поведению). Провайдер без соответствующего
BROWSER_ANCHOR_SEARCH_QUERY_{PROVIDER} поиск тоже не
делает см. ниже.
BROWSER_ANCHOR_SEARCH_QUERY_{PROVIDER} поисковый запрос на yandex.ru для
провайдера, PROVIDER в upper-case (DOMCLICK). Код-дефолт
для domclick "домклик екатеринбург квартиры вторичка"
(реалистичный запрос покупателя, ведёт на нужный хост
одной из первых органических ссылок). Провайдер в
BROWSER_ANCHOR_VIA_SEARCH без запроса (нет ни env, ни
код-дефолта) заход через поиск для него не делается.
ENVIRONMENT "production" в прод-контейнерах, иначе "dev" (дефолт). ENVIRONMENT "production" в прод-контейнерах, иначе "dev" (дефолт).
#2616 шаг 1: прод + нет НИ override в теле, НИ #2616 шаг 1: прод + нет НИ override в теле, НИ
@ -123,7 +141,7 @@ import base64
import logging import logging
import os import os
from collections.abc import Callable, Mapping from collections.abc import Callable, Mapping
from urllib.parse import urlparse from urllib.parse import quote, urlparse
from aiohttp import web from aiohttp import web
@ -242,6 +260,199 @@ _BLOCKED_TYPES: frozenset[str] = _parse_block_types(
# прежде чем открыть новую страницу. 0 = без пейсинга. # прежде чем открыть новую страницу. 0 = без пейсинга.
BROWSER_MIN_PAGE_INTERVAL_S: float = float(os.environ.get("BROWSER_MIN_PAGE_INTERVAL_S", "2.0")) BROWSER_MIN_PAGE_INTERVAL_S: float = float(os.environ.get("BROWSER_MIN_PAGE_INTERVAL_S", "2.0"))
# Провайдеры, для которых якорная вкладка (#3251) заходит на origin ЧЕРЕЗ реальный
# поиск yandex.ru, а не голым goto. Дефолт "domclick" — авито/циан/яндекс проверяются
# отдельно (#3251), не расширять этот список без замера на них. Пустая строка env =
# выключено везде — полный откат на поведение до #3251.
_ANCHOR_VIA_SEARCH_PROVIDERS: frozenset[str] = frozenset(
p.strip().lower()
for p in os.environ.get("BROWSER_ANCHOR_VIA_SEARCH", "domclick").split(",")
if p.strip()
)
# Код-дефолты поискового запроса per-provider, если BROWSER_ANCHOR_SEARCH_QUERY_
# {PROVIDER} не задан. Запрос подобран так, чтобы органическая выдача Яндекса
# реально содержала ссылку на нужный хост, а не только рекламу — проверено вживую
# 29.08.2026 ручной сессией с этим же запросом.
_ANCHOR_SEARCH_QUERY_DEFAULT_BY_PROVIDER: dict[str, str] = {
"domclick": "домклик екатеринбург квартиры вторичка",
}
# Базовый URL поиска и Referer "визита без клика" — если ссылку в выдаче найти не
# удалось, заход на yandex.ru всё равно РЕАЛЬНО был, поэтому Referer честно ставим
# на сам yandex.ru, а не молчим об источнике вовсе (#3251).
_YANDEX_SEARCH_URL: str = "https://yandex.ru/search/"
_YANDEX_REFERER: str = "https://yandex.ru/"
# Маркеры SmartCaptcha Яндекса. Мобильные прокси иногда ловят капчу на выдаче —
# наблюдалось вживую 29.08.2026. Решать капчу нечем и незачем: детектируем и тихо
# откатываемся на прежнее поведение, прогон падать из-за недоступности живого
# поиска не должен.
_YANDEX_CAPTCHA_MARKERS: tuple[str, ...] = (
"smartcaptcha",
"showcaptcha",
"подтвердите, что запросы отправляли вы",
)
def _anchor_search_query(provider: str) -> str | None:
"""Запрос для захода на yandex.ru перед origin данного провайдера.
Приоритет: BROWSER_ANCHOR_SEARCH_QUERY_{PROVIDER} (upper-case) код-дефолт
из _ANCHOR_SEARCH_QUERY_DEFAULT_BY_PROVIDER None. None означает "заход через
поиск для этого провайдера не делается" — даже если он есть в
_ANCHOR_VIA_SEARCH_PROVIDERS, без запроса открывать нечего.
"""
default = _ANCHOR_SEARCH_QUERY_DEFAULT_BY_PROVIDER.get(provider)
return os.environ.get(f"BROWSER_ANCHOR_SEARCH_QUERY_{provider.upper()}", default)
def _looks_like_yandex_captcha(html: str, current_url: str) -> bool:
"""True, если текущая страница — SmartCaptcha Яндекса, а не выдача.
Проверяем и URL (редирект на /showcaptcha), и текст разметки Яндекс
показывает капчу и как отдельную страницу, и как встроенный блок поверх
выдачи в зависимости от типа запроса/прокси.
"""
if "/showcaptcha" in current_url.lower():
return True
low_html = html.lower()
return any(marker in low_html for marker in _YANDEX_CAPTCHA_MARKERS)
async def _find_serp_result_link(page: object, hostname: str) -> object | None:
"""Ищет в выдаче Яндекса ссылку на ``hostname`` среди всех ``a[href]``.
Яндекс часто отдаёт результат как редирект-обёртку (``/redir/...``,
``yandex.ru/clck/...``) совпадение по одному ``href`` не всегда сработает,
поэтому проверяем ещё и видимый текст ссылки (Яндекс показывает реальный
хост зелёной строкой под заголовком результата).
"""
if not hostname:
return None
try:
candidates = await page.query_selector_all("a[href]") # type: ignore[attr-defined]
except Exception:
return None
for element in candidates:
try:
href = await element.get_attribute("href") # type: ignore[attr-defined]
text = await element.text_content() # type: ignore[attr-defined]
except Exception:
continue
haystack = f"{href or ''} {text or ''}".lower()
if hostname.lower() in haystack:
return element
return None
async def _navigate_anchor_via_search(page: object, provider: str, origin: str) -> bool:
"""Пытается открыть ``origin`` переходом со страницы выдачи yandex.ru вместо
голого goto так это делает реальный пользователь (#3251).
Эталон ручная сессия 29.08.2026: yandex.ru клик по результату выдача
Домклика с Referer "https://yandex.ru/" клик по карточке Referer = URL
выдачи. Второй шаг уже воспроизведён передачей referer на целевую навигацию
(#3250), здесь — первый: заход на origin НЕ напрямую, а через поиск.
Возвращает True, если ``origin`` уже открыт успешно через клик по
результату, либо через один из контролируемых фолбэков с честным referer
(ссылка не найдена / клик увёл не туда). Вызывающему в этом случае ничего
дополнительно делать не нужно.
Возвращает False, если заход через поиск не делается вовсе (провайдер не в
BROWSER_ANCHOR_VIA_SEARCH, запроса для него нет) либо не удался целиком
капча на выдаче или упавшая навигация на yandex.ru. В обоих случаях
прогон НЕ должен падать: вызывающий откатывается на прежний прямой
goto(origin) без referer, ровно как до #3251.
"""
if provider not in _ANCHOR_VIA_SEARCH_PROVIDERS:
return False
query = _anchor_search_query(provider)
if query is None:
return False
hostname = urlparse(origin).hostname or ""
search_url = f"{_YANDEX_SEARCH_URL}?text={quote(query)}"
try:
await page.goto( # type: ignore[attr-defined]
search_url, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded"
)
except Exception as exc:
logger.warning(
"tradein-browser[%s]: заход на yandex.ru для якоря не удался (%s) — "
"откат на прямой goto(origin)",
provider,
type(exc).__name__,
)
return False
html = await page.content() # type: ignore[attr-defined]
serp_url = str(getattr(page, "url", search_url))
if _looks_like_yandex_captcha(html, serp_url):
logger.warning(
"tradein-browser[%s]: капча на выдаче Яндекса — откат на прямой goto(origin)",
provider,
)
return False
link = await _find_serp_result_link(page, hostname)
if link is None:
logger.info(
"tradein-browser[%s]: ссылка на %s не найдена в выдаче yandex.ru — "
"goto(origin) с referer=%s",
provider,
hostname,
_YANDEX_REFERER,
)
await page.goto( # type: ignore[attr-defined]
origin,
timeout=BROWSER_NAV_TIMEOUT_MS,
wait_until="domcontentloaded",
referer=_YANDEX_REFERER,
)
return True
try:
await link.click(timeout=BROWSER_NAV_TIMEOUT_MS) # type: ignore[attr-defined]
await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined]
except Exception as exc:
logger.warning(
"tradein-browser[%s]: клик по ссылке в выдаче не удался (%s) — "
"goto(origin) с referer=%s",
provider,
type(exc).__name__,
_YANDEX_REFERER,
)
await page.goto( # type: ignore[attr-defined]
origin,
timeout=BROWSER_NAV_TIMEOUT_MS,
wait_until="domcontentloaded",
referer=_YANDEX_REFERER,
)
return True
landed_host = urlparse(str(getattr(page, "url", ""))).hostname or ""
if landed_host != hostname:
# Клик состоялся, но увёл не туда (редирект-обёртка Яндекса подвела) — визит
# на саму выдачу РЕАЛЬНО был, поэтому referer = точный URL выдачи, а не общий
# yandex.ru (в отличие от ветки "ссылка не найдена" выше).
logger.warning(
"tradein-browser[%s]: клик увёл на %s вместо %s — goto(origin) с "
"referer=URL выдачи",
provider,
landed_host,
hostname,
)
await page.goto( # type: ignore[attr-defined]
origin,
timeout=BROWSER_NAV_TIMEOUT_MS,
wait_until="domcontentloaded",
referer=serp_url,
)
return True
def _resolve_min_interval( def _resolve_min_interval(
provider: str, provider: str,
@ -625,6 +836,13 @@ async def _ensure_anchor_page(provider: str, origin: str) -> bool:
try: try:
page = await ctx.new_page() # type: ignore[attr-defined] page = await ctx.new_page() # type: ignore[attr-defined]
await _apply_resource_block(page) await _apply_resource_block(page)
# #3251: за origin для включённых провайдеров идём через реальный поиск
# yandex.ru, а не голым goto — так это делает человек. landed_via_search
# уже покрывает и успех, и контролируемые фолбэки со своим referer; False
# означает "поиск не делается или не удался целиком" — старое поведение
# ниже не меняется ни на байт.
landed_via_search = await _navigate_anchor_via_search(page, provider, origin)
if not landed_via_search:
await page.goto( # type: ignore[attr-defined] await page.goto( # type: ignore[attr-defined]
origin, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded" origin, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded"
) )

View file

@ -0,0 +1,187 @@
"""test_server_anchor_search.py — якорь заходит на origin ЧЕРЕЗ поиск yandex.ru (#3251).
Контекст: до этой правки якорная вкладка (_ensure_anchor_page, #3244) открывала
origin голым goto без источника перехода вообще. Ручная сессия 29.08.2026
показала эталонный человеческий путь: yandex.ru поиск клик по результату
выдача Домклика (Referer "https://yandex.ru/"). _navigate_anchor_via_search
воспроизводит именно этот первый шаг; переход дальше по карточке (referer на
целевую навигацию) уже сделан в #3250 и здесь не трогается.
camoufox НЕ запускается context/page/link-элемент подделаны.
Запуск (из tradein-mvp/browser/, venv со aiohttp см. CLAUDE.md сайдкара)::
python -m pytest test_server_anchor_search.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", _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)
_ORIGIN = "https://ekaterinburg.domclick.ru/pokupka/kvartiry/vtorichka"
_HOSTNAME = "ekaterinburg.domclick.ru"
@pytest.fixture(autouse=True)
def _reset_state() -> None:
server._contexts = {}
server._anchor_pages = {}
class _FakeLink:
"""Поддельный результат выдачи: get_attribute/text_content/click как ElementHandle."""
def __init__(self, page: "_FakeAnchorPage", host: str, land_host: str | None = None) -> None:
self._page = page
self._host = host
self._land_host = land_host or host
self.clicked = 0
async def get_attribute(self, name: str) -> str:
return f"https://{self._host}/some/redir-wrapper"
async def text_content(self) -> str:
return self._host
async def click(self, timeout: int | None = None) -> None:
self.clicked += 1
self._page.url = f"https://{self._land_host}/landed"
class _FakeAnchorPage:
"""Поддельная page: фиксирует goto (url+referer), эмулирует выдачу/капчу/клик."""
def __init__(
self,
link_host: str | None = None,
land_host: str | None = None,
captcha: bool = False,
fail_search_nav: bool = False,
) -> None:
self.goto_calls: list[dict[str, Any]] = []
self.url = ""
self.closed = 0
self._link_host = link_host
self._land_host = land_host
self._captcha = captcha
self._fail_search_nav = fail_search_nav
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:
if self._fail_search_nav and "yandex.ru/search" in url:
raise TimeoutError("yandex.ru недоступен")
self.goto_calls.append({"url": url, "referer": kwargs.get("referer")})
self.url = url
async def wait_for_timeout(self, ms: int) -> None:
return None
async def content(self) -> str:
if "yandex.ru/search" in self.url:
if self._captcha:
return "<html>SmartCaptcha: подтвердите, что запросы отправляли вы</html>"
return "<html>выдача Яндекса</html>"
return "<html>карточка<script>window.__SSR_STATE__={}</script></html>"
async def query_selector_all(self, selector: str) -> list[_FakeLink]:
if self._link_host is None:
return []
return [_FakeLink(self, self._link_host, self._land_host)]
async def close(self) -> None:
self.closed += 1
class _FakeAnchorContext:
"""Поддельный BrowserContext: new_page() отдаёт заранее сконструированную page."""
def __init__(self, page: _FakeAnchorPage) -> None:
self._page = page
self.pages: list[_FakeAnchorPage] = []
async def new_page(self) -> _FakeAnchorPage:
self.pages.append(self._page)
return self._page
def _run_ensure_anchor(provider: str, origin: str, page: _FakeAnchorPage) -> bool:
server._contexts[provider] = _FakeAnchorContext(page)
return asyncio.run(server._ensure_anchor_page(provider, origin))
def test_anchor_via_search_visits_yandex_and_clicks_result() -> None:
"""domclick включён по умолчанию: заход на yandex.ru → клик по ссылке на хост."""
page = _FakeAnchorPage(link_host=_HOSTNAME)
anchored = _run_ensure_anchor("domclick", _ORIGIN, page)
assert anchored is True
assert server._anchor_pages["domclick"] is page
assert any("yandex.ru/search" in c["url"] for c in page.goto_calls), "должен зайти на yandex.ru"
# Клик по ссылке сам довёл до хоста — повторного goto(origin) после него НЕ было.
assert not any(c["url"] == _ORIGIN for c in page.goto_calls)
assert page.url.startswith(f"https://{_HOSTNAME}/")
def test_captcha_falls_back_to_plain_goto_without_referer() -> None:
"""Капча на выдаче → тихий откат на прежнее поведение, без исключения."""
page = _FakeAnchorPage(link_host=_HOSTNAME, captcha=True)
anchored = _run_ensure_anchor("domclick", _ORIGIN, page)
assert anchored is True
assert server._anchor_pages["domclick"] is page
assert any("yandex.ru/search" in c["url"] for c in page.goto_calls)
origin_calls = [c for c in page.goto_calls if c["url"] == _ORIGIN]
assert len(origin_calls) == 1
assert origin_calls[0]["referer"] is None, "прежнее поведение — без referer вообще"
def test_no_link_in_serp_falls_back_with_yandex_referer() -> None:
"""Ссылки на хост в выдаче нет → goto(origin, referer=yandex.ru)."""
page = _FakeAnchorPage(link_host=None)
anchored = _run_ensure_anchor("domclick", _ORIGIN, page)
assert anchored is True
origin_calls = [c for c in page.goto_calls if c["url"] == _ORIGIN]
assert len(origin_calls) == 1
assert origin_calls[0]["referer"] == "https://yandex.ru/"
def test_provider_not_in_allowlist_skips_search_entirely() -> None:
"""avito не в BROWSER_ANCHOR_VIA_SEARCH (дефолт только domclick) — поведение прежнее."""
origin = "https://www.avito.ru/ekaterinburg/kvartiry"
page = _FakeAnchorPage(link_host="www.avito.ru")
anchored = _run_ensure_anchor("avito", origin, page)
assert anchored is True
assert page.goto_calls == [{"url": origin, "referer": None}], "поиска быть не должно вовсе"
def test_yandex_navigation_failure_falls_back_and_anchor_still_raised() -> None:
"""Таймаут захода на yandex.ru → откат на прямой goto(origin), якорь поднят."""
page = _FakeAnchorPage(link_host=_HOSTNAME, fail_search_nav=True)
anchored = _run_ensure_anchor("domclick", _ORIGIN, page)
assert anchored is True
assert server._anchor_pages["domclick"] is page
assert page.goto_calls == [{"url": _ORIGIN, "referer": None}]