Сайдкар МЕРЫ: зона браузера по IP узла, якорь Домклика без Яндекса, автологин Циана через пул, время навигации в логах #3563
10 changed files with 437 additions and 78 deletions
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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")])
|
||||||
|
|
|
||||||
|
|
@ -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 == []
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}]
|
||||||
|
|
|
||||||
156
tradein-mvp/browser/test_server_nav_timing.py
Normal file
156
tradein-mvp/browser/test_server_nav_timing.py
Normal 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]
|
||||||
|
|
@ -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)" ────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 == []
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue