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:
parent
a48205391c
commit
65b7b73691
3 changed files with 116 additions and 3 deletions
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue