fix(tradein/cian): Циан переименовал MFE карточки ЖК — читать оба имени (#2767) #2804
3 changed files with 95 additions and 10 deletions
|
|
@ -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 = (
|
||||
"<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()
|
||||
|
|
|
|||
|
|
@ -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("<html><div class='g-recaptcha'>Вы не робот?</div></html>")
|
||||
assert "captcha" in described
|
||||
assert "вы не робот" in described
|
||||
assert "html_len=" in described
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
"""Cian.ru newbuilding (ЖК) catalog page scraper.
|
||||
|
||||
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:
|
||||
- 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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue