Merge pull request 'Скрапер: newbuilding-провайдеры без config больше не уходят мимо пула прокси, IMV Авито строит сессию общей фабрикой' (#3562) from fix/kit-providers-config into main
Some checks failed
Deploy Trade-In / test (push) Blocked by required conditions
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / build-frontend (push) Blocked by required conditions
Deploy Trade-In / build-browser (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Has been cancelled

This commit is contained in:
bot-backend 2026-09-17 09:23:57 +00:00
commit 10c80e9e1d
9 changed files with 105 additions and 170 deletions

View file

@ -36,6 +36,7 @@ import pytest
# Mirror tests/test_scraper_kit_pricehistory_session_parity.py. # Mirror tests/test_scraper_kit_pricehistory_session_parity.py.
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost/test_db") 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 ( from scraper_kit.providers.avito.imv import (
IMVAddressNotFoundError as KitIMVAddressNotFoundError, IMVAddressNotFoundError as KitIMVAddressNotFoundError,
) )
@ -102,7 +103,7 @@ def test_kit_evaluate_via_imv_without_config_drops_proxy(monkeypatch: pytest.Mon
captured.update(kwargs) captured.update(kwargs)
return mock_session 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)) asyncio.run(_call_kit_own_session(config=None))
assert captured.get("proxies") is 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) captured.update(kwargs)
return mock_session 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())) asyncio.run(_call_kit_own_session(config=RealScraperConfig()))
assert captured.get("proxies") == { assert captured.get("proxies") == {
"http": "http://test-proxy.local:8080", "http": "http://test-proxy.local:8080",
"https": "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

View file

@ -15,6 +15,7 @@ from __future__ import annotations
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock
# DATABASE_URL required by config before any app import. # 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, 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" _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: async def test_resolve_via_search_happy_path() -> None:
"""HTTP 200 + a SERP containing the slug → canonical url; session reused, not closed.""" """HTTP 200 + a SERP containing the slug → canonical url; session reused, not closed."""
session = _mock_session(status_code=200, text=_serp_fixture()) 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" assert url == "https://zhk-parkovyy-kvartal-ekb-i.cian.ru"
# caller owns a passed-in session — resolver must NOT close it. # caller owns a passed-in session — resolver must NOT close it.
session.close.assert_not_awaited() 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: 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).""" """HTTP 200 but no zhk-slug in the body → None (empty SERP / markup drift)."""
session = _mock_session(status_code=200, text="<html>no results</html>") session = _mock_session(status_code=200, text="<html>no results</html>")
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 @pytest.mark.asyncio
async def test_resolve_via_search_non_200_returns_none() -> None: async def test_resolve_via_search_non_200_returns_none() -> None:
"""A non-200 (block / 404) → None without attempting extraction.""" """A non-200 (block / 404) → None without attempting extraction."""
session = _mock_session(status_code=429, text="too many requests") 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 @pytest.mark.asyncio
@ -144,4 +147,4 @@ async def test_resolve_via_search_swallows_request_error() -> None:
session = MagicMock() session = MagicMock()
session.get = AsyncMock(side_effect=ConnectionError("proxy refused")) session.get = AsyncMock(side_effect=ConnectionError("proxy refused"))
session.close = AsyncMock() 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

View file

@ -162,7 +162,7 @@ def test_empty_pool_in_production_degrades_without_imv(
# патч обязан бить по РЕАЛЬНОМУ атрибуту. С create=True переименование импорта # патч обязан бить по РЕАЛЬНОМУ атрибуту. С create=True переименование импорта
# оставило бы тест зелёным против фантома. # оставило бы тест зелёным против фантома.
patch.object(estimator, "RealProxyProvider", _EmptyPoolProvider), 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") result = await _call(_db_cache_miss(), address="ЕКБ, ул. Тургенева, 4")
@ -213,7 +213,7 @@ def _run_with_pool(
with ( with (
patch.object(estimator, "RealProxyProvider", lambda: provider), patch.object(estimator, "RealProxyProvider", lambda: provider),
patch.object(estimator, "save_imv_evaluation", return_value=1), 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 вокруг него. # Транспорт нам не интересен — проверяем жизненный цикл lease вокруг него.
patch("scraper_kit.providers.avito.imv._warmup", new=AsyncMock()), patch("scraper_kit.providers.avito.imv._warmup", new=AsyncMock()),
patch( patch(

View file

@ -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 (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 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. 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 from __future__ import annotations
@ -42,6 +46,7 @@ from scraper_kit.providers.yandex.newbuilding import (
_TEST_ENDPOINT = "http://test-tradein-browser:9009/fetch" _TEST_ENDPOINT = "http://test-tradein-browser:9009/fetch"
_TEST_PROXY = "http://mobile-proxy.example:8080" _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: 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 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("<html></html>")
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 ───────────────────── # ── 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 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("<html></html>")
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 ──────────────────────────────── # ── 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 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("<html></html>")
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 ищется по СТРАНИЦЕ ЖК, а не по фильтру выдачи ──────────────── # ── #2860: slug ищется по СТРАНИЦЕ ЖК, а не по фильтру выдачи ────────────────
# #
# Замер 19.08.2026 через браузерный сайдкар прода: `?siteId=<id>` Яндексом # Замер 19.08.2026 через браузерный сайдкар прода: `?siteId=<id>` Яндексом
@ -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) 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] url = spy.return_value.fetch.call_args[0][0]
assert "siteId=" not in url, f"фильтр выдачи больше не работает, а запрос идёт туда: {url}" 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): async def test_resolve_yandex_jk_slug_reads_real_slug_from_page(monkeypatch):
"""Со страницы ЖК берётся настоящий slug, а не плейсхолдер из нашего же URL.""" """Со страницы ЖК берётся настоящий slug, а не плейсхолдер из нашего же URL."""
spy = _spy_browser_fetcher(_JK_PAGE_HTML) 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 @pytest.mark.asyncio
@ -221,9 +182,9 @@ async def test_resolve_yandex_jk_slug_returns_none_on_general_list(monkeypatch):
Без привязки к id функция вернула бы «shishkinn» для чужого дома. Без привязки к id функция вернула бы «shishkinn» для чужого дома.
""" """
spy = _spy_browser_fetcher(_GENERAL_LIST_HTML) 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 @pytest.mark.asyncio
@ -232,9 +193,9 @@ async def test_resolve_yandex_jk_slug_does_not_return_placeholder(monkeypatch):
spy = _spy_browser_fetcher( spy = _spy_browser_fetcher(
'<a href="/ekaterinburg/kupit/novostrojka/zhk-286394/">сама страница</a>' '<a href="/ekaterinburg/kupit/novostrojka/zhk-286394/">сама страница</a>'
) )
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 ───────────────────────── # ── 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 assert kwargs.get("proxy_url") == _TEST_PROXY
# ── #2385: config обязателен — без него ни фетчера, ни сессии ────────────────
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_resolve_cian_zhk_url_via_search_proxy_none_without_config(monkeypatch): @pytest.mark.parametrize(
"""Без config= (SILENT footgun): вызов НЕ падает (в отличие от BrowserFetcher "call",
assertion выше в этом файле), но proxy_url молча становится None резолвер уходит [
на прямое (непроксированное) соединение вместо настроенного мобильного прокси.""" lambda: fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/"),
spy = _spy_curl_session(_SERP_HTML) lambda: YandexNewbuildingScraper().fetch_jk(jk_slug="tatlin", jk_id="1592987"),
monkeypatch.setattr("scraper_kit.providers.cian.newbuilding.build_curl_cffi_session", spy) 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("<html></html>")
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 assert browser_spy.call_count == 0
spy.assert_called_once() assert session_spy.call_count == 0
_, kwargs = spy.call_args
assert kwargs.get("proxy_url") is None

View file

@ -195,6 +195,10 @@ async def test_yandex_valuation_session_no_proxies_when_none():
# После #972 fetch_newbuilding тянет страницу ЖК через BrowserFetcher (camoufox), # После #972 fetch_newbuilding тянет страницу ЖК через BrowserFetcher (camoufox),
# а НЕ через curl_cffi AsyncSession. Прокси применяются на уровне браузер-контейнера, # а НЕ через curl_cffi AsyncSession. Прокси применяются на уровне браузер-контейнера,
# а не через proxies= kwarg. AsyncSession для page-fetch больше НЕ создаётся. # а не через 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 @pytest.mark.asyncio
@ -218,8 +222,8 @@ async def test_cian_newbuilding_own_session_receives_proxies():
mock_fetcher.__aenter__ = AsyncMock(return_value=mock_fetcher) mock_fetcher.__aenter__ = AsyncMock(return_value=mock_fetcher)
mock_fetcher.__aexit__ = AsyncMock(return_value=None) mock_fetcher.__aexit__ = AsyncMock(return_value=None)
with patch.object(cian_newbuilding, "BrowserFetcher", lambda *a, **k: mock_fetcher): with patch("scraper_kit.providers._base.BrowserFetcher", lambda *a, **k: mock_fetcher):
await cian_newbuilding.fetch_newbuilding(zhk_url) await cian_newbuilding.fetch_newbuilding(zhk_url, config=_NB_CONFIG)
# BrowserFetcher.fetch вызван с ЖК-url # BrowserFetcher.fetch вызван с ЖК-url
assert fetch_calls == [zhk_url] assert fetch_calls == [zhk_url]
@ -249,8 +253,10 @@ async def test_cian_newbuilding_shared_session_not_recreated():
sentinel_session = MagicMock(name="external_session") sentinel_session = MagicMock(name="external_session")
with patch.object(cian_newbuilding, "BrowserFetcher", lambda *a, **k: mock_fetcher): with patch("scraper_kit.providers._base.BrowserFetcher", lambda *a, **k: mock_fetcher):
await cian_newbuilding.fetch_newbuilding(zhk_url, session=sentinel_session) await cian_newbuilding.fetch_newbuilding(
zhk_url, config=_NB_CONFIG, session=sentinel_session
)
# BrowserFetcher.fetch вызван — именно через него тянется страница # BrowserFetcher.fetch вызван — именно через него тянется страница
assert fetch_calls == [zhk_url] assert fetch_calls == [zhk_url]

View file

@ -31,7 +31,7 @@ def test_yandex_detail_picks_up_delay():
def test_yandex_newbuilding_picks_up_delay(): def test_yandex_newbuilding_picks_up_delay():
"""Kit YandexNewbuildingScraper: delay_provider callable вызывается с self.name.""" """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 assert s.request_delay_sec == 9.5

View file

@ -34,7 +34,7 @@ from uuid import UUID
from sqlalchemy import text from sqlalchemy import text
from sqlalchemy.orm import Session 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._proxy import acurl_proxy_url
from scraper_kit.providers.avito.shared import _unix_to_date 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. # Тёплая страница для seed anti-bot cookies (srv_id/_avisc/u/...) перед XHR.
WARMUP_URL = f"{AVITO_BASE}/evaluation/realty" 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). Имитируют # Заголовки XHR/fetch для API-шагов (coords/position/get-data). Имитируют
# fetch() со страницы /evaluation/realty: JSON Accept + same-origin Sec-Fetch. # fetch() со страницы /evaluation/realty: JSON Accept + same-origin Sec-Fetch.
_COMMON_HEADERS = { _COMMON_HEADERS = {
@ -486,8 +473,7 @@ async def evaluate_via_imv(
# камуфокс in-page fetch (same-origin avito.ru), а не curl_cffi. Прокси + # камуфокс in-page fetch (same-origin avito.ru), а не curl_cffi. Прокси +
# warmed-cookies + реальный fingerprint из sidecar обходят datacenter-403 # warmed-cookies + реальный fingerprint из sidecar обходят datacenter-403
# (#562/#853). _own_session=True → finally дёрнет adapter.close() (no-op, # (#562/#853). _own_session=True → finally дёрнет adapter.close() (no-op,
# браузер принадлежит caller'у — backfill открывает один на батч). Этот путь # браузер принадлежит caller'у — backfill открывает один на батч).
# НЕ требует curl_cffi, поэтому импорт пакета остаётся только в else-ветке.
# AsyncExitStack держит прокси-lease пула (#2163) на всё время own-session: warm-up + # AsyncExitStack держит прокси-lease пула (#2163) на всё время own-session: warm-up +
# geocode + evaluate. Регистрируется ТОЛЬКО когда сами создаём curl_cffi-сессию; # geocode + evaluate. Регистрируется ТОЛЬКО когда сами создаём curl_cffi-сессию;
# для browser_fetcher / переданной cffi_session — no-op. Исключение из блока # для browser_fetcher / переданной cffi_session — no-op. Исключение из блока
@ -499,53 +485,24 @@ async def evaluate_via_imv(
cffi_session = _BrowserSessionAdapter(browser_fetcher, origin=WARMUP_URL) cffi_session = _BrowserSessionAdapter(browser_fetcher, origin=WARMUP_URL)
_own_session = True _own_session = True
else: else:
# Импортируем здесь чтобы избежать циклических зависимостей при тестах без сети _own_session = False
try: if cffi_session is None:
from curl_cffi.requests import AsyncSession as CffiAsyncSession # Общая document-сессия из _base (#2386): impersonate=DEFAULT_IMPERSONATE TLS +
# DOCUMENT_HEADERS + timeout. Затем warm-up GET для seed anti-bot cookies —
_own_session = False # bare-session XHR Avito банит на server-IP.
if cffi_session is None: # Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env
# Зеркалим production-набор из AvitoScraper.__aenter__ (avito.py:150-162): # scraper_proxy_url. proxy=None → прямое подключение (dev).
# impersonate=DEFAULT_IMPERSONATE TLS + document-заголовки + timeout. # Тесты подменяют конструктор через `scraper_kit.providers._base.AsyncSession`.
# Затем warm-up GET для seed anti-bot cookies — bare-session XHR Avito _env = config.scraper_proxy_url if config is not None else None
# банит на server-IP. # acurl_proxy_url (#3398): операции пула в потоке — синхронный вход
# Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env # стоял ДО первого await и держал event loop `/estimate`.
# scraper_proxy_url. proxy=None → прямое подключение (dev). _proxy_url = await _proxy_stack.enter_async_context(
# acurl_proxy_url(config, proxy_provider, "avito", env_fallback_url=_env)
# NB (#2361 F4a): НЕ мигрировано на build_document_session() из )
# scraper_kit.providers._base, хотя headers=_DOC_HEADERS — тот же cffi_session = build_document_session(
# дублированный dict, что и там. _base.py делает module-level proxy_url=_proxy_url, timeout=_HTTP_TIMEOUT_SEC
# `from curl_cffi.requests import AsyncSession` (биндится ОДИН раз )
# при первом импорте _base) — tests/scrapers/test_avito_imv_kit_parity.py _own_session = True
# (#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
try: try:
if _own_session: if _own_session:
@ -579,7 +536,7 @@ async def _warmup(session: Any) -> None:
всё равно попробуют и дадут типизированную ошибку. всё равно попробуют и дадут типизированную ошибку.
""" """
try: 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) logger.info("IMV warm-up GET /evaluation/realty → HTTP %d", resp.status_code)
except Exception as exc: except Exception as exc:
# warm-up опционален: логируем и продолжаем — шаги ниже дадут типизированную # warm-up опционален: логируем и продолжаем — шаги ниже дадут типизированную

View file

@ -24,7 +24,6 @@ import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
from scraper_kit.browser_fetcher import BrowserFetcher
from scraper_kit.cian_exceptions import CianBlockedError from scraper_kit.cian_exceptions import CianBlockedError
from scraper_kit.cian_state_parser import extract_all_states, extract_state 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 from scraper_kit.providers._base import build_browser_fetcher, build_curl_cffi_session
@ -214,7 +213,7 @@ class NewbuildingEnrichment:
async def fetch_newbuilding( async def fetch_newbuilding(
zhk_url: str, zhk_url: str,
*, *,
config: ScraperConfig | None = None, config: ScraperConfig,
session: AsyncSession | None = None, # kept for backward-compat; unused for page fetch session: AsyncSession | None = None, # kept for backward-compat; unused for page fetch
proxy_provider: ProxyProvider | None = None, proxy_provider: ProxyProvider | None = None,
) -> NewbuildingEnrichment | None: ) -> NewbuildingEnrichment | None:
@ -225,9 +224,8 @@ async def fetch_newbuilding(
Args: Args:
zhk_url: e.g. 'https://zhk-ekaterininskiy-park-ekb-i.cian.ru/' zhk_url: e.g. 'https://zhk-ekaterininskiy-park-ekb-i.cian.ru/'
config: ScraperConfig источник browser_http_endpoint для BrowserFetcher config: ScraperConfig источник browser_http_endpoint/use_pool для BrowserFetcher.
(#2322 fix: раньше не принимался вообще, BrowserFetcher(source="cian") Обязателен (#2385): без него фетчер строился с endpoint=None мимо пула.
конструировался без endpoint= TypeError на любом вызове).
session: зарезервирован для обратной совместимости с вызывающими; session: зарезервирован для обратной совместимости с вызывающими;
больше не используется для fetch страницы ЖК. Resolve-функции больше не используется для fetch страницы ЖК. Resolve-функции
(resolve_cian_zhk_url_via_search) по-прежнему используют curl_cffi. (resolve_cian_zhk_url_via_search) по-прежнему используют curl_cffi.
@ -254,14 +252,10 @@ async def fetch_newbuilding(
# Разметка не менялась: MFE 'newbuilding-card-desktop-frontend'/'initialState' # Разметка не менялась: MFE 'newbuilding-card-desktop-frontend'/'initialState'
# разобрался ТЕКУЩИМ кодом на всех трёх мобильных узлах. Ломался тракт, не разбор. # разобрался ТЕКУЩИМ кодом на всех трёх мобильных узлах. Ломался тракт, не разбор.
# #
# config=None остаётся законным (admin.py debug-роут и # config обязателен (#2385): запасная ветка `config=None` строила
# test_cian_fetch_newbuilding_endpoint_none_without_config зовут без config= и # BrowserFetcher(endpoint=None) мимо фабрики — новый вызывающий без config молча уходил
# ждут graceful endpoint=None, а не AttributeError), поэтому фабрика применяется # на env-узел сайдкара, тот самый путь, что стоил 8 суток выше.
# только когда config есть — она требует ScraperConfig mandatory by design. browser = build_browser_fetcher(config, "cian", proxy_provider=proxy_provider)
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]
# ponytail: lease берётся на ОДИН дом (фетчер живёт внутри этой функции), то есть # ponytail: lease берётся на ОДИН дом (фетчер живёт внутри этой функции), то есть
# прогон из 25 домов даёт до 25 acquire → до 25 релончей camoufox (~8 с каждый). # прогон из 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( async def resolve_cian_zhk_url_via_search(
nb_id: int, nb_id: int,
*, *,
config: ScraperConfig | None = None, config: ScraperConfig,
session: AsyncSession | None = None, session: AsyncSession | None = None,
proxy_provider: ProxyProvider | None = None, proxy_provider: ProxyProvider | None = None,
) -> str | None: ) -> str | None:
@ -928,7 +922,7 @@ async def resolve_cian_zhk_url_via_search(
# Own-session path. Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env # Own-session path. Прокси: пул за флагом use_proxy_pool_curl (#2163), иначе env
# cian_proxy_url; пусто → прямое подключение (dev/no-op). # cian_proxy_url; пусто → прямое подключение (dev/no-op).
# curl_proxy_url: mark_banned на CianBlockedError + mark_health + release на выходе. # 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: with curl_proxy_url(config, proxy_provider, "cian", env_fallback_url=_env) as _proxy_url:
own_session = build_curl_cffi_session( own_session = build_curl_cffi_session(
proxy_url=_proxy_url, proxy_url=_proxy_url,

View file

@ -28,7 +28,6 @@ from pydantic import BaseModel, Field
from selectolax.parser import HTMLParser, Node from selectolax.parser import HTMLParser, Node
from scraper_kit.base import BaseScraper 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.providers._base import build_browser_fetcher
from scraper_kit.proxy_errors import caused_by_no_proxy from scraper_kit.proxy_errors import caused_by_no_proxy
from scraper_kit.yandex_helpers import ( from scraper_kit.yandex_helpers import (
@ -158,7 +157,7 @@ class YandexNewbuildingScraper(BaseScraper):
def __init__( def __init__(
self, self,
*, *,
config: ScraperConfig | None = None, config: ScraperConfig,
delay_provider: Callable[[str], float] | None = None, delay_provider: Callable[[str], float] | None = None,
proxy_provider: ProxyProvider | None = None, proxy_provider: ProxyProvider | None = None,
) -> None: ) -> None:
@ -193,14 +192,10 @@ class YandexNewbuildingScraper(BaseScraper):
""" """
url = f"{self.base_url}/{city}/kupit/novostrojka/{jk_slug}-{jk_id}/" url = f"{self.base_url}/{city}/kupit/novostrojka/{jk_slug}-{jk_id}/"
# #3197: через фабрику — endpoint/use_pool/environment из config mandatory # #3197: через фабрику — endpoint/use_pool/environment из config mandatory
# (образец: yandex/serp.py::__aenter__). config=None (dev/офлайн-тесты) — # (образец: yandex/serp.py::__aenter__). Запасной ветки config=None больше нет (#2385).
# прежний путь без пула, как в cian/newbuilding.py. fetcher_cm = build_browser_fetcher(
if self._config is not None: self._config, "yandex", proxy_provider=self._proxy_provider
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]
try: try:
async with fetcher_cm as fetcher: async with fetcher_cm as fetcher:
html = await fetcher.fetch(url) html = await fetcher.fetch(url)
@ -359,7 +354,7 @@ async def resolve_yandex_jk_slug(
jk_id: str, jk_id: str,
city: str = "ekaterinburg", city: str = "ekaterinburg",
*, *,
config: ScraperConfig | None = None, config: ScraperConfig,
proxy_provider: ProxyProvider | None = None, proxy_provider: ProxyProvider | None = None,
) -> str | None: ) -> str | None:
"""Найти Yandex Realty slug для ЖК по его ext_id (jk_id). """Найти 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}/" page_url = f"https://realty.yandex.ru/{city}/kupit/novostrojka/{_JK_SLUG_PLACEHOLDER}-{jk_id}/"
# #3197: см. fetch_jk — та же проводка пула через фабрику. # #3197: см. fetch_jk — та же проводка пула через фабрику.
if config is not None: fetcher_cm = build_browser_fetcher(config, "yandex", proxy_provider=proxy_provider)
fetcher_cm = build_browser_fetcher(config, "yandex", proxy_provider=proxy_provider)
else:
fetcher_cm = BrowserFetcher(source="yandex", endpoint=None) # type: ignore[arg-type]
try: try:
async with fetcher_cm as fetcher: async with fetcher_cm as fetcher:
html = await fetcher.fetch(page_url) html = await fetcher.fetch(page_url)