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
231 lines
12 KiB
Python
231 lines
12 KiB
Python
"""#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()
|