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"
|
_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 {}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue