fix(tradein/cian): Циан переименовал MFE карточки ЖК — читать оба имени (#2767) (#2804)
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

This commit is contained in:
bot-backend 2026-08-09 18:41:02 +00:00
parent 08bb9d6549
commit 9cd6db023b
3 changed files with 95 additions and 10 deletions

View file

@ -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) 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()

View file

@ -53,9 +53,13 @@ def test_describe_parse_miss_reports_size_and_no_markers() -> None:
def test_describe_parse_miss_names_found_markers() -> None: def test_describe_parse_miss_names_found_markers() -> None:
"""Заглушка: маркер назван, но диагноз не выносится — только перечисление найденного.""" """Стена капчи: маркер назван, но диагноз не выносится — только перечисление найденного.
Опознаётся по заголовку «Вы не робот?», а НЕ по подстроке "captcha": последняя есть
на любой здоровой карточке ЖК (скрипт SmartCaptcha) и была снята из списка после
того, как забанила здоровые узлы (#2767).
"""
described = _describe_parse_miss("<html><div class='g-recaptcha'>Вы не робот?</div></html>") described = _describe_parse_miss("<html><div class='g-recaptcha'>Вы не робот?</div></html>")
assert "captcha" in described
assert "вы не робот" in described assert "вы не робот" in described
assert "html_len=" in described assert "html_len=" in described

View file

@ -1,7 +1,9 @@
"""Cian.ru newbuilding (ЖК) catalog page scraper. """Cian.ru newbuilding (ЖК) catalog page scraper.
URL: https://zhk-<slug>-<city>-i.cian.ru/ URL: https://zhk-<slug>-<city>-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: Sister state containers extracted from same MFE initialState top-level keys:
- realtyValuation: 7-month price chart (data.priceDynamics.chart.data.{labels,values}) - realtyValuation: 7-month price chart (data.priceDynamics.chart.data.{labels,values})
@ -82,8 +84,13 @@ _CATPH_NEWBUILDING_SERP = (
# то есть по размеру НЕОТЛИЧИМА от целой карточки — «~1 МБ значит разметка» не работает, # то есть по размеру НЕОТЛИЧИМА от целой карточки — «~1 МБ значит разметка» не работает,
# различает только маркер. Молчание списка читалось как «защита проверена и не нашлась»; # различает только маркер. Молчание списка читалось как «защита проверена и не нашлась»;
# на деле список просто не знал этой подписи (см. capture в #2767). # на деле список просто не знал этой подписи (см. capture в #2767).
# Голой подстроки "captcha" здесь НЕТ намеренно (живая проба 2026-08-09): ЛЮБАЯ здоровая
# карточка ЖК грузит скрипт SmartCaptcha, поэтому "captcha" на ней есть всегда. Стена
# капчи опознаётся по своему заголовку «Вы не робот?» — он в списке ниже и на проде
# срабатывал вместе с "captcha" на всех 15-килобайтных стенах, так что снятие ложного
# маркера ничего не теряет. Пока он тут был, отказ разбора большой (682-886 КБ) страницы
# сопровождался баном ЗДОРОВОГО узла с причиной "captcha".
_ANTIBOT_MARKERS = ( _ANTIBOT_MARKERS = (
"captcha", # покрывает и recaptcha, и smartcaptcha
"qrator", "qrator",
"ddos-guard", "ddos-guard",
"cf-chl", # Cloudflare challenge "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'}" 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: def _blocked_by(html: str) -> str | None:
"""Найденные подписи защиты, если страница — блок; иначе None. """Найденные подписи защиты, если страница — блок; иначе None.
@ -227,15 +265,11 @@ async def fetch_newbuilding(
# прогон из 25 домов даёт до 25 acquire → до 25 релончей camoufox (~8 с каждый). # прогон из 25 домов даёт до 25 acquire → до 25 релончей camoufox (~8 с каждый).
# При паузе 18 с между домами это терпимо и вдобавок само по себе ротация IP. # При паузе 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: async with browser:
html = await browser.fetch(zhk_url) html = await browser.fetch(zhk_url)
# Разбор — ВНУТРИ контекста, чтобы бан репортился, пока lease жив: после выхода # Разбор — ВНУТРИ контекста, чтобы бан репортился, пока lease жив: после выхода
# из `async with` узел уже отпущен и `report_ban` стал бы no-op. # из `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 секунд после деплоя): # Живая регрессия 2026-08-09 (поймана на проде через 17 секунд после деплоя):
# маркер по одному лишь тексту забанил два ЗДОРОВЫХ узла на странице, которая # маркер по одному лишь тексту забанил два ЗДОРОВЫХ узла на странице, которая
@ -250,8 +284,9 @@ async def fetch_newbuilding(
if nb_state is None: if nb_state is None:
logger.warning( logger.warning(
"Cian newbuilding %s: initialState extraction failed%s", "Cian newbuilding %s: initialState extraction failed (искали в MFE %s)%s",
zhk_url, zhk_url,
"/".join(_NEWBUILDING_MFES),
_describe_parse_miss(html), _describe_parse_miss(html),
) )
return None return None