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
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:
parent
08bb9d6549
commit
9cd6db023b
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)
|
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:
|
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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue