All checks were successful
Deploy Trade-In / test (push) Successful in 25s
Deploy Trade-In / build-backend (push) Successful in 41s
Deploy Trade-In / deploy (push) Successful in 35s
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / build-frontend (push) Has been skipped
Co-authored-by: bot-backend <bot-backend@gendsgn.local> Co-committed-by: bot-backend <bot-backend@gendsgn.local>
160 lines
6 KiB
Python
160 lines
6 KiB
Python
"""Shared utility для извлечения Cian Redux state из _cianConfig.
|
||
|
||
Pattern: window._cianConfig['<mfe>'].push({key:..., value:..., priority:..., filter:...})
|
||
Used by SERP / detail / newbuilding / valuation scrapers.
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
import re
|
||
from typing import Any
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Match both patterns Cian uses:
|
||
# 1. window._cianConfig['mfe'].push({key:..., value:..., priority:..., filter:...})
|
||
# 2. window._cianConfig['mfe'] = window._cianConfig['mfe'] || [];
|
||
# window._cianConfig['mfe'].push({...})
|
||
_RE_CIAN_PUSH = re.compile(
|
||
r"window\._cianConfig\['(?P<mfe>[\w-]+)'\]"
|
||
r"(?:\.push\(\s*|"
|
||
r"\s*=\s*window\._cianConfig\['[\w-]+'\]\s*\|\|\s*\[\];\s*"
|
||
r"window\._cianConfig\['[\w-]+'\]\.push\(\s*)"
|
||
r"\{\s*key:\s*['\"](?P<key>\w+)['\"]\s*,\s*"
|
||
r"value:\s*(?P<value>.+?)\s*,\s*"
|
||
r"priority:",
|
||
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.
|
||
|
||
Поддерживает оба формата 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
|
||
|
||
value_str = match.group("value").strip()
|
||
|
||
# Strategy 1: direct JSON parse (value уже JSON-like)
|
||
try:
|
||
return json.loads(value_str)
|
||
except json.JSONDecodeError:
|
||
pass
|
||
|
||
# Strategy 2: JSON.parse("escaped string")
|
||
# Use json.loads on the outer quoted string to unescape atomically — avoids
|
||
# order-dependent manual .replace() chains that break when descriptions contain
|
||
# backslashes (e.g. '\\' in listing text corrupts the rest of the parse).
|
||
m_jp = re.match(r'JSON\.parse\(\s*("(?:[^"\\]|\\.)*")\s*\)\s*$', value_str, re.DOTALL)
|
||
if m_jp:
|
||
outer_json_str = m_jp.group(1) # the full "..." including surrounding quotes
|
||
try:
|
||
inner = json.loads(outer_json_str) # inner is now a Python str
|
||
except json.JSONDecodeError as exc:
|
||
logger.debug(
|
||
"JSON.parse() outer unescape failed for mfe=%s key=%s: %s", mfe, key, exc
|
||
)
|
||
continue
|
||
try:
|
||
return json.loads(inner)
|
||
except json.JSONDecodeError as exc:
|
||
logger.debug("JSON.parse() inner parse failed for mfe=%s key=%s: %s", mfe, key, exc)
|
||
continue
|
||
|
||
# Strategy 3: raw JS object literal — try demjson3 (optional dep)
|
||
try:
|
||
import demjson3 # type: ignore[import-untyped]
|
||
|
||
return demjson3.decode(value_str)
|
||
except ImportError:
|
||
logger.debug(
|
||
"demjson3 not installed; cannot parse raw JS object for mfe=%s key=%s", mfe, key
|
||
)
|
||
except Exception as exc:
|
||
logger.debug("demjson3 parse failed for mfe=%s key=%s: %s", mfe, key, exc)
|
||
|
||
return None
|
||
|
||
|
||
def extract_all_states(html: str) -> dict[str, dict[str, Any]]:
|
||
"""Returns {mfe_name: {key: parsed_value}} for all push() entries.
|
||
|
||
Useful for discovery / debugging during scraper development.
|
||
"""
|
||
result: dict[str, dict[str, Any]] = {}
|
||
for match in _RE_CIAN_PUSH.finditer(html):
|
||
mfe = match.group("mfe")
|
||
key = match.group("key")
|
||
parsed = extract_state(html, mfe, key)
|
||
if parsed is not None:
|
||
result.setdefault(mfe, {})[key] = parsed
|
||
return result
|