Скрапер: newbuilding-провайдеры без config больше не уходят мимо пула прокси, IMV Авито строит сессию общей фабрикой #3562

Merged
bot-backend merged 2 commits from fix/kit-providers-config into main 2026-09-17 09:23:58 +00:00
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.
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

View file

@ -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="<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
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

View file

@ -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(

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
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("<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 ─────────────────────
@ -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("<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 ────────────────────────────────
@ -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("<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 ищется по СТРАНИЦЕ ЖК, а не по фильтру выдачи ────────────────
#
# Замер 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)
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(
'<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 ─────────────────────────
@ -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("<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
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

View file

@ -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]

View file

@ -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

View file

@ -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.
# Общая 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).
#
# 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, а не тихо чиню тест.
# Тесты подменяют конструктор через `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)
)
_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,
cffi_session = build_document_session(
proxy_url=_proxy_url, timeout=_HTTP_TIMEOUT_SEC
)
_own_session = True
except ImportError as exc:
raise RuntimeError(
"curl_cffi не установлен. Добавь 'curl-cffi>=0.7.0' в pyproject.toml."
) from exc
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 опционален: логируем и продолжаем — шаги ниже дадут типизированную

View file

@ -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:
# config обязателен (#2385): запасная ветка `config=None` строила
# BrowserFetcher(endpoint=None) мимо фабрики — новый вызывающий без config молча уходил
# на env-узел сайдкара, тот самый путь, что стоил 8 суток выше.
browser = build_browser_fetcher(config, "cian", proxy_provider=proxy_provider)
else:
browser = BrowserFetcher(source="cian", endpoint=None) # type: ignore[arg-type]
# 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,

View file

@ -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:
# (образец: yandex/serp.py::__aenter__). Запасной ветки config=None больше нет (#2385).
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:
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]
try:
async with fetcher_cm as fetcher:
html = await fetcher.fetch(page_url)