fix(tradein): cian _cianConfig concat-формат + auth из header-frontend (#639) #819

Merged
bot-reviewer merged 1 commit from fix/639-cian-state-concat into main 2026-05-30 19:08:01 +00:00
3 changed files with 116 additions and 3 deletions

View file

@ -56,6 +56,10 @@ _VALUATION_TEST_URL = (
)
_MFE_VALUATION = "valuation-for-agent-frontend"
# #639 (2026-05): auth-сигнал (user.isAuthenticated/userId) уехал из valuation-MFE
# (там user теперь всегда anonymous) в site-header MFE `header-frontend`, который
# присутствует на любой cian-странице. Verify читает auth именно оттуда.
_MFE_AUTH = "header-frontend"
# Returned by verify_session when a TLS/bot-fingerprint ban (HTTP 403) is detected.
# Callers that check `if state is None` will NOT treat this as "expired cookies" —
@ -81,7 +85,7 @@ def _classify_verify_response(
return None
if html is None:
return None
state = extract_state(html, mfe=_MFE_VALUATION, key="initialState")
state = extract_state(html, mfe=_MFE_AUTH, key="initialState")
if state is None:
return None
user = state.get("user", {}) or {}

View file

@ -26,13 +26,78 @@ _RE_CIAN_PUSH = re.compile(
re.DOTALL,
)
# 2026-05 drift (#639): Cian сменил формат на
# window._cianConfig['mfe'] = (window._cianConfig['mfe'] || []).concat([{"key":..,"value":..},..])
# вместо .push({key:..}). Ключи теперь в JSON-кавычках, аргумент concat([...]) — валидный
# JSON-массив → парсим его целиком (надёжнее regexp по одному value). Старый .push формат
# (_RE_CIAN_PUSH) оставлен как fallback для legacy-фикстур.
_RE_CIAN_CONCAT = re.compile(
r"window\._cianConfig\['(?P<mfe>[\w-]+)'\]\s*=\s*"
r"\(\s*window\._cianConfig\['[\w-]+'\]\s*\|\|\s*\[\]\s*\)\.concat\(\s*"
)
def _balanced_array(s: str, start: int) -> str | None:
"""Срез s[start:end] — сбалансированный ``[...]`` от индекса '[', с учётом JSON-строк."""
depth = 0
in_str = False
esc = False
for i in range(start, len(s)):
c = s[i]
if in_str:
if esc:
esc = False
elif c == "\\":
esc = True
elif c == '"':
in_str = False
continue
if c == '"':
in_str = True
elif c == "[":
depth += 1
elif c == "]":
depth -= 1
if depth == 0:
return s[start : i + 1]
return None
def _extract_from_concat(html: str, mfe: str, key: str) -> Any | None:
"""Новый формат: .concat([{"key":..,"value":..},..]) — массив-аргумент = валидный JSON."""
for m in _RE_CIAN_CONCAT.finditer(html):
if m.group("mfe") != mfe:
continue
start = m.end()
if start >= len(html) or html[start] != "[":
start = html.find("[", m.end())
if start < 0:
continue
arr = _balanced_array(html, start)
if arr is None:
continue
try:
items = json.loads(arr)
except json.JSONDecodeError as exc:
logger.debug("concat array parse failed for mfe=%s key=%s: %s", mfe, key, exc)
continue
for it in items:
if isinstance(it, dict) and it.get("key") == key:
return it.get("value")
return None
def extract_state(html: str, mfe: str, key: str = "initialState") -> dict[str, Any] | None:
"""Extract state.value where _cianConfig[<mfe>] entry.key matches.
Returns parsed JSON dict, or None if not found / parse failed.
Logs warning at debug level on parse failure for diagnostics.
Поддерживает оба формата Cian: новый ``.concat([{"key":..,"value":..}])`` (2026-05,
#639) и legacy ``.push({key:..,value:..})``. Returns parsed JSON dict, or None.
"""
# Новый формат (concat) — приоритетный.
concat_val = _extract_from_concat(html, mfe, key)
if concat_val is not None:
return concat_val
for match in _RE_CIAN_PUSH.finditer(html):
if match.group("mfe") != mfe or match.group("key") != key:
continue

View file

@ -76,3 +76,47 @@ def test_extract_state_backslash_in_description() -> None:
assert (
"\\" in state["offerData"]["offer"]["description"]
), "Backslash in description was lost during unescape"
# ── #639: новый формат Cian (2026-05) — .concat([{"key":..,"value":..}]) ──
def test_extract_state_concat_format() -> None:
"""Новый формат: .concat([{...}]) с JSON-кавычками вместо .push({key:..})."""
html = (
"<script>window._cianConfig = window._cianConfig || {};"
"window._cianConfig['frontend-serp'] = "
"(window._cianConfig['frontend-serp'] || []).concat("
'[{"key":"projectName","value":"frontend-serp","priority":1000,"filter":{}},'
'{"key":"initialState","value":{"results":{"offers":[{"id":7}]}},"priority":1,"filter":{}}]'
");</script>"
)
state = extract_state(html, mfe="frontend-serp", key="initialState")
assert state is not None
assert state["results"]["offers"][0]["id"] == 7
def test_extract_state_concat_bracket_in_string() -> None:
"""Балансировщик массива не ломается на ']'/'[' внутри JSON-строк."""
html = (
"window._cianConfig['header-frontend'] = "
"(window._cianConfig['header-frontend'] || []).concat("
'[{"key":"initialState","value":{"user":{"isAuthenticated":true,"userId":42,'
'"note":"text with ] and [ inside"}},"priority":1}]'
");"
)
state = extract_state(html, mfe="header-frontend", key="initialState")
assert state is not None
assert state["user"]["isAuthenticated"] is True
assert state["user"]["userId"] == 42
def test_extract_state_legacy_push_still_works() -> None:
"""Старый .push формат остаётся рабочим (fallback)."""
html = (
"window._cianConfig['frontend-serp'].push({"
'key: \'initialState\', value: {"results":{"offers":[{"id":3}]}}, priority: 1});'
)
state = extract_state(html, mfe="frontend-serp", key="initialState")
assert state is not None
assert state["results"]["offers"][0]["id"] == 3