gendesign/tradein-mvp/backend/tests/test_scraper_kit_newbuilding_endpoint.py
bot-backend eade88bc66 fix(tradein): config обязателен в newbuilding-провайдерах Циана и Яндекса (#2385)
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 <noreply@anthropic.com>
2026-09-17 12:55:32 +05:00

269 lines
13 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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.
#2385: `config` стал обязательным во всех четырёх точках входа. Прежние кейсы
`*_without_config` (ждали endpoint=None / proxy_url=None) заменены одним тестом
«без config — TypeError, фетчер и сессия не строятся».
"""
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"
_CONFIG = SimpleNamespace(browser_http_endpoint=_TEST_ENDPOINT, use_proxy_pool_browser=False)
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
# ── 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
# ── 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
# ── #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._base.BrowserFetcher", spy)
await resolve_yandex_jk_slug("286394", config=_CONFIG)
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._base.BrowserFetcher", spy)
assert await resolve_yandex_jk_slug("286394", config=_CONFIG) == "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._base.BrowserFetcher", spy)
assert await resolve_yandex_jk_slug("286394", config=_CONFIG) 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._base.BrowserFetcher", spy)
assert await resolve_yandex_jk_slug("286394", config=_CONFIG) 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
# ── #2385: config обязателен — без него ни фетчера, ни сессии ────────────────
@pytest.mark.asyncio
@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
)
with pytest.raises(TypeError, match="config"):
await call()
assert browser_spy.call_count == 0
assert session_spy.call_count == 0