diff --git a/tradein-mvp/backend/app/api/v1/admin.py b/tradein-mvp/backend/app/api/v1/admin.py index aa3abd64..96a3aa33 100644 --- a/tradein-mvp/backend/app/api/v1/admin.py +++ b/tradein-mvp/backend/app/api/v1/admin.py @@ -63,6 +63,7 @@ from scraper_kit.providers.yandex.detail import YandexDetailScraper from scraper_kit.providers.yandex.newbuilding import YandexNewbuildingScraper from scraper_kit.providers.yandex.serp import YandexRealtyScraper from scraper_kit.providers.yandex.valuation import YandexValuationScraper +from scraper_kit.proxy_errors import NoProxyAvailableError from sqlalchemy import text from sqlalchemy.orm import Session @@ -516,16 +517,13 @@ async def cian_auto_login( ) try: - # #3197 (хвост): через фабрику (endpoint/environment из одного места), но - # НАМЕРЕННО без proxy_provider. `/login` сайдкара proxy-override не принимает - # (browser/server.py:2814-2817 — `_no_live_proxy(provider, None)`; и сам - # `_post_login` не кладёт payload["proxy"], это делают только fetch/fetch_json) — - # логин идёт с env-узла сайдкара. Аренда здесь была бы холостой и при пустом пуле - # блокировала бы ручку восстановления (`_acquire_lease` → NoProxyAvailableError → - # 502 ровно во время инцидента с пулом). Пул для логина — отдельная задача сайдкара. - # `use_pool` без провайдера фетчер игнорирует (`_acquire_lease`: use_pool AND - # provider is not None), поэтому передавать его тут безвредно, но и бесполезно. - async with build_browser_fetcher(RealScraperConfig(), "cian") as fetcher: + # #3410: логин идёт через узел пула, как debug-карточка DomClick ниже. `/login` + # сайдкара принимает proxy в теле, `BrowserFetcher.login` кладёт туда узел аренды. + # До этого (#3197) провайдер сюда не передавали, и логин шёл с env-узла сайдкара — + # на проде это выключенный узел 9, camoufox `InvalidIP`, ручка не работала вовсе. + async with build_browser_fetcher( + RealScraperConfig(), "cian", proxy_provider=_kit_proxy_provider() + ) as fetcher: raw_cookies = await fetcher.login( url=settings.cian_login_url, email=email, @@ -537,6 +535,13 @@ async def cian_auto_login( pre_click_selectors=settings.cian_login_pre_click_selectors, wait_ms=settings.cian_login_wait_ms, ) + except NoProxyAvailableError as exc: + # Пул пуст для cian (прод, #2616): на мёртвый env-узел не идём, говорим прямо. + logger.warning("cian auto-login: пул прокси пуст для cian — логин не запускался") + raise HTTPException( + status_code=503, + detail="Нет свободного узла прокси для cian в пуле — логин не запускался", + ) from exc except Exception as exc: logger.error("cian auto-login failed: %s", type(exc).__name__) raise HTTPException( diff --git a/tradein-mvp/backend/tests/test_3197_admin_debug_browser_pool_wiring.py b/tradein-mvp/backend/tests/test_3197_admin_debug_browser_pool_wiring.py index ba6f2586..3a2b22bc 100644 --- a/tradein-mvp/backend/tests/test_3197_admin_debug_browser_pool_wiring.py +++ b/tradein-mvp/backend/tests/test_3197_admin_debug_browser_pool_wiring.py @@ -1,8 +1,7 @@ -"""#3197 (хвост) — две служебные ручки admin.py и пул прокси: одна чинится, вторая НЕТ. +"""#3197 (хвост) + #3410 — две служебные ручки admin.py подключают пул прокси. -`POST /admin/scrape/domclick/debug/detail-fetch` — **настоящий фикс**. Он ходит через -`BrowserFetcher.fetch`, а `fetch`/`fetch_json` — единственные методы, которые кладут -`payload["proxy"]` в тело POST /fetch сайдкара, и сайдкар этот override читает +`POST /admin/scrape/domclick/debug/detail-fetch` ходит через `BrowserFetcher.fetch`, который +кладёт `payload["proxy"]` в тело POST /fetch сайдкара, а сайдкар этот override читает (`_resolve_proxy_override` → `_ensure_browser(provider, proxy_override=...)`). Прямая конструкция `BrowserFetcher(source=, endpoint=)` не проставляла `proxy_provider`/`use_pool`/`environment` — без них сайдкар брал свой env-узел @@ -10,17 +9,11 @@ прод-отказ «пул пуст → не ходить на мёртвый env» (#2616) тут был мёртв: он смотрит на `environment`, который до конструктора не доезжал. Тест меряет ЗНАЧЕНИЯ kwargs. -`POST /admin/scrape/cian/auto-login` — **намеренно без пула**, и это проверяется обратными -по значению утверждениями. Логин идёт не через `fetch`, а через `login` → `_post_login`, -который `payload["proxy"]` не кладёт вовсе; на приёме `login_handler` -(`browser/server.py:2814-2817`) зовёт `_no_live_proxy(provider, None)` и -`_ensure_browser(provider)` без override — то есть **сайдкар на `/login` proxy-override не -принимает** и логинится с env-узла при любом теле запроса. Аренда на этом пути была бы -холостой (взяли в `__aenter__`, отпустили в `__aexit__`, health-вердикта по узлу нет), а -при пустом пуле в production `_acquire_lease` поднимает `NoProxyAvailableError` ДО POST — -и единственная ручка ВОССТАНОВЛЕНИЯ сессии отдавала бы `502 Browser login failed` ровно во -время инцидента с пулом. Поэтому здесь `proxy_provider` не передаётся (фабрика остаётся -ради endpoint/environment из одного места); пул для логина — отдельная задача сайдкара. +`POST /admin/scrape/cian/auto-login` (#3410) — до правки намеренно без пула: `/login` +сайдкара proxy-override не принимал, `_post_login` его не клал, и логин шёл с того же +мёртвого env-узла при любом теле. Теперь `/login` берёт proxy из тела, `login()` кладёт +туда узел аренды, а ручка передаёт провайдер. Пустой пул на проде — честный 503 «нет +узла для cian», а не 502 «Browser login failed» и не заход на мёртвый env-узел. Подделка ставится и на `scraper_kit.providers._base.BrowserFetcher` (путь через фабрику), и на `app.api.v1.admin.BrowserFetcher` (прямая конструкция, как было до #3197, `create=True` — @@ -81,17 +74,20 @@ class _RealLeaseFetcher(BrowserFetcher): """ captured: ClassVar[list[dict[str, Any]]] = [] + login_proxy: ClassVar[list[str | None]] = [] def __init__(self, **kwargs: Any) -> None: _RealLeaseFetcher.captured.append(kwargs) super().__init__(**kwargs) async def login(self, **_kwargs: Any) -> dict[str, str]: + # Узел, который настоящий login() положил бы в тело /login (см. его код). + _RealLeaseFetcher.login_proxy.append(self._current_proxy()[0]) return {name: "v" for name in cian_session_svc.CIAN_REQUIRED_COOKIES} class _SpyProvider: - """Пустой пул + спай: `acquire` пишет вызовы в ClassVar и всегда отдаёт None. + """Спай пула: `acquire` пишет вызовы в ClassVar и отдаёт `lease` (None — пул пуст). ClassVar, а не поле инстанса: `_kit_proxy_provider()` конструирует провайдер сам, и «ни разу не позвали» должно покрывать в том числе «даже не создали». @@ -99,10 +95,11 @@ class _SpyProvider: acquired: ClassVar[list[str]] = [] released: ClassVar[list[int]] = [] + lease: ClassVar[ProxyLease | None] = None def acquire(self, provider: str) -> ProxyLease | None: _SpyProvider.acquired.append(provider) - return None + return _SpyProvider.lease def release(self, lease: ProxyLease) -> None: _SpyProvider.released.append(lease.id) @@ -124,8 +121,10 @@ def _pool_on(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(admin, "RealProxyProvider", _SpyProvider) _CapturingFetcher.captured = [] _RealLeaseFetcher.captured = [] + _RealLeaseFetcher.login_proxy = [] _SpyProvider.acquired = [] _SpyProvider.released = [] + _SpyProvider.lease = None def _cian_login_settings(monkeypatch: pytest.MonkeyPatch, *, use_pool: bool, env: str) -> None: @@ -149,13 +148,11 @@ def _patched_cian_login(stack: ExitStack, fetcher_cls: type) -> None: stack.enter_context(patch.object(cian_session_svc, "save_session", MagicMock())) -@pytest.mark.parametrize(("use_pool", "environment"), [(True, "production"), (False, "dev")]) @pytest.mark.usefixtures("_pool_on") -async def test_cian_auto_login_does_not_lease_from_pool( - monkeypatch: pytest.MonkeyPatch, use_pool: bool, environment: str -) -> None: - """Логин пул НЕ арендует: сайдкар на `/login` proxy-override не берёт (см. докстринг).""" - _cian_login_settings(monkeypatch, use_pool=use_pool, env=environment) +async def test_cian_auto_login_logs_in_through_pool_lease(monkeypatch: pytest.MonkeyPatch) -> None: + """#3410: прод + узел в пуле → логин идёт через него, аренда отпускается.""" + _cian_login_settings(monkeypatch, use_pool=True, env="production") + _SpyProvider.lease = ProxyLease(id=13, url="http://u:p@pool:8080", kind="http") with ExitStack() as stack: _patched_cian_login(stack, _RealLeaseFetcher) @@ -165,8 +162,12 @@ async def test_cian_auto_login_does_not_lease_from_pool( assert len(_RealLeaseFetcher.captured) == 1, "ручка обязана построить ровно один фетчер" kwargs = _RealLeaseFetcher.captured[0] # .get(), а не [] — красное должно читаться как «значение не то», а не KeyError. - assert kwargs.get("proxy_provider") is None, "аренда на /login холостая — провайдер не нужен" - assert _SpyProvider.acquired == [], "lease взят впустую (сайдкар его всё равно не увидит)" + assert kwargs.get("proxy_provider") is not None, "без провайдера логин идёт с env-узла" + assert kwargs.get("use_pool") is True + assert kwargs.get("environment") == "production" + assert _SpyProvider.acquired == ["cian"] + assert _RealLeaseFetcher.login_proxy == ["http://u:p@pool:8080"] + assert _SpyProvider.released == [13], "аренда логина не должна течь" # endpoint из фабрики не должен потеряться (#2322: без него TypeError). from app.core.config import settings @@ -174,28 +175,40 @@ async def test_cian_auto_login_does_not_lease_from_pool( @pytest.mark.usefixtures("_pool_on") -async def test_cian_auto_login_survives_empty_pool_in_production( +async def test_cian_auto_login_empty_pool_in_production_is_honest_503( monkeypatch: pytest.MonkeyPatch, ) -> None: - """Пустой пул на проде НЕ ломает ручку восстановления сессии (нет ложного 502). + """Пустой пул на проде → 503 «нет узла для cian», логин не запускается. - Ровно тот сценарий, ради которого пул отсюда убран: `_acquire_lease` при - `use_pool + provider + production + пустой пул` поднимает `NoProxyAvailableError`, - `cian_auto_login` ловит любое `Exception` и отдаёт `502 Browser login failed` — то - есть инцидент с пулом закрывал бы единственный способ переполучить cian-сессию, - хотя логину пул не нужен (сайдкар proxy-override на `/login` не принимает). + Не 502 «Browser login failed» (читается как сломанные селекторы/креды) и не заход на + env-узел сайдкара (#2616: на проде он мёртв). """ _cian_login_settings(monkeypatch, use_pool=True, env="production") with ExitStack() as stack: _patched_cian_login(stack, _RealLeaseFetcher) - try: - result = await admin.cian_auto_login(db=MagicMock(), body=None) - except HTTPException as exc: - # pytest.fail, а не re-raise: красное должно называть статус и detail. - pytest.fail(f"пустой пул уронил ручку восстановления: {exc.status_code} {exc.detail}") + with pytest.raises(HTTPException) as exc_info: + await admin.cian_auto_login(db=MagicMock(), body=None) - assert result["ok"] is True and result["userId"] == 7 + assert (exc_info.value.status_code, "пул" in str(exc_info.value.detail)) == (503, True) + assert _SpyProvider.acquired == ["cian"] + assert _RealLeaseFetcher.login_proxy == [], "логин без узла запускаться не должен" + + +@pytest.mark.usefixtures("_pool_on") +async def test_cian_auto_login_dev_without_browser_pool_uses_env( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """dev + browser-пул выключен → аренды нет, логин без proxy в теле (env сайдкара).""" + _cian_login_settings(monkeypatch, use_pool=False, env="dev") + + with ExitStack() as stack: + _patched_cian_login(stack, _RealLeaseFetcher) + result = await admin.cian_auto_login(db=MagicMock(), body=None) + + assert result["ok"] is True + assert _SpyProvider.acquired == [] + assert _RealLeaseFetcher.login_proxy == [None] @pytest.mark.parametrize(("use_pool", "environment"), [(True, "production"), (False, "dev")]) diff --git a/tradein-mvp/backend/tests/test_kit_browser_fetcher_proxy_pool.py b/tradein-mvp/backend/tests/test_kit_browser_fetcher_proxy_pool.py index 6c19c9e5..81a3e8f9 100644 --- a/tradein-mvp/backend/tests/test_kit_browser_fetcher_proxy_pool.py +++ b/tradein-mvp/backend/tests/test_kit_browser_fetcher_proxy_pool.py @@ -640,3 +640,36 @@ async def test_report_ban_does_not_release_or_rotate_lease() -> None: assert provider.released == [] assert bf._lease is not None assert bf._lease.id == 5 + + +# ── #3410: /login несёт узел аренды, как /fetch ──────────────────────────────── + + +@pytest.mark.parametrize("use_pool", [True, False]) +async def test_login_body_carries_lease_proxy(use_pool: bool) -> None: + """Аренда есть → в теле /login тот же proxy/proxy_kind, что ушёл бы в /fetch; нет → без.""" + provider = _FakeProxyProvider(ProxyLease(id=13, url="http://u:p@pool:8080", kind="http")) + client = _mock_client({"cookies": [{"name": "DMIR_AUTH", "value": "v"}]}) + bf = await _fetcher(client, source="cian", proxy_provider=provider, use_pool=use_pool) + + cookies = await bf.login( + url="https://www.cian.ru/authenticate/", + email="a@b.c", + password="pw", + email_selector="#e", + password_selector="#p", + submit_selector="#s", + success_cookie="DMIR_AUTH", + ) + await bf.__aexit__(None, None, None) + + assert cookies == {"DMIR_AUTH": "v"} + url_called = client.post.call_args.args[0] + body = client.post.call_args.kwargs["json"] + assert url_called == "http://browser:3000/login" + if use_pool: + assert (body.get("proxy"), body.get("proxy_kind")) == ("http://u:p@pool:8080", "http") + assert provider.released == [13] + else: + assert "proxy" not in body and "proxy_kind" not in body + assert provider.acquired == [] diff --git a/tradein-mvp/browser/server.py b/tradein-mvp/browser/server.py index 71de6501..4cbc3a8c 100644 --- a/tradein-mvp/browser/server.py +++ b/tradein-mvp/browser/server.py @@ -107,10 +107,12 @@ Per-provider модель (#1793): поиск yandex.ru, а не голым goto (#3251). Ручная сессия 29.08.2026 показала эталонный человеческий путь: yandex.ru → клик по результату → выдача с Referer - yandex.ru. Дефолт "domclick" — авито/циан/яндекс - проверяются отдельно в #3251, не включать здесь. - Пустая строка = выключено везде (полный откат к - прежнему поведению). Провайдер без соответствующего + yandex.ru. Дефолт — пусто, выключено везде (#3263): + выдача Яндекса капчит конкретные выходные узлы пула, + и путь срабатывал ~2 раза из 9. Якорь поднимается + прямым goto на выдачу площадки, Referer карточке даёт + переход с неё. Включать только явным env и только + после замера на узлах пула. Провайдер без соответствующего BROWSER_ANCHOR_SEARCH_QUERY_{PROVIDER} поиск тоже не делает — см. ниже. BROWSER_ANCHOR_SEARCH_QUERY_{PROVIDER} — поисковый запрос на yandex.ru для @@ -143,6 +145,7 @@ import logging import os import random import re +import time from collections.abc import Callable, Mapping from typing import NamedTuple from urllib.parse import quote, urlparse @@ -329,12 +332,15 @@ BROWSER_PAGE_INTERVAL_JITTER_S: float = float( ) # Провайдеры, для которых якорная вкладка (#3251) заходит на origin ЧЕРЕЗ реальный -# поиск yandex.ru, а не голым goto. Дефолт "domclick" — авито/циан/яндекс проверяются -# отдельно (#3251), не расширять этот список без замера на них. Пустая строка env = -# выключено везде — полный откат на поведение до #3251. +# поиск yandex.ru, а не голым goto. Дефолт — пусто (#3263): капча на выдаче Яндекса — +# свойство выходного узла (замер владельца 29.08: узлы 1/10/11 — капча, 9 и домашний +# IP — выдача), в проде путь срабатывал ~2 из 9, а в логах 15-16.09 все 7 капч +# закончились «якорная вкладка не поднялась (Error)» через 0,2 с после отката — якоря +# не было вовсе. Без поиска якорь сразу открывает выдачу площадки, карточка идёт с +# referer=origin — переход внутри площадки. Включать только явным env после замера. _ANCHOR_VIA_SEARCH_PROVIDERS: frozenset[str] = frozenset( p.strip().lower() - for p in os.environ.get("BROWSER_ANCHOR_VIA_SEARCH", "domclick").split(",") + for p in os.environ.get("BROWSER_ANCHOR_VIA_SEARCH", "").split(",") if p.strip() ) @@ -832,6 +838,12 @@ _last_goto_at: dict[str, float] = {} # provider → loop-time последн # _fetch_once (сбрасывается в None перед навигацией, чтобы не отдать чужой # протухший статус), читается fetch_handler'ом под тем же _locks[provider] — гонки нет. _last_response_status: dict[str, int | None] = {} +# #3419: provider → длительность ЦЕЛЕВОЙ page.goto последнего /fetch в мс (None — до неё не +# дошли: упал прогрев origin, fetch_mode не navigate). Пишется в _fetch_once и на успехе, +# и на исключении goto; читается fetch_handler'ом под тем же локом для строки «fetch error», +# а «fetch OK» печатает её сам. Нужна, чтобы порог BROWSER_NAV_TIMEOUT_MS сравнивать с +# распределением времени навигации, а не подбирать вслепую. +_last_nav_ms: dict[str, int | None] = {} # #2164 P4: proxy-url, с которым СЕЙЧАС запущен инстанс провайдера (env или динамический # из пула, переданный в теле /fetch). Нужен для политики «relaunch ТОЛЬКО при реальной # смене прокси» — camoufox берёт proxy на launch, релонч дорогой, поэтому не релончим, @@ -895,10 +907,16 @@ async def _launch_browser(provider: str, proxy_override: str | None = None) -> N "block_images": _BLOCK_IMAGES_BY_PROVIDER.get(provider, _BLOCK_IMAGES_DEFAULT_FALLBACK), # Анти-leak: WebRTC может слить реальный IP контейнера мимо прокси → деанон. "block_webrtc": True, - # Форсим таймзону +3 МСК. geoip берёт tz одного IP на старте, а БэкКоннект - # ротирует IP на каждый запрос (видели Ульяновск UTC+4 при выходе СПб/МСК) → - # геолик tz↔IP. Жёсткий Europe/Moscow убирает рассинхрон для РФ-мобильного. - "config": {"timezone": "Europe/Moscow"}, + # Таймзону НЕ задаём (#3187): её ставит geoip=True по exit-IP на запуске — + # из той же GeoIP-записи, что и координаты geolocation. Жёсткий + # Europe/Moscow в config (было) camoufox 0.5.5 применял поверх geoip + # (timezone — setdefault), а координаты брал из IP: для узлов вне UTC+3 + # браузер заявлял МСК при новосибирских координатах и адресе (узлы 13/15 — + # T2 NSK, UTC+7; 14 — МегаФон Самара, UTC+4 по RIPE, 17.09.2026) и писал + # LeakWarning «Please use the geoip parameter» на каждом запуске. Ротация + # узла пула меняет IP внутри той же подсети оператора (health-логи 48 ч: + # 176.59.137.x/176.59.144.x у 15, 178.176.78-79.x у 14), а смена узла + # релончит браузер (_launched_proxy) — зона, взятая на запуске, не протухает. } if proxy is not None: kwargs["proxy"] = proxy @@ -1454,8 +1472,9 @@ async def fetch_handler(request: web.Request) -> web.Response: status = _last_response_status.get(provider) except Exception as exc: logger.error( - "tradein-browser[%s]: fetch error url=%r: %s: %s", + "tradein-browser[%s]: fetch error nav_ms=%s url=%r: %s: %s", provider, + _last_nav_ms.get(provider), url, type(exc).__name__, exc, @@ -2349,6 +2368,7 @@ async def _fetch_once( # Гасим статус прошлой навигации ДО работы: если goto упадёт, наверх не должен # уехать статус предыдущей страницы этого же провайдера (#3196). _last_response_status[provider] = None + _last_nav_ms[provider] = None if reset_context: await _close_reusable_context(provider) @@ -2423,7 +2443,11 @@ async def _fetch_once( } if referer: goto_kwargs["referer"] = referer - response = await page.goto(url, **goto_kwargs) # type: ignore[attr-defined] + nav_started = time.monotonic() + try: + response = await page.goto(url, **goto_kwargs) # type: ignore[attr-defined] + finally: + _last_nav_ms[provider] = int((time.monotonic() - nav_started) * 1000) _last_response_status[provider] = _status_of(response) if BROWSER_WAIT_MS > 0: await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined] @@ -2510,9 +2534,12 @@ async def _fetch_once( await page.close() # type: ignore[attr-defined] _page_counters[provider] = _page_counters.get(provider, 0) + 1 - logger.debug( - "tradein-browser[%s]: fetch OK url=%r pages_since_launch=%d", + # INFO, а не DEBUG (#3419): без строк успеха распределение времени навигации не + # снять, а сравнивать его с таймаутами и есть цель. + logger.info( + "tradein-browser[%s]: fetch OK nav_ms=%s url=%r pages_since_launch=%d", provider, + _last_nav_ms.get(provider), url, _page_counters[provider], ) @@ -2811,12 +2838,16 @@ async def login_handler(request: web.Request) -> web.Response: return web.json_response({"error": f"missing required fields: {missing}"}, status=400) provider = _resolve_provider(body, body["url"]) + # #3410: прокси из пула в теле — как у /fetch. Без него логин шёл только с env-узла + # сайдкара (на проде — выключенный узел, InvalidIP), и ручка восстановления сессии + # не работала при живом пуле. relaunch при смене прокси — внутри _ensure_browser. + proxy_override = _resolve_proxy_override(body, provider) - # #2616 шаг 1: см. fetch_handler — прод + нет env-прокси (login override не берёт) → + # #2616 шаг 1: см. fetch_handler — прод + нет НИ override, НИ env-прокси → # отказ, не direct-IP. - if IS_PROD and _no_live_proxy(provider, None): + if IS_PROD and _no_live_proxy(provider, proxy_override): logger.warning( - "tradein-browser[%s]: /login отказ — нет env-прокси (prod) — " + "tradein-browser[%s]: /login отказ — нет прокси в теле и нет env-прокси (prod) — " "не подключаемся напрямую с IP сервера (#2616)", provider, ) @@ -2828,7 +2859,7 @@ async def login_handler(request: web.Request) -> web.Response: async with lock: # Та же resilience что и в /fetch: браузер мог не подняться (прокси лежал). # Lazy-попытка, иначе 503 — без прокси не логинимся. - if not await _ensure_browser(provider): + if not await _ensure_browser(provider, proxy_override=proxy_override): logger.warning( "tradein-browser[%s]: /login 503 — браузер недоступен (proxy may be down)", provider, diff --git a/tradein-mvp/browser/test_server_anchor_search.py b/tradein-mvp/browser/test_server_anchor_search.py index c1cec4ba..2ced4212 100644 --- a/tradein-mvp/browser/test_server_anchor_search.py +++ b/tradein-mvp/browser/test_server_anchor_search.py @@ -34,9 +34,12 @@ _HOSTNAME = "ekaterinburg.domclick.ru" @pytest.fixture(autouse=True) -def _reset_state() -> None: +def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None: server._contexts = {} server._anchor_pages = {} + # Путь через поиск выключен по умолчанию (#3263) — тесты ниже проверяют его + # поведение при ЯВНОМ включении env'ом BROWSER_ANCHOR_VIA_SEARCH=domclick. + monkeypatch.setattr(server, "_ANCHOR_VIA_SEARCH_PROVIDERS", frozenset({"domclick"})) class _FakeLink: @@ -158,7 +161,7 @@ def _run_ensure_anchor(provider: str, origin: str, page: _FakeAnchorPage) -> boo def test_anchor_via_search_visits_yandex_and_clicks_result() -> None: - """domclick включён по умолчанию: заход на yandex.ru → клик по ссылке на хост.""" + """domclick включён env'ом: заход на yandex.ru → клик по ссылке на хост.""" page = _FakeAnchorPage(link_host=_HOSTNAME) anchored = _run_ensure_anchor("domclick", _ORIGIN, page) @@ -202,7 +205,7 @@ def test_no_link_in_serp_falls_back_without_referer() -> None: def test_provider_not_in_allowlist_skips_search_entirely() -> None: - """avito не в BROWSER_ANCHOR_VIA_SEARCH (дефолт только domclick) — поведение прежнее.""" + """avito не в BROWSER_ANCHOR_VIA_SEARCH (включён только domclick) — поведение прежнее.""" origin = "https://www.avito.ru/ekaterinburg/kvartiry" page = _FakeAnchorPage(link_host="www.avito.ru") @@ -255,3 +258,28 @@ def test_new_tab_wrong_host_falls_back_without_referer() -> None: assert origin_calls[0]["referer"] is None assert page.opened_popup is not None assert page.opened_popup.closed == 1, "попап не на том хосте должен быть закрыт" + + +def test_default_env_domclick_anchor_goes_straight_to_origin( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """#3263: без env якорь Домклика открывает свою выдачу прямым goto, yandex.ru не трогает. + + Модуль грузится заново с удалённым env — проверяется именно код-дефолт, а не + значение, подставленное фикстурой. Капча на выдаче Яндекса включена в подделке: + если путь через поиск снова станет дефолтом, goto на yandex.ru попадёт в + goto_calls и тест покраснеет. + """ + monkeypatch.delenv("BROWSER_ANCHOR_VIA_SEARCH", raising=False) + spec = importlib.util.spec_from_file_location("tradein_browser_server_anchor_default", _SERVER_PATH) + assert spec is not None and spec.loader is not None + fresh = importlib.util.module_from_spec(spec) + spec.loader.exec_module(fresh) + + page = _FakeAnchorPage(link_host=_HOSTNAME, captcha=True) + fresh._contexts["domclick"] = _FakeAnchorContext(page) + anchored = asyncio.run(fresh._ensure_anchor_page("domclick", _ORIGIN)) + + assert anchored is True + assert fresh._anchor_pages["domclick"] is page + assert page.goto_calls == [{"url": _ORIGIN, "referer": None}] diff --git a/tradein-mvp/browser/test_server_nav_timing.py b/tradein-mvp/browser/test_server_nav_timing.py new file mode 100644 index 00000000..68330b9e --- /dev/null +++ b/tradein-mvp/browser/test_server_nav_timing.py @@ -0,0 +1,156 @@ +"""test_server_nav_timing.py — длительность целевой навигации в логах /fetch (#3419). + +Порог BROWSER_NAV_TIMEOUT_MS=60000 не с чем было сравнить: «fetch OK» писался на DEBUG +и без длительности, «fetch error» — тоже без неё. Теперь обе строки несут nav_ms — +время ЦЕЛЕВОЙ page.goto (без прогрева origin, пейсинга и BROWSER_WAIT_MS). + +Часы подменяются только в модуле сервера (server.time), event loop их не видит. +camoufox НЕ запускается: browser/page поддельные. + +Запуск (из tradein-mvp/browser/):: + + python -m pytest test_server_nav_timing.py -q +""" + +from __future__ import annotations + +import asyncio +import importlib.util +import logging +import re +from pathlib import Path +from types import SimpleNamespace +from typing import Any + +import pytest +from aiohttp.test_utils import make_mocked_request + +_SERVER_PATH = Path(__file__).resolve().parent / "server.py" +_spec = importlib.util.spec_from_file_location("tradein_browser_server_nav_timing", _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://www.cian.ru/" +_CARD = "https://ekb.cian.ru/sale/flat/1/" + + +class _Clock: + def __init__(self) -> None: + self.now = 1000.0 + + def monotonic(self) -> float: + return self.now + + +class _Page: + """goto двигает поддельные часы на заданное время; на origin или по флагу — падает.""" + + def __init__(self, clock: _Clock, nav_s: float, *, fail_target: bool, fail_origin: bool): + self._clock = clock + self._nav_s = nav_s + self._fail_target = fail_target + self._fail_origin = fail_origin + + async def route(self, pattern: str, handler: Any) -> None: + return None + + async def goto(self, url: str, **kwargs: Any) -> None: + if url == _ORIGIN: + if self._fail_origin: + raise TimeoutError("origin goto timeout") + return None + self._clock.now += self._nav_s + if self._fail_target: + raise TimeoutError("Page.goto: Timeout 60000ms exceeded.") + return None + + async def wait_for_timeout(self, ms: int) -> None: + return None + + async def content(self) -> str: + return "карточка" + + async def close(self) -> None: + return None + + +class _Browser: + def __init__(self, page: _Page) -> None: + self._page = page + + async def new_page(self) -> _Page: + return self._page + + +@pytest.fixture(autouse=True) +def _reset_state(monkeypatch: pytest.MonkeyPatch) -> _Clock: + for name in ("_browsers", "_page_counters", "_locks", "_last_goto_at", + "_last_response_status", "_last_nav_ms", "_launched_proxy"): + monkeypatch.setattr(server, name, {}) + monkeypatch.setattr(server, "_locks_guard", asyncio.Lock()) + monkeypatch.setattr(server, "IS_PROD", False) + monkeypatch.setattr(server, "BROWSER_WAIT_MS", 0) + monkeypatch.setattr(server, "_MIN_PAGE_INTERVAL_BY_PROVIDER", {}) + monkeypatch.setattr(server, "BROWSER_MIN_PAGE_INTERVAL_S", 0.0) + monkeypatch.setattr(server, "_RECYCLE_PAGES_BY_PROVIDER", dict.fromkeys(server.PROVIDERS, 10_000)) + + async def _ensure(provider: str, proxy_override: str | None = None) -> bool: + return True + + monkeypatch.setattr(server, "_ensure_browser", _ensure) + clock = _Clock() + monkeypatch.setattr(server, "time", SimpleNamespace(monotonic=clock.monotonic)) + return clock + + +async def _coro(value: Any) -> Any: + return value + + +def _fetch(page: _Page, body: dict[str, Any]) -> int: + server._browsers["cian"] = _Browser(page) + request = make_mocked_request("POST", "/fetch") + request.json = lambda: _coro(body) # type: ignore[method-assign] + return asyncio.run(server.fetch_handler(request)).status + + +def _nav_ms(caplog: pytest.LogCaptureFixture, marker: str) -> list[int | None]: + """nav_ms из строк лога с маркером, по порядку: число или None.""" + values: list[int | None] = [] + for record in caplog.records: + message = record.getMessage() + if marker in message: + match = re.search(r"nav_ms=(\d+|None)\b", message) + assert match is not None, f"в строке нет nav_ms: {message}" + values.append(None if match.group(1) == "None" else int(match.group(1))) + return values + + +def test_success_logs_target_navigation_ms_at_info( + _reset_state: _Clock, caplog: pytest.LogCaptureFixture +) -> None: + page = _Page(_reset_state, 12.345, fail_target=False, fail_origin=False) + with caplog.at_level(logging.INFO, logger=server.logger.name): + status = _fetch(page, {"url": _CARD, "origin": _ORIGIN}) + + assert status == 200 + assert _nav_ms(caplog, "[cian]: fetch OK") == [12345] + + +def test_timeout_logs_navigation_ms_then_prenav_failure_logs_none( + _reset_state: _Clock, caplog: pytest.LogCaptureFixture +) -> None: + """Таймаут цели несёт своё время; следующий отказ ДО цели не наследует прошлое число.""" + with caplog.at_level(logging.INFO, logger=server.logger.name): + first = _fetch( + _Page(_reset_state, 60.0007, fail_target=True, fail_origin=False), + {"url": _CARD, "origin": _ORIGIN}, + ) + second = _fetch( + _Page(_reset_state, 5.0, fail_target=False, fail_origin=True), + {"url": _CARD, "origin": _ORIGIN}, + ) + + assert (first, second) == (500, 500) + assert _nav_ms(caplog, "[cian]: fetch error") == [60000, None] diff --git a/tradein-mvp/browser/test_server_no_proxy_refusal.py b/tradein-mvp/browser/test_server_no_proxy_refusal.py index b74321ec..c000152a 100644 --- a/tradein-mvp/browser/test_server_no_proxy_refusal.py +++ b/tradein-mvp/browser/test_server_no_proxy_refusal.py @@ -267,6 +267,52 @@ def test_fetch_handler_prod_with_body_override_unaffected(monkeypatch: pytest.Mo assert calls == [("avito", "http://pool:8080")] +_LOGIN_BODY: dict[str, Any] = { + "url": "https://www.cian.ru/login", + "email": "a@b.ru", + "password": "x", + "email_selector": "#e", + "password_selector": "#p", + "submit_selector": "#s", + "success_cookie": "sid", +} + + +@pytest.mark.parametrize( + ("extra", "expected_override"), + [({"proxy": "http://pool:8080", "proxy_kind": "http"}, "http://pool:8080"), ({}, None)], +) +def test_login_handler_prod_uses_body_proxy( + monkeypatch: pytest.MonkeyPatch, extra: dict[str, Any], expected_override: str | None +) -> None: + """#3410: /login берёт прокси из тела, как /fetch; без него — прежний env-путь. + + С прокси в теле и БЕЗ env-прокси прод-guard не отказывает (узел пула есть), а + браузер поднимается ровно с этим узлом. Без прокси в теле, но с env — override None. + """ + monkeypatch.setattr(server, "IS_PROD", True) + if not extra: + monkeypatch.setenv("SCRAPER_PROXY_URL", "http://env:8080") + calls: list[tuple[str, str | None]] = [] + + async def _ensure(provider: str, proxy_override: str | None = None) -> bool: + calls.append((provider, proxy_override)) + return True + + async def _fake_do_login(provider: str, params: dict[str, Any]) -> list[dict[str, Any]]: + return [{"name": "sid", "value": "1"}] + + monkeypatch.setattr(server, "_ensure_browser", _ensure) + monkeypatch.setattr(server, "_do_login", _fake_do_login) + + response = asyncio.run( + server.login_handler(_make_request("/login", {**_LOGIN_BODY, **extra})) + ) + + assert response.status == 200 + assert calls == [("cian", expected_override)] + + # ── распознаваемость: отказ ≠ "browser unavailable (proxy may be down)" ──────── diff --git a/tradein-mvp/browser/test_server_proxy_override.py b/tradein-mvp/browser/test_server_proxy_override.py index 2ec4fdd4..dd124b6b 100644 --- a/tradein-mvp/browser/test_server_proxy_override.py +++ b/tradein-mvp/browser/test_server_proxy_override.py @@ -149,3 +149,46 @@ def test_ensure_browser_env_then_pool_relaunches(monkeypatch: pytest.MonkeyPatch asyncio.run(_run()) assert calls == [("avito", None), ("avito", "http://pool:8080")] + + +# ── #3187: гео-параметры запуска camoufox ──────────────────────────────────────── + + +def test_launch_leaves_timezone_to_geoip(monkeypatch: pytest.MonkeyPatch) -> None: + """Зона и координаты — только от geoip по exit-IP, вручную в config не задаются. + + camoufox 0.5.5 (utils.launch_options): geoip кладёт в config координаты + безусловно, а timezone — через setdefault, то есть ручной config["timezone"] + перебивает зону из IP, и координаты с зоной расходятся (узел в Новосибирске + с заявленной МСК). Ключи 'timezone' и 'geolocation:*' в config — ровно те, + на которые библиотека пишет LeakWarning (warn_manual_config). + """ + import sys + import types + + captured: dict[str, object] = {} + + class _FakeCamoufox: + def __init__(self, **kwargs: object) -> None: + captured.update(kwargs) + + async def __aenter__(self) -> object: + return object() + + fake_mod = types.ModuleType("camoufox.async_api") + fake_mod.AsyncCamoufox = _FakeCamoufox # type: ignore[attr-defined] + monkeypatch.setitem(sys.modules, "camoufox", types.ModuleType("camoufox")) + monkeypatch.setitem(sys.modules, "camoufox.async_api", fake_mod) + + asyncio.run(server._launch_browser("avito", proxy_override="http://u:p@176.59.137.239:8080")) + + assert captured["geoip"] is True + assert captured["proxy"] == { + "server": "http://176.59.137.239:8080", + "username": "u", + "password": "p", + } + config = captured.get("config", {}) + assert isinstance(config, dict) + manual_geo = sorted(k for k in config if k == "timezone" or k.startswith("geolocation:")) + assert manual_geo == [] diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py index a2587ab2..afdcc228 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/browser_fetcher.py @@ -656,6 +656,13 @@ class BrowserFetcher: } if wait_ms is not None: body["wait_ms"] = wait_ms + # #3410: узел аренды — в тело /login, как в _post_fetch. Без него сайдкар логинился + # с env-узла при любой аренде. + proxy_url, proxy_kind = self._current_proxy() + if proxy_url: + body["proxy"] = proxy_url + if proxy_kind: + body["proxy_kind"] = proxy_kind try: return await self._post_login(body) diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py index f78c0bdb..b21b8aa4 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/_base.py @@ -212,13 +212,10 @@ def build_browser_fetcher( Служебные ручки `app/api/v1/admin.py` (cian-login, domclick-detail-debug) были последними прямыми конструкциями мимо фабрики — переведены сюда же (#3197 хвост); прямые конструкции в `orchestration/pipeline.py` и в backfill-задачах пул получают - своими kwargs. Живой `proxy_provider=None` остался ровно один — cian-login: сайдкар - на `/login` proxy-override не берёт (`browser/server.py::login_handler` → - `_no_live_proxy(provider, None)`, а `_post_login` не кладёт `payload["proxy"]`), так - что аренда там была бы холостой, а на проде при пустом пуле роняла бы ручку - восстановления в 502. `use_pool` при `proxy_provider is None` игнорируется - `BrowserFetcher` (`_acquire_lease`: `use_pool AND provider is not None`) — ни аренды, - ни прод-отказа, поведение как до фабрики. + своими kwargs. cian-login тоже с провайдером (#3410): сайдкар на `/login` берёт proxy + из тела, `BrowserFetcher.login` кладёт туда узел аренды. `use_pool` при + `proxy_provider is None` игнорируется `BrowserFetcher` (`_acquire_lease`: + `use_pool AND provider is not None`) — ни аренды, ни прод-отказа. `fetch_timeout_s=None` (дефолт) → используется дефолт `BrowserFetcher` (120s). Явный таймаут передаёт ровно один call-site — `yandex/serp.py` (30s);