From a107ab995072b57a88a70f0a61c8e0dbd57e05b8 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Sat, 29 Aug 2026 23:26:21 +0300 Subject: [PATCH] =?UTF-8?q?feat(tradein/browser):=20=D0=BD=D0=B0=20=D0=B2?= =?UTF-8?q?=D1=8B=D0=B4=D0=B0=D1=87=D1=83=20=D0=BF=D0=BB=D0=BE=D1=89=D0=B0?= =?UTF-8?q?=D0=B4=D0=BA=D0=B8=20=D0=B7=D0=B0=D1=85=D0=BE=D0=B4=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20=D0=BD=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D0=BE=D1=8F=D1=89=D0=B8=D0=B9=20=D0=BF=D0=BE=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=20=D0=AF=D0=BD=D0=B4=D0=B5=D0=BA=D1=81=D0=B0,=20=D0=B0?= =?UTF-8?q?=20=D0=BD=D0=B5=20=D0=B3=D0=BE=D0=BB=D1=8B=D0=BC=20goto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Якорная вкладка (#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 — пять сценариев: успешный клик, откат по капче, откат по отсутствию ссылки, провайдер вне списка, падение навигации. --- tradein-mvp/browser/server.py | 226 +++++++++++++++++- .../browser/test_server_anchor_search.py | 187 +++++++++++++++ 2 files changed, 409 insertions(+), 4 deletions(-) create mode 100644 tradein-mvp/browser/test_server_anchor_search.py diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index 2c9f7b92..9953bfa4 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -102,6 +102,24 @@ Per-provider модель (#1793): AVITO_PROXY_URL/CIAN_PROXY_URL/YANDEX_PROXY_URL сняты — все указывали на закрытые mobileproxy-аккаунты (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" (дефолт). #2616 шаг 1: прод + нет НИ override в теле, НИ @@ -123,7 +141,7 @@ import base64 import logging import os from collections.abc import Callable, Mapping -from urllib.parse import urlparse +from urllib.parse import quote, urlparse from aiohttp import web @@ -242,6 +260,199 @@ _BLOCKED_TYPES: frozenset[str] = _parse_block_types( # прежде чем открыть новую страницу. 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( provider: str, @@ -625,9 +836,16 @@ async def _ensure_anchor_page(provider: str, origin: str) -> bool: try: page = await ctx.new_page() # type: ignore[attr-defined] await _apply_resource_block(page) - await page.goto( # type: ignore[attr-defined] - origin, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded" - ) + # #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] + origin, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded" + ) await page.wait_for_timeout(FETCH_JSON_SETTLE_MS) # type: ignore[attr-defined] except Exception as exc: logger.warning( diff --git a/tradein-mvp/browser/test_server_anchor_search.py b/tradein-mvp/browser/test_server_anchor_search.py new file mode 100644 index 00000000..1f5cd78d --- /dev/null +++ b/tradein-mvp/browser/test_server_anchor_search.py @@ -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 "SmartCaptcha: подтвердите, что запросы отправляли вы" + return "выдача Яндекса" + return "карточка" + + 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}] -- 2.45.3