fix(tradein): cian state-extraction для нового _cianConfig формата + auth из header-frontend (#639)

Cian сменил формат встраивания state (2026-05): `.push({key:..,value:..})` →
`window._cianConfig['mfe'] = (... || []).concat([{"key":..,"value":..},..])` с
JSON-кавычками. `_RE_CIAN_PUSH` матчил 0 → extract_state=None → verify_session
заворачивал ВАЛИДНЫЕ куки → scheduler.py скипал cian-бэкфилл («cookies expired»).
Ломало весь cian (SERP/valuation/detail/newbuilding — общий extract_state).

Плюс auth-сигнал (user.isAuthenticated/userId) уехал из valuation-MFE (там user
теперь anonymous) в site-header MFE `header-frontend`.

- cian_state_parser.py: `_extract_from_concat` (regex .concat([..]) + string-aware
  balance-scan `[...]` → json.loads массива → item по key). extract_state пробует
  concat первым, .push остаётся fallback для legacy-фикстур.
- cian_session.py: verify читает auth из `_MFE_AUTH='header-frontend'`, не valuation.
- tests: concat-формат, ']'/'[' внутри строк, legacy push fallback.

Диагностировано вживую на проде (валидная сессия userId 75544897): _RE_CIAN_PUSH=0
матчей при 12 живых _cianConfig; concat-парсер извлекает initialState; isAuth=true
в header-frontend. 34 passed, ruff clean.

Refs #639, #806.
This commit is contained in:
lekss361 2026-05-30 22:04:26 +03:00
parent a48205391c
commit 65b7b73691
3 changed files with 116 additions and 3 deletions

View file

@ -56,6 +56,10 @@ _VALUATION_TEST_URL = (
) )
_MFE_VALUATION = "valuation-for-agent-frontend" _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. # 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" — # Callers that check `if state is None` will NOT treat this as "expired cookies" —
@ -81,7 +85,7 @@ def _classify_verify_response(
return None return None
if html is None: if html is None:
return 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: if state is None:
return None return None
user = state.get("user", {}) or {} user = state.get("user", {}) or {}

View file

@ -26,13 +26,78 @@ _RE_CIAN_PUSH = re.compile(
re.DOTALL, 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: def extract_state(html: str, mfe: str, key: str = "initialState") -> dict[str, Any] | None:
"""Extract state.value where _cianConfig[<mfe>] entry.key matches. """Extract state.value where _cianConfig[<mfe>] entry.key matches.
Returns parsed JSON dict, or None if not found / parse failed. Поддерживает оба формата Cian: новый ``.concat([{"key":..,"value":..}])`` (2026-05,
Logs warning at debug level on parse failure for diagnostics. #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): for match in _RE_CIAN_PUSH.finditer(html):
if match.group("mfe") != mfe or match.group("key") != key: if match.group("mfe") != mfe or match.group("key") != key:
continue continue

View file

@ -76,3 +76,47 @@ def test_extract_state_backslash_in_description() -> None:
assert ( assert (
"\\" in state["offerData"]["offer"]["description"] "\\" in state["offerData"]["offer"]["description"]
), "Backslash in description was lost during unescape" ), "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