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.
- nedviga/__init__.py +46 -0
- nedviga/__main__.py +6 -0
- nedviga/_wm.py +93 -0
- nedviga/adaptive_selectors.py +100 -0
- nedviga/auditlog.py +77 -0
- nedviga/auto.py +88 -0
- nedviga/bargain.py +183 -0
- nedviga/browser_native.py +242 -0
- nedviga/callsheet.py +63 -0
- nedviga/chains.py +83 -0
- nedviga/checklist.py +119 -0
- nedviga/cian_card.py +1371 -0
- nedviga/cian_live.py +812 -0
- nedviga/cli.py +3118 -0
- nedviga/compare.py +204 -0
- nedviga/data/legal_flags.json +146 -0
- nedviga/data/release_pubkey.asc +22 -0
- nedviga/deck.py +115 -0
- nedviga/dedup.py +242 -0
- nedviga/developer.py +169 -0
- nedviga/digest.py +205 -0
- nedviga/doctor.py +128 -0
- nedviga/dossier.py +303 -0
- nedviga/eco.py +81 -0
- nedviga/errors.py +22 -0
- nedviga/export_html.py +280 -0
- nedviga/export_pdf.py +96 -0
- nedviga/family_card.py +670 -0
- nedviga/feedback.py +79 -0
- nedviga/geo_routing.py +214 -0
- nedviga/graph.py +149 -0
- nedviga/legal_triage.py +105 -0
- nedviga/litprecheck.py +116 -0
- nedviga/market.py +102 -0
- nedviga/mcp_server.py +195 -0
- nedviga/metro_directory.json +9836 -0
- nedviga/metro_vestibules.py +134 -0
- nedviga/models.py +164 -0
- nedviga/mosdata.py +95 -0
- nedviga/net_guard.py +997 -0
- nedviga/noise.py +156 -0
- nedviga/ops.py +94 -0
- nedviga/outcomes.py +35 -0
- nedviga/pii.py +85 -0
- nedviga/pipeline.py +239 -0
- nedviga/poi.py +180 -0
- nedviga/price_guard.py +73 -0
- nedviga/profiles.py +131 -0
- nedviga/projects.py +112 -0
- nedviga/purge.py +116 -0
- nedviga/py.typed +0 -0
- nedviga/risk.py +127 -0
- nedviga/roles.py +124 -0
- nedviga/routing_cache.py +72 -0
- nedviga/rush.py +139 -0
- nedviga/scorefit.py +127 -0
- nedviga/sources.py +55 -0
- nedviga/sources_avito.py +138 -0
- nedviga/sources_domclick.py +122 -0
- nedviga/stale.py +83 -0
- nedviga/textsearch.py +141 -0
- nedviga/tour.py +180 -0
- nedviga/watch.py +234 -0
- nedviga/wizard.py +106 -0
- nedviga_cli-0.1.0.dist-info/METADATA +130 -0
- nedviga_cli-0.1.0.dist-info/RECORD +71 -0
- nedviga_cli-0.1.0.dist-info/WHEEL +5 -0
- nedviga_cli-0.1.0.dist-info/entry_points.txt +2 -0
- nedviga_cli-0.1.0.dist-info/licenses/LICENSE +661 -0
- nedviga_cli-0.1.0.dist-info/licenses/NOTICE +1 -0
- 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
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)
|