From eade88bc662ad3cfb78ef703773b5810c6c61ab5 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:55:32 +0500 Subject: [PATCH 1/2] =?UTF-8?q?fix(tradein):=20config=20=D0=BE=D0=B1=D1=8F?= =?UTF-8?q?=D0=B7=D0=B0=D1=82=D0=B5=D0=BB=D0=B5=D0=BD=20=D0=B2=20newbuildi?= =?UTF-8?q?ng-=D0=BF=D1=80=D0=BE=D0=B2=D0=B0=D0=B9=D0=B4=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D1=85=20=D0=A6=D0=B8=D0=B0=D0=BD=D0=B0=20=D0=B8=20=D0=AF=D0=BD?= =?UTF-8?q?=D0=B4=D0=B5=D0=BA=D1=81=D0=B0=20(#2385)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetch_newbuilding, resolve_cian_zhk_url_via_search, YandexNewbuildingScraper и resolve_yandex_jk_slug принимали config=None и в этом случае строили BrowserFetcher(endpoint=None) мимо фабрики, а резолвер Циана — сессию без прокси. Все боевые вызовы config уже передают, но новый вызывающий без config молча ушёл бы на env-узел сайдкара мимо пула — именно так потеряли 8 суток в #2767 и сломались в #3197. Теперь config обязателен по сигнатуре, запасные ветки удалены, фетчер строит только build_browser_fetcher. Тесты: кейсы *_without_config (ждали endpoint=None) заменены одним — без config TypeError до сети, фетчер и сессия не строятся; остальные тестовые вызовы получили config. Co-Authored-By: Claude Opus 5 --- .../scrapers/test_cian_resolve_zhk_url.py | 11 +- .../test_scraper_kit_newbuilding_endpoint.py | 103 +++++++----------- .../backend/tests/test_scraper_proxy.py | 14 ++- .../test_yandex_scrapers_delay_wiring.py | 2 +- .../scraper_kit/providers/cian/newbuilding.py | 24 ++-- .../providers/yandex/newbuilding.py | 22 ++-- 6 files changed, 74 insertions(+), 102 deletions(-) diff --git a/tradein-mvp/backend/tests/services/scrapers/test_cian_resolve_zhk_url.py b/tradein-mvp/backend/tests/services/scrapers/test_cian_resolve_zhk_url.py index f3b3519e..6459bbbc 100644 --- a/tradein-mvp/backend/tests/services/scrapers/test_cian_resolve_zhk_url.py +++ b/tradein-mvp/backend/tests/services/scrapers/test_cian_resolve_zhk_url.py @@ -15,6 +15,7 @@ from __future__ import annotations import os import sys from pathlib import Path +from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock # DATABASE_URL required by config before any app import. @@ -28,6 +29,8 @@ from scraper_kit.providers.cian.newbuilding import ( # noqa: E402 resolve_cian_zhk_url_via_search, ) +# config обязателен (#2385); на пути с переданной session он не читается. +_CONFIG = SimpleNamespace(cian_proxy_url=None) _FIXTURE = Path(__file__).resolve().parents[2] / "fixtures" / "cian_catph_newbuilding_serp.html" @@ -114,7 +117,7 @@ def test_extract_zhk_url_no_match_returns_none() -> None: async def test_resolve_via_search_happy_path() -> None: """HTTP 200 + a SERP containing the slug → canonical url; session reused, not closed.""" session = _mock_session(status_code=200, text=_serp_fixture()) - url = await resolve_cian_zhk_url_via_search(48853, session=session) + url = await resolve_cian_zhk_url_via_search(48853, config=_CONFIG, session=session) assert url == "https://zhk-parkovyy-kvartal-ekb-i.cian.ru" # caller owns a passed-in session — resolver must NOT close it. session.close.assert_not_awaited() @@ -128,14 +131,14 @@ async def test_resolve_via_search_happy_path() -> None: async def test_resolve_via_search_no_match_returns_none() -> None: """HTTP 200 but no zhk-slug in the body → None (empty SERP / markup drift).""" session = _mock_session(status_code=200, text="no results") - assert await resolve_cian_zhk_url_via_search(999, session=session) is None + assert await resolve_cian_zhk_url_via_search(999, config=_CONFIG, session=session) is None @pytest.mark.asyncio async def test_resolve_via_search_non_200_returns_none() -> None: """A non-200 (block / 404) → None without attempting extraction.""" session = _mock_session(status_code=429, text="too many requests") - assert await resolve_cian_zhk_url_via_search(48853, session=session) is None + assert await resolve_cian_zhk_url_via_search(48853, config=_CONFIG, session=session) is None @pytest.mark.asyncio @@ -144,4 +147,4 @@ async def test_resolve_via_search_swallows_request_error() -> None: session = MagicMock() session.get = AsyncMock(side_effect=ConnectionError("proxy refused")) session.close = AsyncMock() - assert await resolve_cian_zhk_url_via_search(48853, session=session) is None + assert await resolve_cian_zhk_url_via_search(48853, config=_CONFIG, session=session) is None diff --git a/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py b/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py index ae15377e..755cb7c7 100644 --- a/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py +++ b/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py @@ -23,6 +23,10 @@ through `build_curl_cffi_session` — so a missing `config=` does NOT raise like (non-proxied) connection (`cian_proxy_url` dropped). That is much harder to notice in prod (no crash, just quietly unproxied traffic that anti-bot can throttle/block), so a caller that forgets `config=` here gets no signal at all. + +#2385: `config` стал обязательным во всех четырёх точках входа. Прежние кейсы +`*_without_config` (ждали endpoint=None / proxy_url=None) заменены одним тестом +«без config — TypeError, фетчер и сессия не строятся». """ from __future__ import annotations @@ -42,6 +46,7 @@ from scraper_kit.providers.yandex.newbuilding import ( _TEST_ENDPOINT = "http://test-tradein-browser:9009/fetch" _TEST_PROXY = "http://mobile-proxy.example:8080" +_CONFIG = SimpleNamespace(browser_http_endpoint=_TEST_ENDPOINT, use_proxy_pool_browser=False) def _spy_browser_fetcher(html: str) -> MagicMock: @@ -77,21 +82,6 @@ async def test_cian_fetch_newbuilding_passes_endpoint_from_config(monkeypatch): assert kwargs.get("endpoint") == _TEST_ENDPOINT -@pytest.mark.asyncio -async def test_cian_fetch_newbuilding_endpoint_none_without_config(monkeypatch): - """Без config= (backward-compat) endpoint=None передаётся явным kwarg'ом — сигнатура - больше не роняет TypeError на отсутствующем обязательном параметре конструктора.""" - spy = _spy_browser_fetcher("") - monkeypatch.setattr("scraper_kit.providers.cian.newbuilding.BrowserFetcher", spy) - - await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/") - - spy.assert_called_once() - _, kwargs = spy.call_args - assert kwargs.get("source") == "cian" - assert kwargs.get("endpoint") is None - - # ── yandex.newbuilding.YandexNewbuildingScraper.fetch_jk ───────────────────── @@ -115,21 +105,6 @@ async def test_yandex_fetch_jk_passes_endpoint_from_config(monkeypatch): assert kwargs.get("endpoint") == _TEST_ENDPOINT -@pytest.mark.asyncio -async def test_yandex_fetch_jk_endpoint_none_without_config(monkeypatch): - """Без config= (backward-compat) endpoint=None передаётся явным kwarg'ом.""" - spy = _spy_browser_fetcher("") - monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy) - - scraper = YandexNewbuildingScraper() - await scraper.fetch_jk(jk_slug="tatlin", jk_id="1592987") - - spy.assert_called_once() - _, kwargs = spy.call_args - assert kwargs.get("source") == "yandex" - assert kwargs.get("endpoint") is None - - # ── yandex.newbuilding.resolve_yandex_jk_slug ──────────────────────────────── @@ -151,20 +126,6 @@ async def test_resolve_yandex_jk_slug_passes_endpoint_from_config(monkeypatch): assert kwargs.get("endpoint") == _TEST_ENDPOINT -@pytest.mark.asyncio -async def test_resolve_yandex_jk_slug_endpoint_none_without_config(monkeypatch): - """Без config= (backward-compat) endpoint=None передаётся явным kwarg'ом.""" - spy = _spy_browser_fetcher("") - monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy) - - await resolve_yandex_jk_slug("1592987") - - spy.assert_called_once() - _, kwargs = spy.call_args - assert kwargs.get("source") == "yandex" - assert kwargs.get("endpoint") is None - - # ── #2860: slug ищется по СТРАНИЦЕ ЖК, а не по фильтру выдачи ──────────────── # # Замер 19.08.2026 через браузерный сайдкар прода: `?siteId=` Яндексом @@ -195,9 +156,9 @@ async def test_resolve_yandex_jk_slug_requests_jk_page_not_serp_filter(monkeypat Это и есть суть правки: адрес запроса, а не разбор ответа. """ spy = _spy_browser_fetcher(_JK_PAGE_HTML) - monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy) + monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", spy) - await resolve_yandex_jk_slug("286394") + await resolve_yandex_jk_slug("286394", config=_CONFIG) url = spy.return_value.fetch.call_args[0][0] assert "siteId=" not in url, f"фильтр выдачи больше не работает, а запрос идёт туда: {url}" @@ -208,9 +169,9 @@ async def test_resolve_yandex_jk_slug_requests_jk_page_not_serp_filter(monkeypat async def test_resolve_yandex_jk_slug_reads_real_slug_from_page(monkeypatch): """Со страницы ЖК берётся настоящий slug, а не плейсхолдер из нашего же URL.""" spy = _spy_browser_fetcher(_JK_PAGE_HTML) - monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy) + monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", spy) - assert await resolve_yandex_jk_slug("286394") == "uspenskij" + assert await resolve_yandex_jk_slug("286394", config=_CONFIG) == "uspenskij" @pytest.mark.asyncio @@ -221,9 +182,9 @@ async def test_resolve_yandex_jk_slug_returns_none_on_general_list(monkeypatch): Без привязки к id функция вернула бы «shishkinn» для чужого дома. """ spy = _spy_browser_fetcher(_GENERAL_LIST_HTML) - monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy) + monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", spy) - assert await resolve_yandex_jk_slug("286394") is None + assert await resolve_yandex_jk_slug("286394", config=_CONFIG) is None @pytest.mark.asyncio @@ -232,9 +193,9 @@ async def test_resolve_yandex_jk_slug_does_not_return_placeholder(monkeypatch): spy = _spy_browser_fetcher( 'сама страница' ) - monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy) + monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", spy) - assert await resolve_yandex_jk_slug("286394") is None + assert await resolve_yandex_jk_slug("286394", config=_CONFIG) is None # ── cian.newbuilding.resolve_cian_zhk_url_via_search ───────────────────────── @@ -276,17 +237,33 @@ async def test_resolve_cian_zhk_url_via_search_passes_proxy_from_config(monkeypa assert kwargs.get("proxy_url") == _TEST_PROXY +# ── #2385: config обязателен — без него ни фетчера, ни сессии ──────────────── + + @pytest.mark.asyncio -async def test_resolve_cian_zhk_url_via_search_proxy_none_without_config(monkeypatch): - """Без config= (SILENT footgun): вызов НЕ падает (в отличие от BrowserFetcher - assertion выше в этом файле), но proxy_url молча становится None — резолвер уходит - на прямое (непроксированное) соединение вместо настроенного мобильного прокси.""" - spy = _spy_curl_session(_SERP_HTML) - monkeypatch.setattr("scraper_kit.providers.cian.newbuilding.build_curl_cffi_session", spy) +@pytest.mark.parametrize( + "call", + [ + lambda: fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/"), + lambda: YandexNewbuildingScraper().fetch_jk(jk_slug="tatlin", jk_id="1592987"), + lambda: resolve_yandex_jk_slug("1592987"), + lambda: resolve_cian_zhk_url_via_search(48853), + ], + ids=["cian_fetch_newbuilding", "yandex_fetch_jk", "resolve_yandex_slug", "resolve_cian_url"], +) +async def test_newbuilding_entrypoints_refuse_to_run_without_config(monkeypatch, call): + """Вызов без config= падает TypeError ДО сети: браузерный фетчер и curl-сессия не + строятся. Раньше тот же вызов молча уходил на env-узел сайдкара мимо пула (#2767, + #3197) — отказ по сигнатуре единственное, что ловит забывчивого вызывающего.""" + browser_spy = _spy_browser_fetcher("") + monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", browser_spy) + session_spy = _spy_curl_session(_SERP_HTML) + monkeypatch.setattr( + "scraper_kit.providers.cian.newbuilding.build_curl_cffi_session", session_spy + ) - url = await resolve_cian_zhk_url_via_search(48853) + with pytest.raises(TypeError, match="config"): + await call() - assert url == "https://zhk-parkovyy-kvartal-ekb-i.cian.ru" # "works" — silently unproxied - spy.assert_called_once() - _, kwargs = spy.call_args - assert kwargs.get("proxy_url") is None + assert browser_spy.call_count == 0 + assert session_spy.call_count == 0 diff --git a/tradein-mvp/backend/tests/test_scraper_proxy.py b/tradein-mvp/backend/tests/test_scraper_proxy.py index 165539d8..0a3368cc 100644 --- a/tradein-mvp/backend/tests/test_scraper_proxy.py +++ b/tradein-mvp/backend/tests/test_scraper_proxy.py @@ -199,6 +199,10 @@ async def test_yandex_valuation_session_no_proxies_when_none(): # После #972 fetch_newbuilding тянет страницу ЖК через BrowserFetcher (camoufox), # а НЕ через curl_cffi AsyncSession. Прокси применяются на уровне браузер-контейнера, # а не через proxies= kwarg. AsyncSession для page-fetch больше НЕ создаётся. +# config обязателен (#2385): фетчер строит фабрика _base.build_browser_fetcher. +_NB_CONFIG = SimpleNamespace( + browser_http_endpoint="http://tradein-browser:3000", use_proxy_pool_browser=False +) @pytest.mark.asyncio @@ -222,8 +226,8 @@ async def test_cian_newbuilding_own_session_receives_proxies(): mock_fetcher.__aenter__ = AsyncMock(return_value=mock_fetcher) mock_fetcher.__aexit__ = AsyncMock(return_value=None) - with patch.object(cian_newbuilding, "BrowserFetcher", lambda *a, **k: mock_fetcher): - await cian_newbuilding.fetch_newbuilding(zhk_url) + with patch("scraper_kit.providers._base.BrowserFetcher", lambda *a, **k: mock_fetcher): + await cian_newbuilding.fetch_newbuilding(zhk_url, config=_NB_CONFIG) # BrowserFetcher.fetch вызван с ЖК-url assert fetch_calls == [zhk_url] @@ -253,8 +257,10 @@ async def test_cian_newbuilding_shared_session_not_recreated(): sentinel_session = MagicMock(name="external_session") - with patch.object(cian_newbuilding, "BrowserFetcher", lambda *a, **k: mock_fetcher): - await cian_newbuilding.fetch_newbuilding(zhk_url, session=sentinel_session) + with patch("scraper_kit.providers._base.BrowserFetcher", lambda *a, **k: mock_fetcher): + await cian_newbuilding.fetch_newbuilding( + zhk_url, config=_NB_CONFIG, session=sentinel_session + ) # BrowserFetcher.fetch вызван — именно через него тянется страница assert fetch_calls == [zhk_url] diff --git a/tradein-mvp/backend/tests/test_yandex_scrapers_delay_wiring.py b/tradein-mvp/backend/tests/test_yandex_scrapers_delay_wiring.py index f5e413fc..b4df3078 100644 --- a/tradein-mvp/backend/tests/test_yandex_scrapers_delay_wiring.py +++ b/tradein-mvp/backend/tests/test_yandex_scrapers_delay_wiring.py @@ -31,7 +31,7 @@ def test_yandex_detail_picks_up_delay(): def test_yandex_newbuilding_picks_up_delay(): """Kit YandexNewbuildingScraper: delay_provider callable вызывается с self.name.""" - s = YandexNewbuildingScraper(delay_provider=lambda _name: 9.5) + s = YandexNewbuildingScraper(config=_KIT_CONFIG, delay_provider=lambda _name: 9.5) assert s.request_delay_sec == 9.5 diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py index 74a239b0..b59a4aaa 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py @@ -24,7 +24,6 @@ import re from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any -from scraper_kit.browser_fetcher import BrowserFetcher from scraper_kit.cian_exceptions import CianBlockedError from scraper_kit.cian_state_parser import extract_all_states, extract_state from scraper_kit.providers._base import build_browser_fetcher, build_curl_cffi_session @@ -214,7 +213,7 @@ class NewbuildingEnrichment: async def fetch_newbuilding( zhk_url: str, *, - config: ScraperConfig | None = None, + config: ScraperConfig, session: AsyncSession | None = None, # kept for backward-compat; unused for page fetch proxy_provider: ProxyProvider | None = None, ) -> NewbuildingEnrichment | None: @@ -225,9 +224,8 @@ async def fetch_newbuilding( Args: zhk_url: e.g. 'https://zhk-ekaterininskiy-park-ekb-i.cian.ru/' - config: ScraperConfig — источник browser_http_endpoint для BrowserFetcher - (#2322 fix: раньше не принимался вообще, BrowserFetcher(source="cian") - конструировался без endpoint= → TypeError на любом вызове). + config: ScraperConfig — источник browser_http_endpoint/use_pool для BrowserFetcher. + Обязателен (#2385): без него фетчер строился с endpoint=None мимо пула. session: зарезервирован для обратной совместимости с вызывающими; больше не используется для fetch страницы ЖК. Resolve-функции (resolve_cian_zhk_url_via_search) по-прежнему используют curl_cffi. @@ -254,14 +252,10 @@ async def fetch_newbuilding( # Разметка не менялась: MFE 'newbuilding-card-desktop-frontend'/'initialState' # разобрался ТЕКУЩИМ кодом на всех трёх мобильных узлах. Ломался тракт, не разбор. # - # config=None остаётся законным (admin.py debug-роут и - # test_cian_fetch_newbuilding_endpoint_none_without_config зовут без config= и - # ждут graceful endpoint=None, а не AttributeError), поэтому фабрика применяется - # только когда config есть — она требует ScraperConfig mandatory by design. - if config is not None: - browser = build_browser_fetcher(config, "cian", proxy_provider=proxy_provider) - else: - browser = BrowserFetcher(source="cian", endpoint=None) # type: ignore[arg-type] + # config обязателен (#2385): запасная ветка `config=None` строила + # BrowserFetcher(endpoint=None) мимо фабрики — новый вызывающий без config молча уходил + # на env-узел сайдкара, тот самый путь, что стоил 8 суток выше. + browser = build_browser_fetcher(config, "cian", proxy_provider=proxy_provider) # ponytail: lease берётся на ОДИН дом (фетчер живёт внутри этой функции), то есть # прогон из 25 домов даёт до 25 acquire → до 25 релончей camoufox (~8 с каждый). @@ -883,7 +877,7 @@ async def _fetch_zhk_slug(session: AsyncSession, nb_id: int) -> str | None: async def resolve_cian_zhk_url_via_search( nb_id: int, *, - config: ScraperConfig | None = None, + config: ScraperConfig, session: AsyncSession | None = None, proxy_provider: ProxyProvider | None = None, ) -> str | None: @@ -928,7 +922,7 @@ async def resolve_cian_zhk_url_via_search( # Own-session path. Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env # cian_proxy_url; пусто → прямое подключение (dev/no-op). # curl_proxy_url: mark_banned на CianBlockedError + mark_health + release на выходе. - _env = config.cian_proxy_url if config is not None else None + _env = config.cian_proxy_url with curl_proxy_url(config, proxy_provider, "cian", env_fallback_url=_env) as _proxy_url: own_session = build_curl_cffi_session( proxy_url=_proxy_url, diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/newbuilding.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/newbuilding.py index 0e94266c..ad071b26 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/newbuilding.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/yandex/newbuilding.py @@ -28,7 +28,6 @@ from pydantic import BaseModel, Field from selectolax.parser import HTMLParser, Node from scraper_kit.base import BaseScraper -from scraper_kit.browser_fetcher import BrowserFetcher from scraper_kit.providers._base import build_browser_fetcher from scraper_kit.proxy_errors import caused_by_no_proxy from scraper_kit.yandex_helpers import ( @@ -158,7 +157,7 @@ class YandexNewbuildingScraper(BaseScraper): def __init__( self, *, - config: ScraperConfig | None = None, + config: ScraperConfig, delay_provider: Callable[[str], float] | None = None, proxy_provider: ProxyProvider | None = None, ) -> None: @@ -193,14 +192,10 @@ class YandexNewbuildingScraper(BaseScraper): """ url = f"{self.base_url}/{city}/kupit/novostrojka/{jk_slug}-{jk_id}/" # #3197: через фабрику — endpoint/use_pool/environment из config mandatory - # (образец: yandex/serp.py::__aenter__). config=None (dev/офлайн-тесты) — - # прежний путь без пула, как в cian/newbuilding.py. - if self._config is not None: - fetcher_cm = build_browser_fetcher( - self._config, "yandex", proxy_provider=self._proxy_provider - ) - else: - fetcher_cm = BrowserFetcher(source="yandex", endpoint=None) # type: ignore[arg-type] + # (образец: yandex/serp.py::__aenter__). Запасной ветки config=None больше нет (#2385). + fetcher_cm = build_browser_fetcher( + self._config, "yandex", proxy_provider=self._proxy_provider + ) try: async with fetcher_cm as fetcher: html = await fetcher.fetch(url) @@ -359,7 +354,7 @@ async def resolve_yandex_jk_slug( jk_id: str, city: str = "ekaterinburg", *, - config: ScraperConfig | None = None, + config: ScraperConfig, proxy_provider: ProxyProvider | None = None, ) -> str | None: """Найти Yandex Realty slug для ЖК по его ext_id (jk_id). @@ -402,10 +397,7 @@ async def resolve_yandex_jk_slug( """ page_url = f"https://realty.yandex.ru/{city}/kupit/novostrojka/{_JK_SLUG_PLACEHOLDER}-{jk_id}/" # #3197: см. fetch_jk — та же проводка пула через фабрику. - if config is not None: - fetcher_cm = build_browser_fetcher(config, "yandex", proxy_provider=proxy_provider) - else: - fetcher_cm = BrowserFetcher(source="yandex", endpoint=None) # type: ignore[arg-type] + fetcher_cm = build_browser_fetcher(config, "yandex", proxy_provider=proxy_provider) try: async with fetcher_cm as fetcher: html = await fetcher.fetch(page_url) From 808a4fb4cbb3d9e7208c90362b75cc2c0057080f Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 17 Sep 2026 12:57:29 +0500 Subject: [PATCH 2/2] =?UTF-8?q?refactor(tradein):=20IMV=20=D0=90=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D0=BE=20=D1=81=D1=82=D1=80=D0=BE=D0=B8=D1=82=20cur?= =?UTF-8?q?l-=D1=81=D0=B5=D1=81=D1=81=D0=B8=D1=8E=20=D0=BE=D0=B1=D1=89?= =?UTF-8?q?=D0=B5=D0=B9=20=D1=84=D0=B0=D0=B1=D1=80=D0=B8=D0=BA=D0=BE=D0=B9?= =?UTF-8?q?,=20=D0=B0=20=D0=BD=D0=B5=20=D1=81=D0=B2=D0=BE=D0=B5=D0=B9=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BF=D0=B8=D0=B5=D0=B9=20(#2386)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit avito/imv.py сам собирал curl_cffi AsyncSession с третьей копией document-заголовков (_DOC_HEADERS) — из-за того, что parity-тесты патчили curl_cffi.requests.AsyncSession, а _base импортирует класс на уровне модуля и такой патч до него не долетает. Взят вариант B из issue: в кодовой базе уже принят патч scraper_kit.providers._base.AsyncSession (test_scraper_proxy.py, test_pipeline_browser_routing.py, test_kit_serp_proxy_pool.py), а вариант A (живой lookup в _base) сломал бы эти тесты — атрибута _base.AsyncSession не стало бы. Сессия теперь build_document_session(proxy_url, timeout=25); _DOC_HEADERS удалён (идентичен DOCUMENT_HEADERS, проверено до правки), мёртвый try/except ImportError убран — curl_cffi и так импортируется через _base. Тесты патчат _base.AsyncSession; ассерт с config дополнительно фиксирует impersonate, timeout=25 и заголовки — параметры сессии не изменились. Co-Authored-By: Claude Opus 5 --- .../scrapers/test_avito_imv_kit_parity.py | 10 ++- .../test_3386_estimator_imv_proxy_pool.py | 4 +- .../src/scraper_kit/providers/avito/imv.py | 85 +++++-------------- 3 files changed, 31 insertions(+), 68 deletions(-) diff --git a/tradein-mvp/backend/tests/scrapers/test_avito_imv_kit_parity.py b/tradein-mvp/backend/tests/scrapers/test_avito_imv_kit_parity.py index 3cc4e08e..7da60d9c 100644 --- a/tradein-mvp/backend/tests/scrapers/test_avito_imv_kit_parity.py +++ b/tradein-mvp/backend/tests/scrapers/test_avito_imv_kit_parity.py @@ -36,6 +36,7 @@ import pytest # Mirror tests/test_scraper_kit_pricehistory_session_parity.py. os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") +from scraper_kit.providers._base import DEFAULT_IMPERSONATE, DOCUMENT_HEADERS from scraper_kit.providers.avito.imv import ( IMVAddressNotFoundError as KitIMVAddressNotFoundError, ) @@ -102,7 +103,7 @@ def test_kit_evaluate_via_imv_without_config_drops_proxy(monkeypatch: pytest.Mon captured.update(kwargs) return mock_session - with patch("curl_cffi.requests.AsyncSession", _fake_session): + with patch("scraper_kit.providers._base.AsyncSession", _fake_session): asyncio.run(_call_kit_own_session(config=None)) assert captured.get("proxies") is None @@ -123,10 +124,15 @@ def test_kit_evaluate_via_imv_with_config_uses_proxy(monkeypatch: pytest.MonkeyP captured.update(kwargs) return mock_session - with patch("curl_cffi.requests.AsyncSession", _fake_session): + with patch("scraper_kit.providers._base.AsyncSession", _fake_session): asyncio.run(_call_kit_own_session(config=RealScraperConfig())) assert captured.get("proxies") == { "http": "http://test-proxy.local:8080", "https": "http://test-proxy.local:8080", } + # #2386: сессия строится общей build_document_session — параметры те же, что были у + # собственной (impersonate/timeout=25/document-заголовки). + assert captured.get("impersonate") == DEFAULT_IMPERSONATE + assert captured.get("timeout") == 25 + assert captured.get("headers") == DOCUMENT_HEADERS diff --git a/tradein-mvp/backend/tests/test_3386_estimator_imv_proxy_pool.py b/tradein-mvp/backend/tests/test_3386_estimator_imv_proxy_pool.py index d91726f3..0e820899 100644 --- a/tradein-mvp/backend/tests/test_3386_estimator_imv_proxy_pool.py +++ b/tradein-mvp/backend/tests/test_3386_estimator_imv_proxy_pool.py @@ -162,7 +162,7 @@ def test_empty_pool_in_production_degrades_without_imv( # патч обязан бить по РЕАЛЬНОМУ атрибуту. С create=True переименование импорта # оставило бы тест зелёным против фантома. patch.object(estimator, "RealProxyProvider", _EmptyPoolProvider), - patch("curl_cffi.requests.AsyncSession", _no_http), + patch("scraper_kit.providers._base.AsyncSession", _no_http), ): result = await _call(_db_cache_miss(), address="ЕКБ, ул. Тургенева, 4") @@ -213,7 +213,7 @@ def _run_with_pool( with ( patch.object(estimator, "RealProxyProvider", lambda: provider), patch.object(estimator, "save_imv_evaluation", return_value=1), - patch("curl_cffi.requests.AsyncSession", lambda *a, **kw: session), + patch("scraper_kit.providers._base.AsyncSession", lambda *a, **kw: session), # Транспорт нам не интересен — проверяем жизненный цикл lease вокруг него. patch("scraper_kit.providers.avito.imv._warmup", new=AsyncMock()), patch( diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py index ae5bd34f..4c9915c2 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/avito/imv.py @@ -34,7 +34,7 @@ from uuid import UUID from sqlalchemy import text from sqlalchemy.orm import Session -from scraper_kit.providers._base import DEFAULT_IMPERSONATE +from scraper_kit.providers._base import DOCUMENT_HEADERS, build_document_session from scraper_kit.providers._proxy import acurl_proxy_url from scraper_kit.providers.avito.shared import _unix_to_date @@ -54,19 +54,6 @@ IMV_ENDPOINT = "/web/1/realty-imv/get-data" # Тёплая страница для seed anti-bot cookies (srv_id/_avisc/u/...) перед XHR. WARMUP_URL = f"{AVITO_BASE}/evaluation/realty" -# Заголовки document-навигации (warm-up GET) — зеркалят production-набор -# из avito.py (AvitoScraper.__aenter__). Нужны чтобы пройти TLS+header anti-bot. -_DOC_HEADERS = { - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", - "Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8", - "Cache-Control": "max-age=0", - "Sec-Fetch-Dest": "document", - "Sec-Fetch-Mode": "navigate", - "Sec-Fetch-Site": "none", - "Sec-Fetch-User": "?1", - "Upgrade-Insecure-Requests": "1", -} - # Заголовки XHR/fetch для API-шагов (coords/position/get-data). Имитируют # fetch() со страницы /evaluation/realty: JSON Accept + same-origin Sec-Fetch. _COMMON_HEADERS = { @@ -486,8 +473,7 @@ async def evaluate_via_imv( # камуфокс in-page fetch (same-origin avito.ru), а не curl_cffi. Прокси + # warmed-cookies + реальный fingerprint из sidecar обходят datacenter-403 # (#562/#853). _own_session=True → finally дёрнет adapter.close() (no-op, - # браузер принадлежит caller'у — backfill открывает один на батч). Этот путь - # НЕ требует curl_cffi, поэтому импорт пакета остаётся только в else-ветке. + # браузер принадлежит caller'у — backfill открывает один на батч). # AsyncExitStack держит прокси-lease пула (#2163) на всё время own-session: warm-up + # geocode + evaluate. Регистрируется ТОЛЬКО когда сами создаём curl_cffi-сессию; # для browser_fetcher / переданной cffi_session — no-op. Исключение из блока @@ -499,53 +485,24 @@ async def evaluate_via_imv( cffi_session = _BrowserSessionAdapter(browser_fetcher, origin=WARMUP_URL) _own_session = True else: - # Импортируем здесь чтобы избежать циклических зависимостей при тестах без сети - try: - from curl_cffi.requests import AsyncSession as CffiAsyncSession - - _own_session = False - if cffi_session is None: - # Зеркалим production-набор из AvitoScraper.__aenter__ (avito.py:150-162): - # impersonate=DEFAULT_IMPERSONATE TLS + document-заголовки + timeout. - # Затем warm-up GET для seed anti-bot cookies — bare-session XHR Avito - # банит на server-IP. - # Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env - # scraper_proxy_url. proxy=None → прямое подключение (dev). - # - # NB (#2361 F4a): НЕ мигрировано на build_document_session() из - # scraper_kit.providers._base, хотя headers=_DOC_HEADERS — тот же - # дублированный dict, что и там. _base.py делает module-level - # `from curl_cffi.requests import AsyncSession` (биндится ОДИН раз - # при первом импорте _base) — tests/scrapers/test_avito_imv_kit_parity.py - # (#2334) патчит `curl_cffi.requests.AsyncSession` через unittest.mock, - # что работает ТОЛЬКО если конструктор вызывается через СВЕЖИЙ lookup - # атрибута на живом module-объекте (как этот локальный import ниже), - # а не через имя, захваченное в чужом module namespace на момент его - # импорта. Мигрировав, ловим 2 real failures - # (test_kit_evaluate_via_imv_{with,without}_config_uses_proxy) — НЕ - # тестовый шум, тест был зелёным на main. Production-поведение - # идентично в обоих вариантах (сеть не отличает откуда взят класс), - # но так как таск прямо запрещает "weaken or skip" parity-тесты — - # оставляю это единственное место немигрированным и репортю как - # finding, а не тихо чиню тест. - _env = config.scraper_proxy_url if config is not None else None - # acurl_proxy_url (#3398): операции пула в потоке — синхронный вход - # стоял ДО первого await и держал event loop `/estimate`. - _proxy_url = await _proxy_stack.enter_async_context( - acurl_proxy_url(config, proxy_provider, "avito", env_fallback_url=_env) - ) - _proxies = {"http": _proxy_url, "https": _proxy_url} if _proxy_url else None - cffi_session = CffiAsyncSession( - impersonate=DEFAULT_IMPERSONATE, - timeout=_HTTP_TIMEOUT_SEC, - proxies=_proxies, - headers=_DOC_HEADERS, - ) - _own_session = True - except ImportError as exc: - raise RuntimeError( - "curl_cffi не установлен. Добавь 'curl-cffi>=0.7.0' в pyproject.toml." - ) from exc + _own_session = False + if cffi_session is None: + # Общая document-сессия из _base (#2386): impersonate=DEFAULT_IMPERSONATE TLS + + # DOCUMENT_HEADERS + timeout. Затем warm-up GET для seed anti-bot cookies — + # bare-session XHR Avito банит на server-IP. + # Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env + # scraper_proxy_url. proxy=None → прямое подключение (dev). + # Тесты подменяют конструктор через `scraper_kit.providers._base.AsyncSession`. + _env = config.scraper_proxy_url if config is not None else None + # acurl_proxy_url (#3398): операции пула в потоке — синхронный вход + # стоял ДО первого await и держал event loop `/estimate`. + _proxy_url = await _proxy_stack.enter_async_context( + acurl_proxy_url(config, proxy_provider, "avito", env_fallback_url=_env) + ) + cffi_session = build_document_session( + proxy_url=_proxy_url, timeout=_HTTP_TIMEOUT_SEC + ) + _own_session = True try: if _own_session: @@ -579,7 +536,7 @@ async def _warmup(session: Any) -> None: всё равно попробуют и дадут типизированную ошибку. """ try: - resp = await session.get(WARMUP_URL, headers=_DOC_HEADERS) + resp = await session.get(WARMUP_URL, headers=DOCUMENT_HEADERS) logger.info("IMV warm-up GET /evaluation/realty → HTTP %d", resp.status_code) except Exception as exc: # warm-up опционален: логируем и продолжаем — шаги ниже дадут типизированную