diff --git a/tradein-mvp/backend/tests/test_2767_cian_waf_block.py b/tradein-mvp/backend/tests/test_2767_cian_waf_block.py index 660db0f7..43d793df 100644 --- a/tradein-mvp/backend/tests/test_2767_cian_waf_block.py +++ b/tradein-mvp/backend/tests/test_2767_cian_waf_block.py @@ -183,3 +183,49 @@ async def test_proxy_provider_reaches_the_fetcher_factory(monkeypatch, waf_html) ) 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() diff --git a/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py b/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py index 28b16f56..81056835 100644 --- a/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py +++ b/tradein-mvp/backend/tests/test_2767_newbuilding_parse_miss.py @@ -53,9 +53,13 @@ def test_describe_parse_miss_reports_size_and_no_markers() -> None: def test_describe_parse_miss_names_found_markers() -> None: - """Заглушка: маркер назван, но диагноз не выносится — только перечисление найденного.""" + """Стена капчи: маркер назван, но диагноз не выносится — только перечисление найденного. + + Опознаётся по заголовку «Вы не робот?», а НЕ по подстроке "captcha": последняя есть + на любой здоровой карточке ЖК (скрипт SmartCaptcha) и была снята из списка после + того, как забанила здоровые узлы (#2767). + """ described = _describe_parse_miss("
Вы не робот?
") - assert "captcha" in described assert "вы не робот" in described assert "html_len=" in described diff --git a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py index 89034166..4211f73f 100644 --- a/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py +++ b/tradein-mvp/packages/scraper-kit/src/scraper_kit/providers/cian/newbuilding.py @@ -1,7 +1,9 @@ """Cian.ru newbuilding (ЖК) catalog page scraper. URL: https://zhk---i.cian.ru/ -MFE: 'newbuilding-card-desktop-frontend', key: 'initialState' +MFE: 'newbuilding-card-desktop-frontend' ИЛИ 'newbuilding-card-desktop-fichering-frontend' +(Циан раскатывает новый фронт на часть ЖК — оба имени живые, см. _NEWBUILDING_MFES), +key: 'initialState' Sister state containers extracted from same MFE initialState top-level keys: - realtyValuation: 7-month price chart (data.priceDynamics.chart.data.{labels,values}) @@ -82,8 +84,13 @@ _CATPH_NEWBUILDING_SERP = ( # то есть по размеру НЕОТЛИЧИМА от целой карточки — «~1 МБ значит разметка» не работает, # различает только маркер. Молчание списка читалось как «защита проверена и не нашлась»; # на деле список просто не знал этой подписи (см. capture в #2767). +# Голой подстроки "captcha" здесь НЕТ намеренно (живая проба 2026-08-09): ЛЮБАЯ здоровая +# карточка ЖК грузит скрипт SmartCaptcha, поэтому "captcha" на ней есть всегда. Стена +# капчи опознаётся по своему заголовку «Вы не робот?» — он в списке ниже и на проде +# срабатывал вместе с "captcha" на всех 15-килобайтных стенах, так что снятие ложного +# маркера ничего не теряет. Пока он тут был, отказ разбора большой (682-886 КБ) страницы +# сопровождался баном ЗДОРОВОГО узла с причиной "captcha". _ANTIBOT_MARKERS = ( - "captcha", # покрывает и recaptcha, и smartcaptcha "qrator", "ddos-guard", "cf-chl", # Cloudflare challenge @@ -109,6 +116,37 @@ def _describe_parse_miss(html: str) -> str: return f"html_len={len(html)} antibot_markers={','.join(found) if found else 'none'}" +# MFE, под которым Циан кладёт состояние ЖК-карточки. Имён ДВА, и оба живые — замер +# 2026-08-09, один и тот же прогон: +# zhk-pihtovyy-ekb-i.cian.ru → newbuilding-card-desktop-frontend +# zhk-kosmos-ekb-i.cian.ru → newbuilding-card-desktop-fichering-frontend +# zhk-les-ekb-i.cian.ru → newbuilding-card-desktop-fichering-frontend +# Второе имя (`fichering`) — раскатка нового фронта Циана на ЧАСТЬ ЖК. Страницы под ним +# приходят целиком (810-993 КБ, ни капчи, ни WAF), состояние в них лежит под тем же +# ключом `initialState` и той же формы: `newbuilding.id`/`name` на месте, +# `realtyValuation.data.priceDynamics` — тоже. Именно из-за него `houses_price_dynamics` +# стояла с 2026-07-26: у ЖК, переехавших на новый фронт, график есть, но старое имя MFE +# его не находило, а отказ выглядел как «страница целая, разметка изменилась». +# Порядок — исторический первым: большинство ЖК всё ещё на нём, лишнего разбора не будет. +_NEWBUILDING_MFES = ( + "newbuilding-card-desktop-frontend", + "newbuilding-card-desktop-fichering-frontend", +) + + +def _extract_nb_state(html: str) -> tuple[dict[str, Any] | None, str]: + """Состояние ЖК-карточки + имя MFE, под которым оно нашлось. + + Имя возвращается, потому что sister-контейнеры читаются из `extract_all_states()` + по ТОМУ ЖЕ MFE — искать их под чужим именем бессмысленно. + """ + for mfe in _NEWBUILDING_MFES: + state = extract_state(html, mfe=mfe, key="initialState") + if state is not None: + return state, mfe + return None, _NEWBUILDING_MFES[0] + + def _blocked_by(html: str) -> str | None: """Найденные подписи защиты, если страница — блок; иначе None. @@ -227,15 +265,11 @@ async def fetch_newbuilding( # прогон из 25 домов даёт до 25 acquire → до 25 релончей camoufox (~8 с каждый). # При паузе 18 с между домами это терпимо и вдобавок само по себе ротация IP. # Понадобится дешевле — поднимать фетчер в вызывающий цикл и передавать сюда. - # Cian ЖК-карточка: MFE 'newbuilding-card-desktop-frontend', key 'initialState' - # (verified live 2026-06-15; перепроверено живьём 2026-08-09 — разметка та же). - mfe = "newbuilding-card-desktop-frontend" - async with browser: html = await browser.fetch(zhk_url) # Разбор — ВНУТРИ контекста, чтобы бан репортился, пока lease жив: после выхода # из `async with` узел уже отпущен и `report_ban` стал бы no-op. - nb_state = extract_state(html, mfe=mfe, key="initialState") + nb_state, mfe = _extract_nb_state(html) # Условие бана — «состояния НЕТ и есть подпись защиты», а не одна подпись. # Живая регрессия 2026-08-09 (поймана на проде через 17 секунд после деплоя): # маркер по одному лишь тексту забанил два ЗДОРОВЫХ узла на странице, которая @@ -250,8 +284,9 @@ async def fetch_newbuilding( if nb_state is None: logger.warning( - "Cian newbuilding %s: initialState extraction failed — %s", + "Cian newbuilding %s: initialState extraction failed (искали в MFE %s) — %s", zhk_url, + "/".join(_NEWBUILDING_MFES), _describe_parse_miss(html), ) return None