gendesign/tradein-mvp/packages/scraper-kit/src/scraper_kit/cian_state_parser.py
lekss361 994eba0a16
All checks were successful
Deploy Trade-In / changes (push) Successful in 11s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Successful in 47s
Deploy Trade-In / build-backend (push) Successful in 1m34s
Deploy Trade-In / deploy (push) Successful in 1m8s
fix(tradein/cian): extract_all_states — discover new .concat() format, not just legacy .push() (#2438)
2026-07-04 21:03:32 +00:00

196 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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_from_concat(html: str) -> dict[str, dict[str, Any]]:
"""Новый формат (``.concat([...])``) — discovery-версия `_extract_from_concat`
без key-фильтра: собирает ВСЕ key/value пары из каждого concat-массива сразу
(один concat-вызов на mfe несёт сразу несколько key/value записей)."""
result: dict[str, dict[str, Any]] = {}
for m in _RE_CIAN_CONCAT.finditer(html):
mfe = m.group("mfe")
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 (discovery): %s", mfe, exc)
continue
for it in items:
if isinstance(it, dict) and "key" in it:
result.setdefault(mfe, {})[it["key"]] = it.get("value")
return result
def extract_all_states(html: str) -> dict[str, dict[str, Any]]:
"""Returns {mfe_name: {key: parsed_value}} for all push()/concat() entries.
Useful for discovery / debugging during scraper development. Bug fix (found live
2026-07-04 while verifying #2435 bti_data persistence): this previously only
scanned the legacy `.push({...})` pattern (`_RE_CIAN_PUSH`) — but Cian switched to
`.concat([...])` in 2026-05 (#639), which `extract_state()` already handles
(concat checked first, push as fallback) while this function never did. Result:
on current-format pages this returned `{}` unconditionally, silently starving
every `extract_all_states()` consumer (bti sister-state in detail.py, newbuilding
sister-state in newbuilding.py) of real data. Mirrors extract_state()'s
concat-first / push-fallback precedence.
"""
result = _extract_all_from_concat(html)
for match in _RE_CIAN_PUSH.finditer(html):
mfe = match.group("mfe")
key = match.group("key")
if key in result.get(mfe, {}):
continue # concat already supplied this mfe/key — don't reparse/overwrite
parsed = extract_state(html, mfe, key)
if parsed is not None:
result.setdefault(mfe, {})[key] = parsed
return result