gendesign/tradein-mvp/backend/tests/test_2767_cian_waf_block.py
bot-backend 9cd6db023b
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 3m4s
Deploy Trade-In / build-backend (push) Successful in 1m31s
Deploy Trade-In / deploy (push) Successful in 2m11s
fix(tradein/cian): Циан переименовал MFE карточки ЖК — читать оба имени (#2767) (#2804)
2026-08-09 18:41:02 +00:00

231 lines
12 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.

"""#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("<html><body>ЖК «Пихтовый», сдача 2027</body></html>") 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 = (
'<html><head><script src="https://smartcaptcha.yandexcloud.net/captcha.js" async>'
"</script></head><body><script>"
"window._cianConfig['newbuilding-card-desktop-frontend'] = "
"(window._cianConfig['newbuilding-card-desktop-frontend'] || []).concat("
'[{"key":"initialState","value":{"newbuilding":{"id":108855,"name":"ЖК «Пихтовый»"}}}]'
");</script></body></html>"
)
@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 = (
"<html><body><script>"
"window._cianConfig['newbuilding-card-desktop-fichering-frontend'] = "
"(window._cianConfig['newbuilding-card-desktop-fichering-frontend'] || []).concat("
'[{"key":"initialState","value":{"newbuilding":{"id":3235089,"name":"Космос"},'
'"realtyValuation":{"data":{"priceDynamics":{"chart":{"data":'
'{"labels":["2026-06-01","2026-07-01"],"values":[4100000,4250000]}}}}}}}]'
");</script></body></html>"
)
@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()