diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index 9953bfa4..579ad451 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -278,11 +278,11 @@ _ANCHOR_SEARCH_QUERY_DEFAULT_BY_PROVIDER: dict[str, str] = { "domclick": "домклик екатеринбург квартиры вторичка", } -# Базовый URL поиска и Referer "визита без клика" — если ссылку в выдаче найти не -# удалось, заход на yandex.ru всё равно РЕАЛЬНО был, поэтому Referer честно ставим -# на сам yandex.ru, а не молчим об источнике вовсе (#3251). +# Базовый URL поиска. Константы «Referer визита без клика» здесь СОЗНАТЕЛЬНО нет: +# подставлять https://yandex.ru/ по факту одного лишь захода на поиск — это заявить +# переход, которого не было. Не нашли ссылку / клик увёл не туда → идём на origin +# вообще без Referer (#3251). _YANDEX_SEARCH_URL: str = "https://yandex.ru/search/" -_YANDEX_REFERER: str = "https://yandex.ru/" # Маркеры SmartCaptcha Яндекса. Мобильные прокси иногда ловят капчу на выдаче — # наблюдалось вживую 29.08.2026. Решать капчу нечем и незачем: детектируем и тихо @@ -346,31 +346,43 @@ async def _find_serp_result_link(page: object, hostname: str) -> object | None: return None -async def _navigate_anchor_via_search(page: object, provider: str, origin: str) -> bool: +async def _navigate_anchor_via_search(page: object, provider: str, origin: str) -> object | None: """Пытается открыть ``origin`` переходом со страницы выдачи yandex.ru вместо голого goto — так это делает реальный пользователь (#3251). - Эталон — ручная сессия 29.08.2026: yandex.ru → клик по результату → выдача - Домклика с Referer "https://yandex.ru/" → клик по карточке → Referer = URL - выдачи. Второй шаг уже воспроизведён передачей referer на целевую навигацию - (#3250), здесь — первый: заход на origin НЕ напрямую, а через поиск. + ПРИНЦИП (не оптимизировать обратно на "поставить правдоподобный Referer"): + либо переход с Яндекса на origin случился НАСТОЯЩИЙ, либо мы честно об этом + молчим. До этой правки (#3258) два фолбэка — "ссылка не найдена" и "клик + увёл не туда" — подставляли Referer (yandex.ru / URL выдачи) страницам, на + которые фактически НЕ переходили с Яндекса. Заявлять переход, которого не + было, нельзя. Теперь оба случая возвращают ``None`` без единого referer, и + вызывающий делает обычный ``goto(origin)`` без referer — ровно как до + #3251, когда захода через поиск не существовало вовсе. - Возвращает True, если ``origin`` уже открыт — успешно через клик по - результату, либо через один из контролируемых фолбэков с честным referer - (ссылка не найдена / клик увёл не туда). Вызывающему в этом случае ничего - дополнительно делать не нужно. + Возвращает страницу, на которой origin реально открыт после клика: + - ``page`` (тот же объект) — клик по результату выдачи навигировал текущую + вкладку; + - НОВУЮ страницу — клик открыл origin в отдельной вкладке (частый случай: + ссылки в выдаче Яндекса нередко имеют ``target="_blank"``). В этом случае + именно новая страница несёт на себе результат клика, поэтому она + становится якорной, а исходная вкладка с Яндексом закрывается (держать + две вкладки на один провайдер незачем и дороже по памяти). Вызывающий + обязан подменить свою ссылку на ``page`` возвращённой — и применить к + ней ``_apply_resource_block`` заново, т.к. новая вкладка ничего не + наследует от исходной. - Возвращает False, если заход через поиск не делается вовсе (провайдер не в - BROWSER_ANCHOR_VIA_SEARCH, запроса для него нет) либо не удался целиком — - капча на выдаче или упавшая навигация на yandex.ru. В обоих случаях - прогон НЕ должен падать: вызывающий откатывается на прежний прямой - goto(origin) без referer, ровно как до #3251. + Возвращает ``None``, если настоящего перехода на origin не случилось: + поиск выключен для провайдера / нет запроса, капча на выдаче, упавшая + навигация на yandex.ru, ссылки в выдаче нет, клик не удался, либо клик + (в текущей вкладке или в новой) увёл не на тот хост. Прогон в любом из + этих случаев НЕ должен падать — вызывающий откатывается на прямой + goto(origin) без referer. """ if provider not in _ANCHOR_VIA_SEARCH_PROVIDERS: - return False + return None query = _anchor_search_query(provider) if query is None: - return False + return None hostname = urlparse(origin).hostname or "" search_url = f"{_YANDEX_SEARCH_URL}?text={quote(query)}" @@ -382,37 +394,39 @@ async def _navigate_anchor_via_search(page: object, provider: str, origin: str) except Exception as exc: logger.warning( "tradein-browser[%s]: заход на yandex.ru для якоря не удался (%s) — " - "откат на прямой goto(origin)", + "откат на прямой goto(origin) без referer", provider, type(exc).__name__, ) - return False + return None 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)", + "tradein-browser[%s]: капча на выдаче Яндекса — откат на прямой " + "goto(origin) без referer", provider, ) - return False + return None link = await _find_serp_result_link(page, hostname) if link is None: logger.info( "tradein-browser[%s]: ссылка на %s не найдена в выдаче yandex.ru — " - "goto(origin) с referer=%s", + "перехода не было, откат на прямой goto(origin) без referer", 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 + return None + + # Снимок открытых вкладок ДО клика — так после клика можно отличить "клик + # открыл новую вкладку" (target="_blank", частый случай в живой выдаче + # Яндекса) от "клик навигировал текущую". Без этого попап на другой вкладке + # остался бы незамеченным: текущая page осталась бы на yandex.ru, проверка + # хоста ниже не прошла бы, и мы ушли бы в фолбэк вместо настоящего перехода. + context = getattr(page, "context", None) + pages_before = list(context.pages) if context is not None else [] # type: ignore[attr-defined] try: await link.click(timeout=BROWSER_NAV_TIMEOUT_MS) # type: ignore[attr-defined] @@ -420,38 +434,64 @@ async def _navigate_anchor_via_search(page: object, provider: str, origin: str) except Exception as exc: logger.warning( "tradein-browser[%s]: клик по ссылке в выдаче не удался (%s) — " - "goto(origin) с referer=%s", + "откат на прямой goto(origin) без referer", 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 + return None - landed_host = urlparse(str(getattr(page, "url", ""))).hostname or "" - if landed_host != hostname: - # Клик состоялся, но увёл не туда (редирект-обёртка Яндекса подвела) — визит - # на саму выдачу РЕАЛЬНО был, поэтому referer = точный URL выдачи, а не общий - # yandex.ru (в отличие от ветки "ссылка не найдена" выше). + popup: object | None = None + if context is not None: + for candidate in context.pages: # type: ignore[attr-defined] + if candidate not in pages_before and candidate is not page: + popup = candidate + break + + if popup is not None: + try: + await popup.wait_for_load_state( # type: ignore[attr-defined] + "domcontentloaded", timeout=BROWSER_NAV_TIMEOUT_MS + ) + except Exception: + pass + popup_host = urlparse(str(getattr(popup, "url", ""))).hostname or "" + if popup_host == hostname: + logger.info( + "tradein-browser[%s]: клик по ссылке в выдаче открыл НОВУЮ вкладку " + "на %s — она становится якорной, исходная с Яндексом закрывается", + provider, + hostname, + ) + await _apply_resource_block(popup) + try: + await page.close() # type: ignore[attr-defined] + except Exception: + pass + return popup logger.warning( - "tradein-browser[%s]: клик увёл на %s вместо %s — goto(origin) с " - "referer=URL выдачи", + "tradein-browser[%s]: клик открыл новую вкладку на %s вместо %s — " + "перехода на origin не было, откат на прямой goto(origin) без referer", provider, - landed_host, + popup_host, hostname, ) - await page.goto( # type: ignore[attr-defined] - origin, - timeout=BROWSER_NAV_TIMEOUT_MS, - wait_until="domcontentloaded", - referer=serp_url, - ) - return True + try: + await popup.close() # type: ignore[attr-defined] + except Exception: + pass + return None + + landed_host = urlparse(str(getattr(page, "url", ""))).hostname or "" + if landed_host == hostname: + return page + logger.warning( + "tradein-browser[%s]: клик увёл текущую вкладку на %s вместо %s — " + "перехода на origin не было, откат на прямой goto(origin) без referer", + provider, + landed_host, + hostname, + ) + return None def _resolve_min_interval( @@ -836,13 +876,18 @@ 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) - # #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: + # #3251/#3258: за origin для включённых провайдеров идём через реальный + # поиск yandex.ru, а не голым goto — так это делает человек. anchored_page + # не None только если переход на origin был НАСТОЯЩИЙ (см. докстринг + # _navigate_anchor_via_search) — тогда это либо та же page, либо новая + # вкладка (клик открыл её через target="_blank"), и её нужно взять как + # якорную вместо исходной. None — переход не случился ни в каком виде, + # откатываемся на прежний прямой goto(origin) БЕЗ referer, ровно как до + # #3251: заявлять переход, которого не было, нельзя. + anchored_page = await _navigate_anchor_via_search(page, provider, origin) + if anchored_page is not None: + page = anchored_page # type: ignore[assignment] + else: await page.goto( # type: ignore[attr-defined] origin, timeout=BROWSER_NAV_TIMEOUT_MS, wait_until="domcontentloaded" ) diff --git a/tradein-mvp/browser/test_server_anchor_search.py b/tradein-mvp/browser/test_server_anchor_search.py index 1f5cd78d..c1cec4ba 100644 --- a/tradein-mvp/browser/test_server_anchor_search.py +++ b/tradein-mvp/browser/test_server_anchor_search.py @@ -40,12 +40,24 @@ def _reset_state() -> None: class _FakeLink: - """Поддельный результат выдачи: get_attribute/text_content/click как ElementHandle.""" + """Поддельный результат выдачи: get_attribute/text_content/click как ElementHandle. - def __init__(self, page: "_FakeAnchorPage", host: str, land_host: str | None = None) -> None: + ``new_tab_host`` эмулирует ссылку с ``target="_blank"``: клик не трогает + ``url`` исходной страницы, а открывает НОВУЮ поддельную page в том же + context'е (как это делает настоящий Яндекс для части результатов выдачи). + """ + + def __init__( + self, + page: "_FakeAnchorPage", + host: str, + land_host: str | None = None, + new_tab_host: str | None = None, + ) -> None: self._page = page self._host = host self._land_host = land_host or host + self._new_tab_host = new_tab_host self.clicked = 0 async def get_attribute(self, name: str) -> str: @@ -56,6 +68,13 @@ class _FakeLink: async def click(self, timeout: int | None = None) -> None: self.clicked += 1 + if self._new_tab_host is not None: + popup = _FakeAnchorPage() + popup.url = f"https://{self._new_tab_host}/landed" + popup.context = self._page.context + self._page.context.pages.append(popup) + self._page.opened_popup = popup + return self._page.url = f"https://{self._land_host}/landed" @@ -68,20 +87,25 @@ class _FakeAnchorPage: land_host: str | None = None, captcha: bool = False, fail_search_nav: bool = False, + new_tab_host: str | None = None, ) -> None: self.goto_calls: list[dict[str, Any]] = [] self.url = "" self.closed = 0 + self.route_calls = 0 + self.context: "_FakeAnchorContext | None" = None + self.opened_popup: "_FakeAnchorPage | None" = None self._link_host = link_host self._land_host = land_host self._captcha = captcha self._fail_search_nav = fail_search_nav + self._new_tab_host = new_tab_host def is_closed(self) -> bool: return self.closed > 0 async def route(self, pattern: str, handler: Any) -> None: - return None + self.route_calls += 1 async def goto(self, url: str, **kwargs: Any) -> None: if self._fail_search_nav and "yandex.ru/search" in url: @@ -92,6 +116,9 @@ class _FakeAnchorPage: async def wait_for_timeout(self, ms: int) -> None: return None + async def wait_for_load_state(self, state: str, timeout: int | None = None) -> None: + return None + async def content(self) -> str: if "yandex.ru/search" in self.url: if self._captcha: @@ -102,14 +129,18 @@ class _FakeAnchorPage: 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)] + return [_FakeLink(self, self._link_host, self._land_host, self._new_tab_host)] async def close(self) -> None: self.closed += 1 class _FakeAnchorContext: - """Поддельный BrowserContext: new_page() отдаёт заранее сконструированную page.""" + """Поддельный BrowserContext: new_page() отдаёт заранее сконструированную page. + + ``pages`` эмулирует ``BrowserContext.pages`` — список всех открытых вкладок + контекста, растущий при появлении попапа (target="_blank"). + """ def __init__(self, page: _FakeAnchorPage) -> None: self._page = page @@ -117,6 +148,7 @@ class _FakeAnchorContext: async def new_page(self) -> _FakeAnchorPage: self.pages.append(self._page) + self._page.context = self return self._page @@ -153,8 +185,12 @@ def test_captcha_falls_back_to_plain_goto_without_referer() -> None: 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).""" +def test_no_link_in_serp_falls_back_without_referer() -> None: + """Ссылки на хост в выдаче нет → перехода не было → goto(origin) БЕЗ referer. + + До #3258 этот случай подставлял referer=yandex.ru, хотя перехода с + Яндекса на origin фактически не было — намеренная смена поведения. + """ page = _FakeAnchorPage(link_host=None) anchored = _run_ensure_anchor("domclick", _ORIGIN, page) @@ -162,7 +198,7 @@ def test_no_link_in_serp_falls_back_with_yandex_referer() -> None: 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/" + assert origin_calls[0]["referer"] is None def test_provider_not_in_allowlist_skips_search_entirely() -> None: @@ -185,3 +221,37 @@ def test_yandex_navigation_failure_falls_back_and_anchor_still_raised() -> None: assert anchored is True assert server._anchor_pages["domclick"] is page assert page.goto_calls == [{"url": _ORIGIN, "referer": None}] + + +def test_click_opens_new_tab_on_target_host_becomes_anchor() -> None: + """Ссылка в выдаче с target="_blank" открывает НОВУЮ вкладку на нужном хосте — + + она и становится якорной, исходная вкладка с Яндексом закрывается, + _apply_resource_block применяется к новой вкладке (не унаследован). + """ + page = _FakeAnchorPage(link_host=_HOSTNAME, new_tab_host=_HOSTNAME) + + anchored = _run_ensure_anchor("domclick", _ORIGIN, page) + + assert anchored is True + popup = server._anchor_pages["domclick"] + assert popup is not page, "якорной должна стать НОВАЯ вкладка, а не исходная с Яндексом" + assert popup.url == f"https://{_HOSTNAME}/landed" + assert page.closed == 1, "исходная вкладка с Яндексом должна закрыться" + assert popup.route_calls > 0, "_apply_resource_block должен примениться к новой вкладке" + assert not any(c["url"] == _ORIGIN for c in popup.goto_calls), "был реальный клик, не goto" + + +def test_new_tab_wrong_host_falls_back_without_referer() -> None: + """Новая вкладка открылась НЕ на нужном хосте → None → goto(origin) без referer.""" + page = _FakeAnchorPage(link_host=_HOSTNAME, new_tab_host="another.example.ru") + + anchored = _run_ensure_anchor("domclick", _ORIGIN, page) + + assert anchored is True + assert server._anchor_pages["domclick"] is page, "якорь остаётся на исходной вкладке" + 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 + assert page.opened_popup is not None + assert page.opened_popup.closed == 1, "попап не на том хосте должен быть закрыт"