feat(tradein/browser): на выдачу площадки заходить через настоящий поиск Яндекса, а не голым goto #3258
2 changed files with 409 additions and 4 deletions
|
|
@ -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(
|
||||
|
|
|
|||
187
tradein-mvp/browser/test_server_anchor_search.py
Normal file
187
tradein-mvp/browser/test_server_anchor_search.py
Normal 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}]
|
||||
Loading…
Add table
Reference in a new issue