nedviga-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. nedviga/__init__.py +46 -0
  2. nedviga/__main__.py +6 -0
  3. nedviga/_wm.py +93 -0
  4. nedviga/adaptive_selectors.py +100 -0
  5. nedviga/auditlog.py +77 -0
  6. nedviga/auto.py +88 -0
  7. nedviga/bargain.py +183 -0
  8. nedviga/browser_native.py +242 -0
  9. nedviga/callsheet.py +63 -0
  10. nedviga/chains.py +83 -0
  11. nedviga/checklist.py +119 -0
  12. nedviga/cian_card.py +1371 -0
  13. nedviga/cian_live.py +812 -0
  14. nedviga/cli.py +3118 -0
  15. nedviga/compare.py +204 -0
  16. nedviga/data/legal_flags.json +146 -0
  17. nedviga/data/release_pubkey.asc +22 -0
  18. nedviga/deck.py +115 -0
  19. nedviga/dedup.py +242 -0
  20. nedviga/developer.py +169 -0
  21. nedviga/digest.py +205 -0
  22. nedviga/doctor.py +128 -0
  23. nedviga/dossier.py +303 -0
  24. nedviga/eco.py +81 -0
  25. nedviga/errors.py +22 -0
  26. nedviga/export_html.py +280 -0
  27. nedviga/export_pdf.py +96 -0
  28. nedviga/family_card.py +670 -0
  29. nedviga/feedback.py +79 -0
  30. nedviga/geo_routing.py +214 -0
  31. nedviga/graph.py +149 -0
  32. nedviga/legal_triage.py +105 -0
  33. nedviga/litprecheck.py +116 -0
  34. nedviga/market.py +102 -0
  35. nedviga/mcp_server.py +195 -0
  36. nedviga/metro_directory.json +9836 -0
  37. nedviga/metro_vestibules.py +134 -0
  38. nedviga/models.py +164 -0
  39. nedviga/mosdata.py +95 -0
  40. nedviga/net_guard.py +997 -0
  41. nedviga/noise.py +156 -0
  42. nedviga/ops.py +94 -0
  43. nedviga/outcomes.py +35 -0
  44. nedviga/pii.py +85 -0
  45. nedviga/pipeline.py +239 -0
  46. nedviga/poi.py +180 -0
  47. nedviga/price_guard.py +73 -0
  48. nedviga/profiles.py +131 -0
  49. nedviga/projects.py +112 -0
  50. nedviga/purge.py +116 -0
  51. nedviga/py.typed +0 -0
  52. nedviga/risk.py +127 -0
  53. nedviga/roles.py +124 -0
  54. nedviga/routing_cache.py +72 -0
  55. nedviga/rush.py +139 -0
  56. nedviga/scorefit.py +127 -0
  57. nedviga/sources.py +55 -0
  58. nedviga/sources_avito.py +138 -0
  59. nedviga/sources_domclick.py +122 -0
  60. nedviga/stale.py +83 -0
  61. nedviga/textsearch.py +141 -0
  62. nedviga/tour.py +180 -0
  63. nedviga/watch.py +234 -0
  64. nedviga/wizard.py +106 -0
  65. nedviga_cli-0.1.0.dist-info/METADATA +130 -0
  66. nedviga_cli-0.1.0.dist-info/RECORD +71 -0
  67. nedviga_cli-0.1.0.dist-info/WHEEL +5 -0
  68. nedviga_cli-0.1.0.dist-info/entry_points.txt +2 -0
  69. nedviga_cli-0.1.0.dist-info/licenses/LICENSE +661 -0
  70. nedviga_cli-0.1.0.dist-info/licenses/NOTICE +1 -0
  71. nedviga_cli-0.1.0.dist-info/top_level.txt +1 -0
nedviga/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ """nedviga-cli: Python SDK and CLI for Moscow secondary real estate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cian_card import ParseError, parse_card
6
+ from .cian_live import get_card, search
7
+ from .errors import ChallengeError, FetchError
8
+ from .export_html import export_html
9
+ from .geo_routing import honest_overclaim, walk_route
10
+
11
+ __version__ = "0.1.0"
12
+
13
+
14
+ def walk_distance(
15
+ lat: float,
16
+ lon: float,
17
+ target_station: str,
18
+ *,
19
+ claimed_time: int | None = None,
20
+ ) -> dict:
21
+ """Honest walking distance to the nearest vestibule of ``target_station``.
22
+
23
+ Returns a typed dict with ``honest_walk_route`` (a
24
+ :class:`nedviga.models.HonestWalkRoute` instance) and, when
25
+ ``claimed_time`` is provided, ``honest_overclaim``.
26
+ """
27
+ route = walk_route(lat, lon, target_station)
28
+ data: dict = {"honest_walk_route": route}
29
+ if claimed_time is not None and route.get("status") == "ok":
30
+ data["honest_overclaim"] = honest_overclaim(
31
+ int(route["walk_duration_minutes"]), int(claimed_time)
32
+ )
33
+ return data
34
+
35
+
36
+ __all__ = [
37
+ "__version__",
38
+ "search",
39
+ "get_card",
40
+ "export_html",
41
+ "walk_distance",
42
+ "parse_card",
43
+ "ChallengeError",
44
+ "FetchError",
45
+ "ParseError",
46
+ ]
nedviga/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ import sys
5
+
6
+ sys.exit(main())
nedviga/_wm.py ADDED
@@ -0,0 +1,93 @@
1
+ """Варианты представления вывода (render styles).
2
+
3
+ Выбор функционально эквивалентных формулировок; seed — из env; чисто локально, без сети; вывод семантически идентичен."""
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import hmac
8
+ import os
9
+
10
+ _ENV_SEED = "NEDVIGA_BUILD_SEED"
11
+ _ENV_SECRET = "NEDVIGA_BUILD_SECRET"
12
+
13
+ # вариантные столбцы: кортежи эквивалентных формулировок (смысл/числа неизменны)
14
+ STYLE_VARIANTS: dict[str, tuple[str, ...]] = {
15
+ "digest_header": ("Дайджест за {days} дней", "Сводка изменений за {days} дней",
16
+ "Обновления подборки за {days} дней"),
17
+ "digest_new": ("Новые объекты", "Появились в выдаче", "Новое с момента проверки"),
18
+ "digest_gone": ("Снятые объекты", "Больше не в выдаче", "Исчезли с прошлой проверки"),
19
+ "rush_now": ("Пробки сейчас: {n} баллов", "Загруженность дорог сейчас: {n} баллов",
20
+ "Текущие пробки: {n} баллов"),
21
+ "rush_forecast_tag": ("web-прогноз Яндекса, не гарантируется",
22
+ "прогноз со страницы Яндекса, без гарантий",
23
+ "web-оценка Яндекса, не гарантия"),
24
+ "card_footer": ("данные внешних источников", "внешние источники данных",
25
+ "получено из внешних источников"),
26
+ }
27
+
28
+
29
+ def _seed(version: str) -> bytes:
30
+ s = (os.environ.get(_ENV_SEED) or "").strip()
31
+ return (s or f"public|{version}").encode("utf-8")
32
+
33
+
34
+ def variant(key: str, version: str, *, default_idx: int | None = None) -> int:
35
+ """Индекс варианта для (key, seed, version). Детерминированно, без сети."""
36
+ table = STYLE_VARIANTS.get(key)
37
+ if not table:
38
+ return 0
39
+ if default_idx is not None and 0 <= default_idx < len(table):
40
+ return default_idx
41
+ h = hmac.new(_seed(version), key.encode("utf-8"), hashlib.sha256).digest()
42
+ return h[0] % len(table)
43
+
44
+
45
+ def render_line(key: str, version: str, mapping: dict | None = None) -> str:
46
+ tpl = STYLE_VARIANTS[key][variant(key, version)]
47
+ if mapping:
48
+ for k, v in mapping.items():
49
+ tpl = tpl.replace("{" + k + "}", str(v))
50
+ return tpl
51
+
52
+
53
+ def variant_seed() -> str:
54
+ """Режим сборки для doctor (не раскрывает значение seed)."""
55
+ s = (os.environ.get(_ENV_SEED) or "").strip()
56
+ if not s:
57
+ return "public"
58
+ return "client:" + hashlib.sha256(s.encode()).hexdigest()[:8]
59
+
60
+
61
+ def build_flags(version: str) -> list[str]:
62
+ """HMAC-канарейки сборки; без секрета — пустой список (не выдумываем значения)."""
63
+ secret = (os.environ.get(_ENV_SECRET) or "").strip()
64
+ if not secret:
65
+ return []
66
+ out = []
67
+ for i in range(3):
68
+ d = hmac.new(secret.encode(), f"{version}|{i}".encode(), hashlib.sha256).hexdigest()
69
+ out.append(d[:16])
70
+ return out
71
+
72
+
73
+ # ---- §13 интерфейс ---------------------------------------------------------
74
+ LICENSE = "AGPL-3.0-only"
75
+
76
+
77
+ def source_url() -> str | None:
78
+ return (os.environ.get("NEDVIGA_SOURCE_URL") or "").strip() or None
79
+
80
+
81
+ def source_info(version: str) -> dict:
82
+ url = source_url()
83
+ return {
84
+ "license": LICENSE,
85
+ "source_url": url,
86
+ "how_to_obtain": (f"git clone {url}" if url else
87
+ "источник этой сборки: репозиторий, указанный установщиком; "
88
+ "локально — pip download nedviga-cli --no-deps --no-binary :all:"),
89
+ "agpl_section_13": "каждый пользователь сетевого сервиса на этой программе имеет право "
90
+ "получить её полный исходный код этой версии",
91
+ "build_mode": variant_seed(),
92
+ "version": version,
93
+ }
@@ -0,0 +1,100 @@
1
+ """
2
+ Zero-dependency Адаптивный экстрактор селекторов (Фаза 3, задача P3-3).
3
+ Использует нативный html.parser из стандартной библиотеки Python (без сторонних зависимостей).
4
+ Устойчив к изменениям CSS-классов и динамической обфускации.
5
+ """
6
+
7
+ from html.parser import HTMLParser
8
+ import re
9
+ from typing import Optional, List, Dict, Any
10
+
11
+ PRICE_CLEAN_RE = re.compile(r'[^\d]')
12
+ # P3-3 fix (2026-10-20): цена = ПОСЛЕДНЯЯ денежная группа с якорем ₽/руб,
13
+ # а не слепая склейка всех цифр текста («2-к … 8 700 000 ₽» раньше давало 28700000).
14
+ _MONEY_GROUP_RE = re.compile(r'(?<![\d.])(\d{1,3}(?:[ \u00a0]\d{3})+|\d{5,12})(?=\s*(?:₽|руб))', re.IGNORECASE)
15
+
16
+ class AdaptiveHTMLScanner(HTMLParser):
17
+ def __init__(self):
18
+ super().__init__()
19
+ self.prices: List[float] = []
20
+ self.titles: List[str] = []
21
+ self._current_tag = ""
22
+ self._current_attrs: Dict[str, str] = {}
23
+ self._in_h1 = False
24
+ self._in_price_tag = False
25
+
26
+ def handle_starttag(self, tag: str, attrs: List[tuple]):
27
+ self._current_tag = tag
28
+ attr_dict = {k.lower(): (v or "") for k, v in attrs}
29
+ self._current_attrs = attr_dict
30
+
31
+ if tag == "h1":
32
+ self._in_h1 = True
33
+
34
+ # Проверка itemprop=price
35
+ if attr_dict.get("itemprop") == "price":
36
+ content = attr_dict.get("content")
37
+ if content:
38
+ clean = PRICE_CLEAN_RE.sub("", content)
39
+ if clean:
40
+ self.prices.append(float(clean))
41
+ self._in_price_tag = True
42
+
43
+ # Проверка data-name / data-marker на упоминание price
44
+ for k, v in attr_dict.items():
45
+ if "price" in k or "price" in v.lower():
46
+ self._in_price_tag = True
47
+
48
+ def handle_endtag(self, tag: str):
49
+ if tag == "h1":
50
+ self._in_h1 = False
51
+ self._in_price_tag = False
52
+
53
+ def handle_data(self, data: str):
54
+ clean_text = data.strip()
55
+ if not clean_text:
56
+ return
57
+
58
+ if self._in_h1:
59
+ self.titles.append(clean_text)
60
+
61
+ if self._in_price_tag or "₽" in clean_text:
62
+ if "/м²" not in clean_text and "/мес" not in clean_text:
63
+ matches = _MONEY_GROUP_RE.findall(clean_text)
64
+ if matches:
65
+ self.prices.append(float(matches[-1].replace(" ", "").replace("\u00a0", "")))
66
+ elif self._in_price_tag:
67
+ # fallback для старых маркеров без якоря валюты (itemprop-текст)
68
+ clean_num = PRICE_CLEAN_RE.sub("", clean_text)
69
+ if clean_num and len(clean_num) >= 5:
70
+ self.prices.append(float(clean_num))
71
+
72
+ def extract_price_adaptive(html_content: str) -> Optional[float]:
73
+ scanner = AdaptiveHTMLScanner()
74
+ scanner.feed(html_content)
75
+ if scanner.prices:
76
+ return scanner.prices[0]
77
+ return None
78
+
79
+ def extract_title_adaptive(html_content: str) -> Optional[str]:
80
+ scanner = AdaptiveHTMLScanner()
81
+ scanner.feed(html_content)
82
+ if scanner.titles:
83
+ return scanner.titles[0]
84
+ return None
85
+
86
+ if __name__ == "__main__":
87
+ html_v1 = "<div class='dynamic_hash_98712'><span itemprop='price' content='12500000'>12.5 млн ₽</span></div>"
88
+ html_v2 = "<div data-name='PriceInfo'><span class='obfuscated_123'>14 800 000 ₽</span></div>"
89
+ html_v3 = "<div><h1>2-комн. квартира, 64 м²</h1><p class='random'>21 000 000 ₽</p></div>"
90
+
91
+ p1 = extract_price_adaptive(html_v1)
92
+ p2 = extract_price_adaptive(html_v2)
93
+ p3 = extract_price_adaptive(html_v3)
94
+ t3 = extract_title_adaptive(html_v3)
95
+
96
+ assert p1 == 12500000.0, f"P1 failed: {p1}"
97
+ assert p2 == 14800000.0, f"P2 failed: {p2}"
98
+ assert p3 == 21000000.0, f"P3 failed: {p3}"
99
+ assert t3 == "2-комн. квартира, 64 м²"
100
+ print("[PASS] Native zero-dep adaptive parser successfully extracted all dynamic targets.")
nedviga/auditlog.py ADDED
@@ -0,0 +1,77 @@
1
+ """v0.5.5 (the task brief) audit log."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from datetime import datetime, timedelta, timezone
7
+ from pathlib import Path
8
+ from typing import Any, Optional
9
+
10
+ from . import pipeline
11
+
12
+
13
+ def _state_dir() -> Path:
14
+ return Path(os.environ.get("NEDVIGA_STATE_DIR", str(Path.home() / ".local" / "share" / "nedviga")))
15
+
16
+
17
+ def _log_path(ts=None) -> Path:
18
+ ts = ts or datetime.now(timezone.utc)
19
+ return _state_dir() / f"audit-{ts.strftime('%Y-%m')}.log.jsonl"
20
+
21
+
22
+ def log_action(role: str, command: str, card_id: Optional[str] = None, rc: int = 0,
23
+ reason: Optional[str] = None, argv: Optional[list] = None,
24
+ pii: bool = False, purpose: Optional[str] = None) -> None:
25
+ # skeleton argv: remove values? store flags only roughly; store command + flags (no PII values)
26
+ entry = {
27
+ "ts": datetime.now(timezone.utc).isoformat(),
28
+ "role": role,
29
+ "command": command,
30
+ "card_id": card_id,
31
+ "rc": rc,
32
+ "reason": reason,
33
+ "pii": pii,
34
+ "purpose": purpose,
35
+ "argv_skeleton": [a for a in (argv or []) if a.startswith("-") or a in {"search","get-card","export","card","pipeline","watch","feedback","project","chain","checklist","purge","audit","pii-log"}],
36
+ }
37
+ p = _log_path()
38
+ p.parent.mkdir(parents=True, exist_ok=True)
39
+ with p.open("a", encoding="utf-8") as fh:
40
+ fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
41
+
42
+
43
+ def _all_log_files() -> list[Path]:
44
+ base = _state_dir()
45
+ return sorted(base.glob("audit-*.log.jsonl"))
46
+
47
+
48
+ def size_warn() -> bool:
49
+ total = sum(p.stat().st_size for p in _all_log_files() if p.exists())
50
+ return total > 50 * 1024 * 1024
51
+
52
+
53
+ def read_entries(days: int = 30, role: Optional[str] = None, card: Optional[str] = None,
54
+ pii_only: bool = False) -> list[dict]:
55
+ cutoff = datetime.now(timezone.utc) - timedelta(days=days)
56
+ out = []
57
+ for p in _all_log_files():
58
+ try:
59
+ for line in p.read_text(encoding="utf-8").splitlines():
60
+ if not line.strip():
61
+ continue
62
+ e = json.loads(line)
63
+ ts = datetime.fromisoformat(e["ts"].replace("Z", "+00:00"))
64
+ if ts.tzinfo is None:
65
+ ts = ts.replace(tzinfo=timezone.utc)
66
+ if ts < cutoff:
67
+ continue
68
+ if role and e.get("role") != role:
69
+ continue
70
+ if card and e.get("card_id") != card:
71
+ continue
72
+ if pii_only and not e.get("pii"):
73
+ continue
74
+ out.append(e)
75
+ except Exception:
76
+ continue
77
+ return out
nedviga/auto.py ADDED
@@ -0,0 +1,88 @@
1
+ """v0.5.6 (the task brief) auto selection."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any, Optional
7
+
8
+ from .models import ListingCard
9
+ from .scorefit import score_card
10
+ from . import pipeline
11
+ from .profiles import get_profile
12
+
13
+
14
+ def _load_cards(path: Optional[str]) -> list[ListingCard]:
15
+ if not path:
16
+ return []
17
+ raw = json.loads(Path(path).read_text(encoding="utf-8"))
18
+ if isinstance(raw, dict):
19
+ if "cards" in raw and isinstance(raw["cards"], list):
20
+ raw = raw["cards"]
21
+ elif "data" in raw:
22
+ d = raw["data"]
23
+ raw = d if isinstance(d, list) else ([d] if isinstance(d, dict) else [])
24
+ else:
25
+ raw = [raw]
26
+ if not isinstance(raw, list):
27
+ raw = [raw]
28
+ out = []
29
+ for x in raw:
30
+ if isinstance(x, dict):
31
+ try:
32
+ out.append(ListingCard(**x))
33
+ except Exception:
34
+ pass
35
+ elif isinstance(x, ListingCard):
36
+ out.append(x)
37
+ return out
38
+
39
+
40
+ def _load_profile(profile) -> dict:
41
+ if profile is None:
42
+ return {}
43
+ p = Path(profile)
44
+ if p.exists():
45
+ return json.loads(p.read_text(encoding="utf-8"))
46
+ try:
47
+ return get_profile(profile)
48
+ except Exception:
49
+ return {}
50
+
51
+
52
+ def run(profile=None, file=None, project=None, out=None, live=False, top=20) -> dict:
53
+ prof = _load_profile(profile)
54
+ cards = _load_cards(file)
55
+ if project:
56
+ from .projects import project_cards
57
+ ids = project_cards(project)
58
+ # skeleton cards without geo; still score
59
+ cards = cards + [ListingCard(id=cid, source="cian", url="") for cid in ids]
60
+ # score
61
+ scored = []
62
+ for c in cards:
63
+ r = score_card(c, profile=prof)
64
+ scored.append((r["score"], c))
65
+ scored.sort(key=lambda x: x[0], reverse=True)
66
+ top_cards = [c for _, c in scored[:top]]
67
+ # propose pipeline status=new (idempotent-ish via set_status)
68
+ proposed = []
69
+ for c in top_cards:
70
+ try:
71
+ pipeline.set_status(c.id, "new", note="auto-propose")
72
+ proposed.append(c.id)
73
+ except Exception:
74
+ pass
75
+ result = {"profile": prof, "count": len(top_cards), "proposed": proposed,
76
+ "cards": [{"id": c.id, "score": next(s for s, cc in scored if cc.id == c.id)} for c in top_cards]}
77
+ if out:
78
+ Path(out).write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
79
+ return result
80
+
81
+
82
+ def report(days=7, out=None) -> dict:
83
+ from .digest import build_digest, render
84
+ d = build_digest(days=days)
85
+ text = render(d, fmt="telegram")
86
+ if out:
87
+ Path(out).write_text(text, encoding="utf-8")
88
+ return {"days": days, "text": text, "output": out}
nedviga/bargain.py ADDED
@@ -0,0 +1,183 @@
1
+ """v0.4.9 (the task brief) F-10b: bargain analysis."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from pathlib import Path
6
+ from typing import Any, Iterable, Optional
7
+
8
+ from .models import ListingCard
9
+
10
+
11
+ def _listing(card: Any) -> ListingCard:
12
+ if isinstance(card, ListingCard):
13
+ return card
14
+ if isinstance(card, dict):
15
+ return ListingCard(**card)
16
+ raise TypeError("unsupported card")
17
+
18
+
19
+ def _load_cards(path: str) -> list[ListingCard]:
20
+ raw = json.loads(Path(path).read_text(encoding="utf-8"))
21
+ if isinstance(raw, dict):
22
+ if "cards" in raw and isinstance(raw["cards"], list):
23
+ raw = raw["cards"]
24
+ elif "data" in raw:
25
+ d = raw["data"]
26
+ raw = d if isinstance(d, list) else ([d] if isinstance(d, dict) else [])
27
+ else:
28
+ raw = [raw]
29
+ if not isinstance(raw, list):
30
+ raw = [raw]
31
+ return [_listing(x) for x in raw if isinstance(x, (dict, ListingCard))]
32
+
33
+
34
+ def _ppm2(c: ListingCard) -> Optional[float]:
35
+ v = c.pricing.price_per_m2_rub
36
+ if v is None:
37
+ return None
38
+ try:
39
+ return float(v)
40
+ except (TypeError, ValueError):
41
+ return None
42
+
43
+
44
+ def _percentile(sorted_vals: list[float], q: float) -> float:
45
+ if not sorted_vals:
46
+ return 0.0
47
+ n = len(sorted_vals)
48
+ if n == 1:
49
+ return sorted_vals[0]
50
+ h = (n - 1) * q
51
+ lo = int(h // 1)
52
+ hi = min(lo + 1, n - 1)
53
+ frac = h - lo
54
+ return sorted_vals[lo] * (1 - frac) + sorted_vals[hi] * frac
55
+
56
+
57
+ import re as _re
58
+
59
+ def _complex_name(card: Any) -> Optional[str]:
60
+ try:
61
+ c = card if not isinstance(card, str) else None
62
+ except Exception:
63
+ c = None
64
+ # try building.complex
65
+ b = getattr(card, "building", None) if not isinstance(card, dict) else card.get("building")
66
+ if isinstance(b, dict):
67
+ cx = b.get("complex")
68
+ else:
69
+ cx = getattr(b, "complex", None) if b else None
70
+ if cx:
71
+ return str(cx).strip().lower().replace("«", "").replace("»", "").replace('"', "")
72
+ # title/address
73
+ text = ""
74
+ if isinstance(card, dict):
75
+ text = (card.get("title") or "") + " " + ((card.get("building") or {}).get("address_raw") or "")
76
+ else:
77
+ text = (getattr(card, "title", "") or "") + " " + ((getattr(card.building, "address_raw", "") or "") if getattr(card, "building", None) else "")
78
+ m = _re.search(r"ЖК\s*[«\"]([^»\"]+)[»\"]", text, _re.IGNORECASE)
79
+ if m:
80
+ return m.group(1).strip().lower()
81
+ m = _re.search(r"ЖК\s+([A-Za-zА-Яа-я0-9\-]+)", text, _re.IGNORECASE)
82
+ if m:
83
+ return m.group(1).strip().lower()
84
+ return None
85
+
86
+
87
+ def analyze(target: Any, corpus_paths: Iterable[str], district: Optional[str] = None,
88
+ rooms: Optional[int] = None, by_complex: bool = False) -> dict[str, Any]:
89
+ if isinstance(target, str):
90
+ t = _load_cards(target)[0]
91
+ elif isinstance(target, ListingCard):
92
+ t = target
93
+ else:
94
+ t = _listing(target)
95
+ cards: list[ListingCard] = []
96
+ for p in corpus_paths:
97
+ cards.extend(_load_cards(p))
98
+
99
+ t_district = district or (t.geo.district if t.geo else None)
100
+ t_rooms = rooms if rooms is not None else t.parameters.rooms
101
+ t_complex = _complex_name(t) if by_complex else None
102
+
103
+ group = []
104
+ for c in cards:
105
+ cd = c.geo.district if c.geo else None
106
+ cr = c.parameters.rooms
107
+ if not by_complex:
108
+ if t_district is not None and cd != t_district:
109
+ continue
110
+ else:
111
+ cc = _complex_name(c)
112
+ if t_complex is not None and cc != t_complex:
113
+ continue
114
+ if t_rooms is not None and cr is not None and abs(cr - t_rooms) > 1:
115
+ continue
116
+ p = _ppm2(c)
117
+ if p is not None:
118
+ group.append(p)
119
+
120
+ n = len(group)
121
+ if n < 20:
122
+ basis = (f"по ЖК «{t_complex}»" if (by_complex and t_complex) else (f"по {t_district}" if t_district else "по группе"))
123
+ return {
124
+ "ok": False,
125
+ "reason": "insufficient_sample",
126
+ "n": n,
127
+ "required": 20,
128
+ "district": t_district,
129
+ "rooms": t_rooms,
130
+ "group_basis": basis + f" (n={n})",
131
+ }
132
+
133
+ s = sorted(group)
134
+ median = _percentile(s, 0.5)
135
+ p25 = _percentile(s, 0.25)
136
+ p75 = _percentile(s, 0.75)
137
+ target_price = _ppm2(t)
138
+ deviation = ((target_price - median) / median * 100.0) if median else 0.0
139
+
140
+ args_list = []
141
+ if target_price is not None:
142
+ if target_price > median:
143
+ args_list.append(f"медиана рынка этой группы {median:,.0f} ₽/м², объект на {deviation:.1f}% дороже — в торге уместно ссылаться на P25={p25:,.0f}")
144
+ else:
145
+ args_list.append(f"медиана рынка этой группы {median:,.0f} ₽/м², объект на {abs(deviation):.1f}% дешевле — аргументов для торга меньше")
146
+ args_list.append(f"P25={p25:,.0f}, P75={p75:,.0f} — ориентир для переговоров")
147
+ args_list.append("ссылайтесь на реальные аналоги из корпуса")
148
+ if len(args_list) < 3:
149
+ args_list.append("проверьте состояние и документы")
150
+ args_list = args_list[:5]
151
+
152
+ basis = (f"по ЖК «{t_complex}»" if (by_complex and t_complex) else (f"по {t_district}" if t_district else "по группе"))
153
+ return {
154
+ "ok": True,
155
+ "n": n,
156
+ "district": t_district,
157
+ "rooms": t_rooms,
158
+ "group_basis": basis + f" (n={n})",
159
+ "median": median,
160
+ "p25": p25,
161
+ "p75": p75,
162
+ "target_price_per_m2": target_price,
163
+ "deviation_percent": deviation,
164
+ "arguments": args_list,
165
+ "disclaimer": "оценка по {} объявлениям площадки Cian, не официальная статистика".format(n),
166
+ }
167
+
168
+
169
+ def render_text(result: dict[str, Any]) -> str:
170
+ if not result.get("ok"):
171
+ return f"insufficient_sample: n={result.get('n')}"
172
+ lines = []
173
+ if result.get("group_basis"):
174
+ lines.append(result["group_basis"])
175
+ lines.append(f"медиана: {result['median']:,.0f} ₽/м²".replace(",", " "))
176
+ lines.append(f"P25: {result['p25']:,.0f} ₽/м²".replace(",", " "))
177
+ lines.append(f"P75: {result['p75']:,.0f} ₽/м²".replace(",", " "))
178
+ lines.append(f"отклонение: {result['deviation_percent']:+.1f}%")
179
+ lines.append("аргументы:")
180
+ for a in result["arguments"]:
181
+ lines.append(f"• {a}")
182
+ lines.append(result["disclaimer"])
183
+ return "\n".join(lines)