All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 9s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m56s
`YandexNewbuildingScraper.fetch_jk` и `resolve_yandex_jk_slug` строили `BrowserFetcher(source="yandex", endpoint=...)` без proxy_provider/use_pool/ environment — сайдкар брал env-узел SCRAPER_PROXY_URL, на проде выключенный (407 → camoufox InvalidIP → /fetch 503, факт #3386), то есть путь шёл мимо пула целиком, а прод-отказ «пул пуст» (#2616) был мёртв: он смотрит на environment, который до конструктора не доезжал. Обе точки собраны через build_browser_fetcher(config, "yandex", proxy_provider=...), как yandex/serp.py. Второе: общий `except Exception` в обеих функциях глотал NoProxyAvailableError и возвращал None — прогон, не ходивший к площадке, перебирал все ЖК и уходил в 'done'. Теперь «пул пуст» пробрасывается наружу (caused_by_no_proxy), sweep обрывается на первом доме с no_proxy_stop, а прогон финализируется как failed (образец дефекта — #3382, cian/detail.py).
292 lines
14 KiB
Python
292 lines
14 KiB
Python
"""Regression test — scraper_kit yandex/cian newbuilding providers proxy `endpoint=`
|
||
into `BrowserFetcher` (issue #2322, epic #2277 Group F1).
|
||
|
||
Bug: `scraper_kit/providers/yandex/newbuilding.py` (`YandexNewbuildingScraper.fetch_jk`,
|
||
`resolve_yandex_jk_slug`) and `scraper_kit/providers/cian/newbuilding.py`
|
||
(`fetch_newbuilding`) constructed `BrowserFetcher(source=...)` WITHOUT the mandatory
|
||
`endpoint=` kwarg (required, no default in `BrowserFetcher.__init__`) — `TypeError` on
|
||
every real call, and the functions did not even expose a `config` parameter so callers
|
||
could not fix this from outside via DI (mirrors the pattern already used by
|
||
`resolve_cian_zhk_url_via_search` / other providers, e.g. `cian/serp.py`,
|
||
`domclick/serp.py`, `avito/serp.py`, `yandex/serp.py`).
|
||
|
||
These tests prove `config.browser_http_endpoint` is actually threaded through to the
|
||
`BrowserFetcher(...)` constructor call args — not merely that the function "doesn't
|
||
crash" on a happy path. `BrowserFetcher` itself is replaced by a spy so the constructor
|
||
call is directly inspectable.
|
||
|
||
Also covers a SIBLING but importantly DIFFERENT footgun (#2397 Part D3, migrating
|
||
`app.tasks.newbuilding_enrich_backfill` onto this kit provider):
|
||
`resolve_cian_zhk_url_via_search` does NOT use `BrowserFetcher` at all — it goes
|
||
through `build_curl_cffi_session` — so a missing `config=` does NOT raise like the
|
||
`BrowserFetcher.__aenter__` assertion above. It silently degrades to a direct
|
||
(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.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from types import SimpleNamespace
|
||
from unittest.mock import AsyncMock, MagicMock
|
||
|
||
import pytest
|
||
from scraper_kit.providers.cian.newbuilding import (
|
||
fetch_newbuilding,
|
||
resolve_cian_zhk_url_via_search,
|
||
)
|
||
from scraper_kit.providers.yandex.newbuilding import (
|
||
YandexNewbuildingScraper,
|
||
resolve_yandex_jk_slug,
|
||
)
|
||
|
||
_TEST_ENDPOINT = "http://test-tradein-browser:9009/fetch"
|
||
_TEST_PROXY = "http://mobile-proxy.example:8080"
|
||
|
||
|
||
def _spy_browser_fetcher(html: str) -> MagicMock:
|
||
"""BrowserFetcher class-replacement spy: records the constructor call args/kwargs
|
||
and returns an async-context-manager mock whose `.fetch()` resolves to `html`."""
|
||
mock_fetcher = MagicMock()
|
||
mock_fetcher.fetch = AsyncMock(return_value=html)
|
||
mock_fetcher.__aenter__ = AsyncMock(return_value=mock_fetcher)
|
||
mock_fetcher.__aexit__ = AsyncMock(return_value=None)
|
||
return MagicMock(return_value=mock_fetcher)
|
||
|
||
|
||
# ── cian.newbuilding.fetch_newbuilding ───────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_cian_fetch_newbuilding_passes_endpoint_from_config(monkeypatch):
|
||
"""config.browser_http_endpoint должен попасть в BrowserFetcher(endpoint=...).
|
||
|
||
С #2767 путь идёт через `build_browser_fetcher` (подключение к пулу прокси), поэтому
|
||
подменяется BrowserFetcher В ФАБРИКЕ — сама фабрика при этом настоящая, и тест
|
||
проверяет всю цепочку config → фабрика → фетчер, а не контракт мока.
|
||
"""
|
||
spy = _spy_browser_fetcher("<html></html>")
|
||
monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", spy)
|
||
|
||
config = SimpleNamespace(browser_http_endpoint=_TEST_ENDPOINT, use_proxy_pool_browser=False)
|
||
await fetch_newbuilding("https://zhk-test-ekb-i.cian.ru/", config=config)
|
||
|
||
spy.assert_called_once()
|
||
_, kwargs = spy.call_args
|
||
assert kwargs.get("source") == "cian"
|
||
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 ─────────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_yandex_fetch_jk_passes_endpoint_from_config(monkeypatch):
|
||
"""config передан в конструктор скрапера → endpoint пробрасывается в BrowserFetcher.
|
||
|
||
С #3197 путь идёт через `build_browser_fetcher` (подключение к пулу прокси) — как у
|
||
cian выше, подменяется BrowserFetcher В ФАБРИКЕ, сама фабрика настоящая.
|
||
"""
|
||
spy = _spy_browser_fetcher("<html></html>")
|
||
monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", spy)
|
||
|
||
config = SimpleNamespace(browser_http_endpoint=_TEST_ENDPOINT, use_proxy_pool_browser=False)
|
||
scraper = YandexNewbuildingScraper(config=config)
|
||
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") == _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 ────────────────────────────────
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_resolve_yandex_jk_slug_passes_endpoint_from_config(monkeypatch):
|
||
"""config= (новый kwarg-only параметр) должен пробрасывать endpoint в BrowserFetcher.
|
||
|
||
#3197: конструкция переехала в `build_browser_fetcher` — спай ставим на фабрику.
|
||
"""
|
||
spy = _spy_browser_fetcher("<html></html>")
|
||
monkeypatch.setattr("scraper_kit.providers._base.BrowserFetcher", spy)
|
||
|
||
config = SimpleNamespace(browser_http_endpoint=_TEST_ENDPOINT, use_proxy_pool_browser=False)
|
||
await resolve_yandex_jk_slug("1592987", config=config)
|
||
|
||
spy.assert_called_once()
|
||
_, kwargs = spy.call_args
|
||
assert kwargs.get("source") == "yandex"
|
||
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>` Яндексом
|
||
# больше не применяется — отдаётся общий список новостроек (1.7 МБ, 35 разных
|
||
# ЖК), запрошенного id среди них нет. Разметка при этом цела: прежний regex
|
||
# находил 128 ссылок нужной формы, просто ни одной с нужным id. Из-за этого
|
||
# обход не разрешил НИ ОДНОГО дома с 16.07.2026 (14 прогонов подряд 5/0).
|
||
|
||
_JK_PAGE_HTML = (
|
||
"<html><head><title>ЖК «Успенский»</title></head><body>"
|
||
'<a href="/ekaterinburg/kupit/novostrojka/zhk-286394/">эта же страница</a>'
|
||
'<a href="/ekaterinburg/kupit/novostrojka/uspenskij-286394/">ЖК «Успенский»</a>'
|
||
"</body></html>"
|
||
)
|
||
|
||
# Общий список: ссылок много, с нужным id — ни одной. Ровно то, что прод
|
||
# отдавал на `?siteId=`.
|
||
_GENERAL_LIST_HTML = (
|
||
'<a href="/ekaterinburg/kupit/novostrojka/shishkinn-2671892/">ШишкINN</a>'
|
||
'<a href="/ekaterinburg/kupit/novostrojka/parkovyj-1637230/">Парковый</a>'
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_resolve_yandex_jk_slug_requests_jk_page_not_serp_filter(monkeypatch):
|
||
"""Запрашивается страница ЖК по id, а НЕ выдача с ?siteId=.
|
||
|
||
Это и есть суть правки: адрес запроса, а не разбор ответа.
|
||
"""
|
||
spy = _spy_browser_fetcher(_JK_PAGE_HTML)
|
||
monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy)
|
||
|
||
await resolve_yandex_jk_slug("286394")
|
||
|
||
url = spy.return_value.fetch.call_args[0][0]
|
||
assert "siteId=" not in url, f"фильтр выдачи больше не работает, а запрос идёт туда: {url}"
|
||
assert url.endswith("/kupit/novostrojka/zhk-286394/"), url
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
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)
|
||
|
||
assert await resolve_yandex_jk_slug("286394") == "uspenskij"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_resolve_yandex_jk_slug_returns_none_on_general_list(monkeypatch):
|
||
"""Отрицательный контроль: ссылки есть, с нашим id — нет → None, а не чужой slug.
|
||
|
||
Прод-эквивалент: несуществующий id 999999999 отдаёт общий список.
|
||
Без привязки к id функция вернула бы «shishkinn» для чужого дома.
|
||
"""
|
||
spy = _spy_browser_fetcher(_GENERAL_LIST_HTML)
|
||
monkeypatch.setattr("scraper_kit.providers.yandex.newbuilding.BrowserFetcher", spy)
|
||
|
||
assert await resolve_yandex_jk_slug("286394") is None
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
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)
|
||
|
||
assert await resolve_yandex_jk_slug("286394") is None
|
||
|
||
|
||
# ── cian.newbuilding.resolve_cian_zhk_url_via_search ─────────────────────────
|
||
# NOTE (#2397 Part D3): this provider does NOT use BrowserFetcher (unlike
|
||
# fetch_newbuilding above) — it builds its own curl_cffi session via
|
||
# build_curl_cffi_session(proxy_url=...). Unlike the BrowserFetcher assertion, a
|
||
# missing config= here does NOT crash — it silently drops the proxy and falls back to
|
||
# a direct (non-proxied) connection. These tests prove the threading in BOTH directions
|
||
# so a caller forgetting config= has a red test instead of quiet unproxied traffic.
|
||
|
||
_SERP_HTML = (
|
||
'<h1 data-name="Title">Купить квартиру в '
|
||
'<a href="https://zhk-parkovyy-kvartal-ekb-i.cian.ru/">ЖК «Парковый квартал»</a></h1>'
|
||
)
|
||
|
||
|
||
def _spy_curl_session(html: str) -> MagicMock:
|
||
"""build_curl_cffi_session spy: records the (proxy_url=...) call kwargs and returns
|
||
a fake curl_cffi AsyncSession whose .get() resolves to a 200 response carrying `html`."""
|
||
mock_resp = SimpleNamespace(status_code=200, text=html)
|
||
mock_session = MagicMock()
|
||
mock_session.get = AsyncMock(return_value=mock_resp)
|
||
mock_session.close = AsyncMock()
|
||
return MagicMock(return_value=mock_session)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_resolve_cian_zhk_url_via_search_passes_proxy_from_config(monkeypatch):
|
||
"""config.cian_proxy_url должен попасть в build_curl_cffi_session(proxy_url=...)."""
|
||
spy = _spy_curl_session(_SERP_HTML)
|
||
monkeypatch.setattr("scraper_kit.providers.cian.newbuilding.build_curl_cffi_session", spy)
|
||
|
||
config = SimpleNamespace(cian_proxy_url=_TEST_PROXY)
|
||
url = await resolve_cian_zhk_url_via_search(48853, config=config)
|
||
|
||
assert url == "https://zhk-parkovyy-kvartal-ekb-i.cian.ru"
|
||
spy.assert_called_once()
|
||
_, kwargs = spy.call_args
|
||
assert kwargs.get("proxy_url") == _TEST_PROXY
|
||
|
||
|
||
@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)
|
||
|
||
url = await resolve_cian_zhk_url_via_search(48853)
|
||
|
||
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
|