Сайдкар МЕРЫ: зона браузера по IP узла, якорь Домклика без Яндекса, автологин Циана через пул, время навигации в логах #3563

Merged
bot-backend merged 4 commits from fix/browser-sidecar into main 2026-09-17 09:24:13 +00:00
10 changed files with 437 additions and 78 deletions

View file

@ -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.newbuilding import YandexNewbuildingScraper
from scraper_kit.providers.yandex.serp import YandexRealtyScraper from scraper_kit.providers.yandex.serp import YandexRealtyScraper
from scraper_kit.providers.yandex.valuation import YandexValuationScraper from scraper_kit.providers.yandex.valuation import YandexValuationScraper
from scraper_kit.proxy_errors import NoProxyAvailableError
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -516,16 +517,13 @@ async def cian_auto_login(
) )
try: try:
# #3197 (хвост): через фабрику (endpoint/environment из одного места), но # #3410: логин идёт через узел пула, как debug-карточка DomClick ниже. `/login`
# НАМЕРЕННО без proxy_provider. `/login` сайдкара proxy-override не принимает # сайдкара принимает proxy в теле, `BrowserFetcher.login` кладёт туда узел аренды.
# (browser/server.py:2814-2817 — `_no_live_proxy(provider, None)`; и сам # До этого (#3197) провайдер сюда не передавали, и логин шёл с env-узла сайдкара —
# `_post_login` не кладёт payload["proxy"], это делают только fetch/fetch_json) — # на проде это выключенный узел 9, camoufox `InvalidIP`, ручка не работала вовсе.
# логин идёт с env-узла сайдкара. Аренда здесь была бы холостой и при пустом пуле async with build_browser_fetcher(
# блокировала бы ручку восстановления (`_acquire_lease` → NoProxyAvailableError → RealScraperConfig(), "cian", proxy_provider=_kit_proxy_provider()
# 502 ровно во время инцидента с пулом). Пул для логина — отдельная задача сайдкара. ) as fetcher:
# `use_pool` без провайдера фетчер игнорирует (`_acquire_lease`: use_pool AND
# provider is not None), поэтому передавать его тут безвредно, но и бесполезно.
async with build_browser_fetcher(RealScraperConfig(), "cian") as fetcher:
raw_cookies = await fetcher.login( raw_cookies = await fetcher.login(
url=settings.cian_login_url, url=settings.cian_login_url,
email=email, email=email,
@ -537,6 +535,13 @@ async def cian_auto_login(
pre_click_selectors=settings.cian_login_pre_click_selectors, pre_click_selectors=settings.cian_login_pre_click_selectors,
wait_ms=settings.cian_login_wait_ms, 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: except Exception as exc:
logger.error("cian auto-login failed: %s", type(exc).__name__) logger.error("cian auto-login failed: %s", type(exc).__name__)
raise HTTPException( raise HTTPException(

View file

@ -1,8 +1,7 @@
"""#3197 (хвост) — две служебные ручки admin.py и пул прокси: одна чинится, вторая НЕТ. """#3197 (хвост) + #3410 — две служебные ручки admin.py подключают пул прокси.
`POST /admin/scrape/domclick/debug/detail-fetch` **настоящий фикс**. Он ходит через `POST /admin/scrape/domclick/debug/detail-fetch` ходит через `BrowserFetcher.fetch`, который
`BrowserFetcher.fetch`, а `fetch`/`fetch_json` единственные методы, которые кладут кладёт `payload["proxy"]` в тело POST /fetch сайдкара, а сайдкар этот override читает
`payload["proxy"]` в тело POST /fetch сайдкара, и сайдкар этот override читает
(`_resolve_proxy_override` `_ensure_browser(provider, proxy_override=...)`). Прямая (`_resolve_proxy_override` `_ensure_browser(provider, proxy_override=...)`). Прямая
конструкция `BrowserFetcher(source=, endpoint=)` не проставляла конструкция `BrowserFetcher(source=, endpoint=)` не проставляла
`proxy_provider`/`use_pool`/`environment` без них сайдкар брал свой env-узел `proxy_provider`/`use_pool`/`environment` без них сайдкар брал свой env-узел
@ -10,17 +9,11 @@
прод-отказ «пул пуст не ходить на мёртвый env» (#2616) тут был мёртв: он смотрит на прод-отказ «пул пуст не ходить на мёртвый env» (#2616) тут был мёртв: он смотрит на
`environment`, который до конструктора не доезжал. Тест меряет ЗНАЧЕНИЯ kwargs. `environment`, который до конструктора не доезжал. Тест меряет ЗНАЧЕНИЯ kwargs.
`POST /admin/scrape/cian/auto-login` **намеренно без пула**, и это проверяется обратными `POST /admin/scrape/cian/auto-login` (#3410) — до правки намеренно без пула: `/login`
по значению утверждениями. Логин идёт не через `fetch`, а через `login` `_post_login`, сайдкара proxy-override не принимал, `_post_login` его не клал, и логин шёл с того же
который `payload["proxy"]` не кладёт вовсе; на приёме `login_handler` мёртвого env-узла при любом теле. Теперь `/login` берёт proxy из тела, `login()` кладёт
(`browser/server.py:2814-2817`) зовёт `_no_live_proxy(provider, None)` и туда узел аренды, а ручка передаёт провайдер. Пустой пул на проде честный 503 «нет
`_ensure_browser(provider)` без override то есть **сайдкар на `/login` proxy-override не узла для cian», а не 502 «Browser login failed» и не заход на мёртвый env-узел.
принимает** и логинится с env-узла при любом теле запроса. Аренда на этом пути была бы
холостой (взяли в `__aenter__`, отпустили в `__aexit__`, health-вердикта по узлу нет), а
при пустом пуле в production `_acquire_lease` поднимает `NoProxyAvailableError` ДО POST
и единственная ручка ВОССТАНОВЛЕНИЯ сессии отдавала бы `502 Browser login failed` ровно во
время инцидента с пулом. Поэтому здесь `proxy_provider` не передаётся (фабрика остаётся
ради endpoint/environment из одного места); пул для логина отдельная задача сайдкара.
Подделка ставится и на `scraper_kit.providers._base.BrowserFetcher` (путь через фабрику), и Подделка ставится и на `scraper_kit.providers._base.BrowserFetcher` (путь через фабрику), и
на `app.api.v1.admin.BrowserFetcher` (прямая конструкция, как было до #3197, `create=True` — на `app.api.v1.admin.BrowserFetcher` (прямая конструкция, как было до #3197, `create=True` —
@ -81,17 +74,20 @@ class _RealLeaseFetcher(BrowserFetcher):
""" """
captured: ClassVar[list[dict[str, Any]]] = [] captured: ClassVar[list[dict[str, Any]]] = []
login_proxy: ClassVar[list[str | None]] = []
def __init__(self, **kwargs: Any) -> None: def __init__(self, **kwargs: Any) -> None:
_RealLeaseFetcher.captured.append(kwargs) _RealLeaseFetcher.captured.append(kwargs)
super().__init__(**kwargs) super().__init__(**kwargs)
async def login(self, **_kwargs: Any) -> dict[str, str]: 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} return {name: "v" for name in cian_session_svc.CIAN_REQUIRED_COOKIES}
class _SpyProvider: class _SpyProvider:
"""Пустой пул + спай: `acquire` пишет вызовы в ClassVar и всегда отдаёт None. """Спай пула: `acquire` пишет вызовы в ClassVar и отдаёт `lease` (None — пул пуст).
ClassVar, а не поле инстанса: `_kit_proxy_provider()` конструирует провайдер сам, ClassVar, а не поле инстанса: `_kit_proxy_provider()` конструирует провайдер сам,
и «ни разу не позвали» должно покрывать в том числе «даже не создали». и «ни разу не позвали» должно покрывать в том числе «даже не создали».
@ -99,10 +95,11 @@ class _SpyProvider:
acquired: ClassVar[list[str]] = [] acquired: ClassVar[list[str]] = []
released: ClassVar[list[int]] = [] released: ClassVar[list[int]] = []
lease: ClassVar[ProxyLease | None] = None
def acquire(self, provider: str) -> ProxyLease | None: def acquire(self, provider: str) -> ProxyLease | None:
_SpyProvider.acquired.append(provider) _SpyProvider.acquired.append(provider)
return None return _SpyProvider.lease
def release(self, lease: ProxyLease) -> None: def release(self, lease: ProxyLease) -> None:
_SpyProvider.released.append(lease.id) _SpyProvider.released.append(lease.id)
@ -124,8 +121,10 @@ def _pool_on(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(admin, "RealProxyProvider", _SpyProvider) monkeypatch.setattr(admin, "RealProxyProvider", _SpyProvider)
_CapturingFetcher.captured = [] _CapturingFetcher.captured = []
_RealLeaseFetcher.captured = [] _RealLeaseFetcher.captured = []
_RealLeaseFetcher.login_proxy = []
_SpyProvider.acquired = [] _SpyProvider.acquired = []
_SpyProvider.released = [] _SpyProvider.released = []
_SpyProvider.lease = None
def _cian_login_settings(monkeypatch: pytest.MonkeyPatch, *, use_pool: bool, env: str) -> 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())) 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") @pytest.mark.usefixtures("_pool_on")
async def test_cian_auto_login_does_not_lease_from_pool( async def test_cian_auto_login_logs_in_through_pool_lease(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch: pytest.MonkeyPatch, use_pool: bool, environment: str """#3410: прод + узел в пуле → логин идёт через него, аренда отпускается."""
) -> None: _cian_login_settings(monkeypatch, use_pool=True, env="production")
"""Логин пул НЕ арендует: сайдкар на `/login` proxy-override не берёт (см. докстринг).""" _SpyProvider.lease = ProxyLease(id=13, url="http://u:p@pool:8080", kind="http")
_cian_login_settings(monkeypatch, use_pool=use_pool, env=environment)
with ExitStack() as stack: with ExitStack() as stack:
_patched_cian_login(stack, _RealLeaseFetcher) _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, "ручка обязана построить ровно один фетчер" assert len(_RealLeaseFetcher.captured) == 1, "ручка обязана построить ровно один фетчер"
kwargs = _RealLeaseFetcher.captured[0] kwargs = _RealLeaseFetcher.captured[0]
# .get(), а не [] — красное должно читаться как «значение не то», а не KeyError. # .get(), а не [] — красное должно читаться как «значение не то», а не KeyError.
assert kwargs.get("proxy_provider") is None, "аренда на /login холостая — провайдер не нужен" assert kwargs.get("proxy_provider") is not None, "без провайдера логин идёт с env-узла"
assert _SpyProvider.acquired == [], "lease взят впустую (сайдкар его всё равно не увидит)" 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). # endpoint из фабрики не должен потеряться (#2322: без него TypeError).
from app.core.config import settings 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") @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, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
"""Пустой пул на проде НЕ ломает ручку восстановления сессии (нет ложного 502). """Пустой пул на проде → 503 «нет узла для cian», логин не запускается.
Ровно тот сценарий, ради которого пул отсюда убран: `_acquire_lease` при Не 502 «Browser login failed» (читается как сломанные селекторы/креды) и не заход на
`use_pool + provider + production + пустой пул` поднимает `NoProxyAvailableError`, env-узел сайдкара (#2616: на проде он мёртв).
`cian_auto_login` ловит любое `Exception` и отдаёт `502 Browser login failed` то
есть инцидент с пулом закрывал бы единственный способ переполучить cian-сессию,
хотя логину пул не нужен (сайдкар proxy-override на `/login` не принимает).
""" """
_cian_login_settings(monkeypatch, use_pool=True, env="production") _cian_login_settings(monkeypatch, use_pool=True, env="production")
with ExitStack() as stack: with ExitStack() as stack:
_patched_cian_login(stack, _RealLeaseFetcher) _patched_cian_login(stack, _RealLeaseFetcher)
try: with pytest.raises(HTTPException) as exc_info:
result = await admin.cian_auto_login(db=MagicMock(), body=None) 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}")
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")]) @pytest.mark.parametrize(("use_pool", "environment"), [(True, "production"), (False, "dev")])

View file

@ -640,3 +640,36 @@ async def test_report_ban_does_not_release_or_rotate_lease() -> None:
assert provider.released == [] assert provider.released == []
assert bf._lease is not None assert bf._lease is not None
assert bf._lease.id == 5 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 == []

View file

@ -107,10 +107,12 @@ Per-provider модель (#1793):
поиск yandex.ru, а не голым goto (#3251). Ручная сессия поиск yandex.ru, а не голым goto (#3251). Ручная сессия
29.08.2026 показала эталонный человеческий путь: 29.08.2026 показала эталонный человеческий путь:
yandex.ru клик по результату выдача с Referer yandex.ru клик по результату выдача с Referer
yandex.ru. Дефолт "domclick" авито/циан/яндекс yandex.ru. Дефолт пусто, выключено везде (#3263):
проверяются отдельно в #3251, не включать здесь. выдача Яндекса капчит конкретные выходные узлы пула,
Пустая строка = выключено везде (полный откат к и путь срабатывал ~2 раза из 9. Якорь поднимается
прежнему поведению). Провайдер без соответствующего прямым goto на выдачу площадки, Referer карточке даёт
переход с неё. Включать только явным env и только
после замера на узлах пула. Провайдер без соответствующего
BROWSER_ANCHOR_SEARCH_QUERY_{PROVIDER} поиск тоже не BROWSER_ANCHOR_SEARCH_QUERY_{PROVIDER} поиск тоже не
делает см. ниже. делает см. ниже.
BROWSER_ANCHOR_SEARCH_QUERY_{PROVIDER} поисковый запрос на yandex.ru для BROWSER_ANCHOR_SEARCH_QUERY_{PROVIDER} поисковый запрос на yandex.ru для
@ -143,6 +145,7 @@ import logging
import os import os
import random import random
import re import re
import time
from collections.abc import Callable, Mapping from collections.abc import Callable, Mapping
from typing import NamedTuple from typing import NamedTuple
from urllib.parse import quote, urlparse from urllib.parse import quote, urlparse
@ -329,12 +332,15 @@ BROWSER_PAGE_INTERVAL_JITTER_S: float = float(
) )
# Провайдеры, для которых якорная вкладка (#3251) заходит на origin ЧЕРЕЗ реальный # Провайдеры, для которых якорная вкладка (#3251) заходит на origin ЧЕРЕЗ реальный
# поиск yandex.ru, а не голым goto. Дефолт "domclick" — авито/циан/яндекс проверяются # поиск yandex.ru, а не голым goto. Дефолт — пусто (#3263): капча на выдаче Яндекса —
# отдельно (#3251), не расширять этот список без замера на них. Пустая строка env = # свойство выходного узла (замер владельца 29.08: узлы 1/10/11 — капча, 9 и домашний
# выключено везде — полный откат на поведение до #3251. # IP — выдача), в проде путь срабатывал ~2 из 9, а в логах 15-16.09 все 7 капч
# закончились «якорная вкладка не поднялась (Error)» через 0,2 с после отката — якоря
# не было вовсе. Без поиска якорь сразу открывает выдачу площадки, карточка идёт с
# referer=origin — переход внутри площадки. Включать только явным env после замера.
_ANCHOR_VIA_SEARCH_PROVIDERS: frozenset[str] = frozenset( _ANCHOR_VIA_SEARCH_PROVIDERS: frozenset[str] = frozenset(
p.strip().lower() 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() if p.strip()
) )
@ -832,6 +838,12 @@ _last_goto_at: dict[str, float] = {} # provider → loop-time последн
# _fetch_once (сбрасывается в None перед навигацией, чтобы не отдать чужой # _fetch_once (сбрасывается в None перед навигацией, чтобы не отдать чужой
# протухший статус), читается fetch_handler'ом под тем же _locks[provider] — гонки нет. # протухший статус), читается fetch_handler'ом под тем же _locks[provider] — гонки нет.
_last_response_status: dict[str, int | None] = {} _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 или динамический # #2164 P4: proxy-url, с которым СЕЙЧАС запущен инстанс провайдера (env или динамический
# из пула, переданный в теле /fetch). Нужен для политики «relaunch ТОЛЬКО при реальной # из пула, переданный в теле /fetch). Нужен для политики «relaunch ТОЛЬКО при реальной
# смене прокси» — camoufox берёт proxy на launch, релонч дорогой, поэтому не релончим, # смене прокси» — 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), "block_images": _BLOCK_IMAGES_BY_PROVIDER.get(provider, _BLOCK_IMAGES_DEFAULT_FALLBACK),
# Анти-leak: WebRTC может слить реальный IP контейнера мимо прокси → деанон. # Анти-leak: WebRTC может слить реальный IP контейнера мимо прокси → деанон.
"block_webrtc": True, "block_webrtc": True,
# Форсим таймзону +3 МСК. geoip берёт tz одного IP на старте, а БэкКоннект # Таймзону НЕ задаём (#3187): её ставит geoip=True по exit-IP на запуске —
# ротирует IP на каждый запрос (видели Ульяновск UTC+4 при выходе СПб/МСК) → # из той же GeoIP-записи, что и координаты geolocation. Жёсткий
# геолик tz↔IP. Жёсткий Europe/Moscow убирает рассинхрон для РФ-мобильного. # Europe/Moscow в config (было) camoufox 0.5.5 применял поверх geoip
"config": {"timezone": "Europe/Moscow"}, # (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: if proxy is not None:
kwargs["proxy"] = proxy kwargs["proxy"] = proxy
@ -1454,8 +1472,9 @@ async def fetch_handler(request: web.Request) -> web.Response:
status = _last_response_status.get(provider) status = _last_response_status.get(provider)
except Exception as exc: except Exception as exc:
logger.error( logger.error(
"tradein-browser[%s]: fetch error url=%r: %s: %s", "tradein-browser[%s]: fetch error nav_ms=%s url=%r: %s: %s",
provider, provider,
_last_nav_ms.get(provider),
url, url,
type(exc).__name__, type(exc).__name__,
exc, exc,
@ -2349,6 +2368,7 @@ async def _fetch_once(
# Гасим статус прошлой навигации ДО работы: если goto упадёт, наверх не должен # Гасим статус прошлой навигации ДО работы: если goto упадёт, наверх не должен
# уехать статус предыдущей страницы этого же провайдера (#3196). # уехать статус предыдущей страницы этого же провайдера (#3196).
_last_response_status[provider] = None _last_response_status[provider] = None
_last_nav_ms[provider] = None
if reset_context: if reset_context:
await _close_reusable_context(provider) await _close_reusable_context(provider)
@ -2423,7 +2443,11 @@ async def _fetch_once(
} }
if referer: if referer:
goto_kwargs["referer"] = referer goto_kwargs["referer"] = referer
nav_started = time.monotonic()
try:
response = await page.goto(url, **goto_kwargs) # type: ignore[attr-defined] 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) _last_response_status[provider] = _status_of(response)
if BROWSER_WAIT_MS > 0: if BROWSER_WAIT_MS > 0:
await page.wait_for_timeout(BROWSER_WAIT_MS) # type: ignore[attr-defined] 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] await page.close() # type: ignore[attr-defined]
_page_counters[provider] = _page_counters.get(provider, 0) + 1 _page_counters[provider] = _page_counters.get(provider, 0) + 1
logger.debug( # INFO, а не DEBUG (#3419): без строк успеха распределение времени навигации не
"tradein-browser[%s]: fetch OK url=%r pages_since_launch=%d", # снять, а сравнивать его с таймаутами и есть цель.
logger.info(
"tradein-browser[%s]: fetch OK nav_ms=%s url=%r pages_since_launch=%d",
provider, provider,
_last_nav_ms.get(provider),
url, url,
_page_counters[provider], _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) return web.json_response({"error": f"missing required fields: {missing}"}, status=400)
provider = _resolve_provider(body, body["url"]) 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. # отказ, не direct-IP.
if IS_PROD and _no_live_proxy(provider, None): if IS_PROD and _no_live_proxy(provider, proxy_override):
logger.warning( logger.warning(
"tradein-browser[%s]: /login отказ — нет env-прокси (prod) — " "tradein-browser[%s]: /login отказ — нет прокси в теле и нет env-прокси (prod) — "
"не подключаемся напрямую с IP сервера (#2616)", "не подключаемся напрямую с IP сервера (#2616)",
provider, provider,
) )
@ -2828,7 +2859,7 @@ async def login_handler(request: web.Request) -> web.Response:
async with lock: async with lock:
# Та же resilience что и в /fetch: браузер мог не подняться (прокси лежал). # Та же resilience что и в /fetch: браузер мог не подняться (прокси лежал).
# Lazy-попытка, иначе 503 — без прокси не логинимся. # Lazy-попытка, иначе 503 — без прокси не логинимся.
if not await _ensure_browser(provider): if not await _ensure_browser(provider, proxy_override=proxy_override):
logger.warning( logger.warning(
"tradein-browser[%s]: /login 503 — браузер недоступен (proxy may be down)", "tradein-browser[%s]: /login 503 — браузер недоступен (proxy may be down)",
provider, provider,

View file

@ -34,9 +34,12 @@ _HOSTNAME = "ekaterinburg.domclick.ru"
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _reset_state() -> None: def _reset_state(monkeypatch: pytest.MonkeyPatch) -> None:
server._contexts = {} server._contexts = {}
server._anchor_pages = {} server._anchor_pages = {}
# Путь через поиск выключен по умолчанию (#3263) — тесты ниже проверяют его
# поведение при ЯВНОМ включении env'ом BROWSER_ANCHOR_VIA_SEARCH=domclick.
monkeypatch.setattr(server, "_ANCHOR_VIA_SEARCH_PROVIDERS", frozenset({"domclick"}))
class _FakeLink: 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: def test_anchor_via_search_visits_yandex_and_clicks_result() -> None:
"""domclick включён по умолчанию: заход на yandex.ru → клик по ссылке на хост.""" """domclick включён env'ом: заход на yandex.ru → клик по ссылке на хост."""
page = _FakeAnchorPage(link_host=_HOSTNAME) page = _FakeAnchorPage(link_host=_HOSTNAME)
anchored = _run_ensure_anchor("domclick", _ORIGIN, page) 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: 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" origin = "https://www.avito.ru/ekaterinburg/kvartiry"
page = _FakeAnchorPage(link_host="www.avito.ru") 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 origin_calls[0]["referer"] is None
assert page.opened_popup is not None assert page.opened_popup is not None
assert page.opened_popup.closed == 1, "попап не на том хосте должен быть закрыт" 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}]

View file

@ -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 "<html><body>карточка</body></html>"
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]

View file

@ -267,6 +267,52 @@ def test_fetch_handler_prod_with_body_override_unaffected(monkeypatch: pytest.Mo
assert calls == [("avito", "http://pool:8080")] 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)" ──────── # ── распознаваемость: отказ ≠ "browser unavailable (proxy may be down)" ────────

View file

@ -149,3 +149,46 @@ def test_ensure_browser_env_then_pool_relaunches(monkeypatch: pytest.MonkeyPatch
asyncio.run(_run()) asyncio.run(_run())
assert calls == [("avito", None), ("avito", "http://pool:8080")] 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 == []

View file

@ -656,6 +656,13 @@ class BrowserFetcher:
} }
if wait_ms is not None: if wait_ms is not None:
body["wait_ms"] = wait_ms 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: try:
return await self._post_login(body) return await self._post_login(body)

View file

@ -212,13 +212,10 @@ def build_browser_fetcher(
Служебные ручки `app/api/v1/admin.py` (cian-login, domclick-detail-debug) были Служебные ручки `app/api/v1/admin.py` (cian-login, domclick-detail-debug) были
последними прямыми конструкциями мимо фабрики переведены сюда же (#3197 хвост); последними прямыми конструкциями мимо фабрики переведены сюда же (#3197 хвост);
прямые конструкции в `orchestration/pipeline.py` и в backfill-задачах пул получают прямые конструкции в `orchestration/pipeline.py` и в backfill-задачах пул получают
своими kwargs. Живой `proxy_provider=None` остался ровно один cian-login: сайдкар своими kwargs. cian-login тоже с провайдером (#3410): сайдкар на `/login` берёт proxy
на `/login` proxy-override не берёт (`browser/server.py::login_handler` из тела, `BrowserFetcher.login` кладёт туда узел аренды. `use_pool` при
`_no_live_proxy(provider, None)`, а `_post_login` не кладёт `payload["proxy"]`), так `proxy_provider is None` игнорируется `BrowserFetcher` (`_acquire_lease`:
что аренда там была бы холостой, а на проде при пустом пуле роняла бы ручку `use_pool AND provider is not None`) ни аренды, ни прод-отказа.
восстановления в 502. `use_pool` при `proxy_provider is None` игнорируется
`BrowserFetcher` (`_acquire_lease`: `use_pool AND provider is not None`) ни аренды,
ни прод-отказа, поведение как до фабрики.
`fetch_timeout_s=None` (дефолт) используется дефолт `BrowserFetcher` `fetch_timeout_s=None` (дефолт) используется дефолт `BrowserFetcher`
(120s). Явный таймаут передаёт ровно один call-site `yandex/serp.py` (30s); (120s). Явный таймаут передаёт ровно один call-site `yandex/serp.py` (30s);