"""#2767 (вторая итерация) — то, что 8 суток принимали за смену разметки, было блоком. Живая проба 2026-08-09 (сайдкар → camoufox → узел пула, тот же тракт, что у задачи) на одной и той же странице `zhk-pihtovyy-ekb-i.cian.ru`: узел #1 asocks-residential-1 → 374 168 Б, cian_waf_block, initialState нет узел #9 asocks-mobile-1 → 542 021 Б, initialState ЕСТЬ узел #10 asocks-mobile-2 → 557 235 Б, initialState ЕСТЬ узел #11 asocks-mobile-3 → 557 163 Б, initialState ЕСТЬ Разметка не менялась: ТЕКУЩИЙ разбор находит состояние на трёх узлах из четырёх. Ломался тракт: `fetch_newbuilding` была единственным cian-путём без proxy_provider, поэтому всегда выходила через один env-узел сайдкара — как раз тот, чей exit-IP Циан забанил. Фикстура `cian_waf_block_zhk_page.html` — НАСТОЯЩИЙ ответ прода (обрезаны инлайн-стили и часть аналитики; текст блока, код страницы и разметка сохранены байт в байт). Все три проверки красные на коде до правки: 1. страница блока опознаётся (старый список маркеров отвечал на неё `none`); 2. блок репортится в пул как бан узла (старый код не мог: lease не брался вовсе); 3. proxy_provider доезжает до фабрики фетчера (старый код такого аргумента не знал). """ from __future__ import annotations import logging import os import sys from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") sys.modules.setdefault("weasyprint", MagicMock()) import pytest # noqa: E402 from scraper_kit.providers.cian.newbuilding import ( # noqa: E402 _blocked_by, _describe_parse_miss, fetch_newbuilding, ) _FIXTURE = Path(__file__).parent / "fixtures" / "cian_waf_block_zhk_page.html" # Список маркеров ДО правки (#2768). Держим здесь дословно: тест ниже показывает, что # страница блока не содержала ни одного из них — то есть «antibot_markers=none» был не # «защиты нет», а «список не знал этой подписи». _MARKERS_BEFORE_FIX = ( "captcha", "qrator", "ddos-guard", "cf-chl", "доступ ограничен", "вы не робот", ) @pytest.fixture def waf_html() -> str: return _FIXTURE.read_text(encoding="utf-8") def test_waf_page_carries_none_of_the_old_markers(waf_html: str) -> None: """Почему правка вообще нужна: прежний список на этой странице молчал. Молчание защиты читалось в журнале как её вердикт «чисто» — и увело разбор в гипотезу «Циан сменил разметку» на восемь суток. """ lowered = waf_html.lower() assert [m for m in _MARKERS_BEFORE_FIX if m in lowered] == [] # При этом страница прямо называет себя блоком — и словами, и кодом, и в аналитике. assert "cian_waf_block" in waf_html assert "Обнаружен подозрительный трафик" in waf_html assert 'pageType:"VPNBlock"' in waf_html def test_describe_parse_miss_names_the_waf_block(waf_html: str) -> None: """Строка отказа обязана НАЗВАТЬ блок. Красная на старом коде: там `none`.""" described = _describe_parse_miss(waf_html) assert "cian_waf_block" in described assert "antibot_markers=none" not in described assert f"html_len={len(waf_html)}" in described def test_blocked_by_stays_silent_on_a_normal_card() -> None: """Ложных срабатываний быть не должно: обычная разметка ЖК блоком не считается.""" assert _blocked_by("
ЖК «Пихтовый», сдача 2027") is None def _spy_fetcher(html: str) -> MagicMock: fetcher = MagicMock() fetcher.fetch = AsyncMock(return_value=html) fetcher.__aenter__ = AsyncMock(return_value=fetcher) fetcher.__aexit__ = AsyncMock(return_value=None) return fetcher # Здоровая карточка ЖК в текущем формате Циана (.concat([...])), и на ней ЕСТЬ подстрока # «captcha»: обычная страница грузит скрипт SmartCaptcha. Форма взята с живой страницы # 2026-08-09, содержимое сокращено до того, что читает fetch_newbuilding. _HEALTHY_CARD_WITH_CAPTCHA_SCRIPT = ( '" ) @pytest.mark.asyncio async def test_waf_block_is_reported_as_a_ban_of_the_node(monkeypatch, waf_html, caplog) -> None: """Блок → `report_ban`, пока lease ещё жив. Без этого пул продолжает выдавать сожжённый узел, и следующий дом идёт с того же IP — ровно то, что происходило 200 попыток подряд. """ fetcher = _spy_fetcher(waf_html) monkeypatch.setattr( "scraper_kit.providers.cian.newbuilding.build_browser_fetcher", MagicMock(return_value=fetcher), ) with caplog.at_level(logging.WARNING, logger="scraper_kit.providers.cian.newbuilding"): result = await fetch_newbuilding( "https://zhk-pihtovyy-ekb-i.cian.ru", config=SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000"), ) assert result is None fetcher.report_ban.assert_called_once() assert "cian_waf_block" in fetcher.report_ban.call_args.args[0] # Бан репортится ДО выхода из контекста — иначе lease уже отпущен и вызов no-op. assert fetcher.__aexit__.await_count == 1 assert "cian_waf_block" in "\n".join(r.getMessage() for r in caplog.records) @pytest.mark.asyncio async def test_parsed_page_never_bans_the_node_even_with_captcha_word(monkeypatch) -> None: """Разобранная страница НЕ банит узел, даже если на ней написано «captcha». Живая регрессия 2026-08-09: первая версия этой правки звала `_blocked_by` на КАЖДОМ ответе, и за первые 17 секунд прогона забанила два ЗДОРОВЫХ узла (9 и 10) на страницах, которые успешно разобрались — обычная карточка ЖК грузит скрипт SmartCaptcha, подстрока там есть всегда. Признак «есть подпись защиты» осмыслен только вместе с «состояния нет»: список маркеров создавался объяснять УЖЕ случившийся отказ разбора, а не служить самостоятельным детектором. """ fetcher = _spy_fetcher(_HEALTHY_CARD_WITH_CAPTCHA_SCRIPT) monkeypatch.setattr( "scraper_kit.providers.cian.newbuilding.build_browser_fetcher", MagicMock(return_value=fetcher), ) result = await fetch_newbuilding( "https://zhk-pihtovyy-ekb-i.cian.ru", config=SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000"), ) # Предпосылка теста: страница действительно и разбирается, и содержит слово-ловушку. assert result is not None assert result.cian_internal_house_id == 108855 assert "captcha" in _HEALTHY_CARD_WITH_CAPTCHA_SCRIPT fetcher.report_ban.assert_not_called() @pytest.mark.asyncio async def test_proxy_provider_reaches_the_fetcher_factory(monkeypatch, waf_html) -> None: """Провод целиком: provider из задачи → build_browser_fetcher → пул. Красный на старом коде дважды: аргумента `proxy_provider` там не было, и фабрика для этого провайдера не вызывалась вовсе (BrowserFetcher конструировался вручную, без пула). """ factory = MagicMock(return_value=_spy_fetcher(waf_html)) monkeypatch.setattr("scraper_kit.providers.cian.newbuilding.build_browser_fetcher", factory) provider = object() config = SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000") await fetch_newbuilding( "https://zhk-pihtovyy-ekb-i.cian.ru", config=config, proxy_provider=provider ) factory.assert_called_once_with(config, "cian", proxy_provider=provider) # ── новый фронт Циана: тот же ключ под другим именем MFE ────────────────────── # Форма живой страницы zhk-kosmos-ekb-i.cian.ru (2026-08-09): состояние лежит под # 'newbuilding-card-desktop-fichering-frontend', СТАРОГО имени на странице нет вовсе. _FICHERING_CARD = ( "" ) @pytest.mark.asyncio async def test_state_is_found_under_the_renamed_fichering_mfe(monkeypatch) -> None: """ЖК на новом фронте Циана разбирается — вместе с графиком цен. Именно из-за этого имени `houses_price_dynamics` стояла с 2026-07-26: страница приходила целиком, состояние в ней было, но под другим MFE — и отказ выглядел как «разметка изменилась», хотя форма состояния та же. """ fetcher = _spy_fetcher(_FICHERING_CARD) monkeypatch.setattr( "scraper_kit.providers.cian.newbuilding.build_browser_fetcher", MagicMock(return_value=fetcher), ) result = await fetch_newbuilding( "https://zhk-kosmos-ekb-i.cian.ru", config=SimpleNamespace(browser_http_endpoint="http://tradein-browser:3000"), ) assert result is not None assert result.cian_internal_house_id == 3235089 assert result.name == "Космос" # График — тот самый, ради которого задача и существует. assert [p["month_date"] for p in result.realty_valuation_chart] == [ "2026-06-01", "2026-07-01", ] assert [p["price_per_sqm"] for p in result.realty_valuation_chart] == [4100000.0, 4250000.0] fetcher.report_ban.assert_not_called()