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/watch.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""v0.4.8 (the task brief) F-08: price/state monitoring."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Optional
|
|
7
|
+
|
|
8
|
+
from . import pipeline
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _extract_price(card: dict) -> Optional[float]:
|
|
12
|
+
if not isinstance(card, dict):
|
|
13
|
+
return None
|
|
14
|
+
if "price_total_rub" in card and card.get("price_total_rub") is not None:
|
|
15
|
+
try:
|
|
16
|
+
return float(card["price_total_rub"])
|
|
17
|
+
except (TypeError, ValueError):
|
|
18
|
+
pass
|
|
19
|
+
pricing = card.get("pricing")
|
|
20
|
+
if isinstance(pricing, dict):
|
|
21
|
+
v = pricing.get("price_total_rub")
|
|
22
|
+
if v is not None:
|
|
23
|
+
try:
|
|
24
|
+
return float(v)
|
|
25
|
+
except (TypeError, ValueError):
|
|
26
|
+
return None
|
|
27
|
+
return None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _extract_status(card: dict) -> Optional[str]:
|
|
31
|
+
if not isinstance(card, dict):
|
|
32
|
+
return None
|
|
33
|
+
for key in ("status", "sale_status"):
|
|
34
|
+
if card.get(key):
|
|
35
|
+
return str(card[key])
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _extract_floors(card: dict) -> Optional[int]:
|
|
40
|
+
if not isinstance(card, dict):
|
|
41
|
+
return None
|
|
42
|
+
params = card.get("parameters") or {}
|
|
43
|
+
if isinstance(params, dict):
|
|
44
|
+
v = params.get("floors_total")
|
|
45
|
+
if v is not None:
|
|
46
|
+
try:
|
|
47
|
+
return int(v)
|
|
48
|
+
except (TypeError, ValueError):
|
|
49
|
+
pass
|
|
50
|
+
v = card.get("floors_total")
|
|
51
|
+
if v is not None:
|
|
52
|
+
try:
|
|
53
|
+
return int(v)
|
|
54
|
+
except (TypeError, ValueError):
|
|
55
|
+
pass
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def add(card_id: Optional[str] = None, file: Optional[str] = None, price: Optional[float] = None,
|
|
60
|
+
status: str = "active", floors_total: Optional[int] = None, db=None) -> dict[str, Any]:
|
|
61
|
+
conn = pipeline._connect(db)
|
|
62
|
+
try:
|
|
63
|
+
if file:
|
|
64
|
+
raw = json.loads(Path(file).read_text(encoding="utf-8"))
|
|
65
|
+
if isinstance(raw, dict):
|
|
66
|
+
if "cards" in raw and isinstance(raw["cards"], list):
|
|
67
|
+
raw = raw["cards"]
|
|
68
|
+
elif "data" in raw:
|
|
69
|
+
d = raw["data"]
|
|
70
|
+
raw = d if isinstance(d, list) else ([d] if isinstance(d, dict) else [])
|
|
71
|
+
else:
|
|
72
|
+
raw = [raw]
|
|
73
|
+
if not isinstance(raw, list):
|
|
74
|
+
raw = [raw]
|
|
75
|
+
added = []
|
|
76
|
+
for item in raw:
|
|
77
|
+
if not isinstance(item, dict):
|
|
78
|
+
continue
|
|
79
|
+
cid = item.get("id")
|
|
80
|
+
if not cid:
|
|
81
|
+
continue
|
|
82
|
+
p = price if price is not None else _extract_price(item)
|
|
83
|
+
st = status if status else (_extract_status(item) or "active")
|
|
84
|
+
fl = floors_total if floors_total is not None else _extract_floors(item)
|
|
85
|
+
now = pipeline._now()
|
|
86
|
+
conn.execute(
|
|
87
|
+
"INSERT OR REPLACE INTO watch_snapshots (card_id, price, status, floors_total, snapshot_at, raw_json) VALUES (?,?,?,?,?,?)",
|
|
88
|
+
(str(cid), p, st, fl, now, json.dumps(item, ensure_ascii=False)),
|
|
89
|
+
)
|
|
90
|
+
conn.execute(
|
|
91
|
+
"INSERT INTO watch_history (card_id, price, status, at) VALUES (?,?,?,?)",
|
|
92
|
+
(str(cid), p, st, now),
|
|
93
|
+
)
|
|
94
|
+
added.append(str(cid))
|
|
95
|
+
conn.commit()
|
|
96
|
+
return {"ok": True, "added": added}
|
|
97
|
+
if not card_id:
|
|
98
|
+
return {"ok": False, "reason": "missing_card_id"}
|
|
99
|
+
now = pipeline._now()
|
|
100
|
+
conn.execute(
|
|
101
|
+
"INSERT OR REPLACE INTO watch_snapshots (card_id, price, status, floors_total, snapshot_at, raw_json) VALUES (?,?,?,?,?,?)",
|
|
102
|
+
(card_id, price, status, floors_total, now, None),
|
|
103
|
+
)
|
|
104
|
+
conn.execute(
|
|
105
|
+
"INSERT INTO watch_history (card_id, price, status, at) VALUES (?,?,?,?)",
|
|
106
|
+
(card_id, price, status, now),
|
|
107
|
+
)
|
|
108
|
+
conn.commit()
|
|
109
|
+
return {"ok": True, "added": [card_id]}
|
|
110
|
+
finally:
|
|
111
|
+
conn.close()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def list_items(db=None) -> list[dict[str, Any]]:
|
|
115
|
+
conn = pipeline._connect(db)
|
|
116
|
+
try:
|
|
117
|
+
rows = conn.execute("SELECT * FROM watch_snapshots ORDER BY card_id").fetchall()
|
|
118
|
+
return [dict(r) for r in rows]
|
|
119
|
+
finally:
|
|
120
|
+
conn.close()
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def remove(card_id: str, db=None) -> dict[str, Any]:
|
|
124
|
+
conn = pipeline._connect(db)
|
|
125
|
+
try:
|
|
126
|
+
conn.execute("DELETE FROM watch_snapshots WHERE card_id=?", (card_id,))
|
|
127
|
+
conn.commit()
|
|
128
|
+
return {"ok": True, "removed": card_id}
|
|
129
|
+
finally:
|
|
130
|
+
conn.close()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _normalize_fresh(raw) -> dict[str, dict]:
|
|
134
|
+
if isinstance(raw, dict):
|
|
135
|
+
if "cards" in raw and isinstance(raw["cards"], list):
|
|
136
|
+
raw = raw["cards"]
|
|
137
|
+
elif "data" in raw:
|
|
138
|
+
d = raw["data"]
|
|
139
|
+
raw = d if isinstance(d, list) else ([d] if isinstance(d, dict) else [])
|
|
140
|
+
else:
|
|
141
|
+
raw = [raw]
|
|
142
|
+
if not isinstance(raw, list):
|
|
143
|
+
raw = [raw]
|
|
144
|
+
out = {}
|
|
145
|
+
for item in raw:
|
|
146
|
+
if isinstance(item, dict) and item.get("id"):
|
|
147
|
+
out[str(item["id"])] = item
|
|
148
|
+
return out
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def check(offline: Optional[str] = None, db=None) -> list[dict[str, Any]]:
|
|
152
|
+
conn = pipeline._connect(db)
|
|
153
|
+
try:
|
|
154
|
+
rows = conn.execute("SELECT * FROM watch_snapshots").fetchall()
|
|
155
|
+
finally:
|
|
156
|
+
conn.close()
|
|
157
|
+
|
|
158
|
+
fresh_map: dict[str, dict] = {}
|
|
159
|
+
if offline:
|
|
160
|
+
try:
|
|
161
|
+
raw = json.loads(Path(offline).read_text(encoding="utf-8"))
|
|
162
|
+
fresh_map = _normalize_fresh(raw)
|
|
163
|
+
except Exception as exc:
|
|
164
|
+
# v0.4.8 контроль качества: битый файл раньше молча превращался в {} и
|
|
165
|
+
# каждая карточка получала ложное "disappeared". Только явный degraded.
|
|
166
|
+
return [{"card_id": "*", "type": "source_degraded",
|
|
167
|
+
"reason": f"offline_file_unreadable: {type(exc).__name__}"}]
|
|
168
|
+
|
|
169
|
+
events: list[dict[str, Any]] = []
|
|
170
|
+
|
|
171
|
+
for row in rows:
|
|
172
|
+
cid = row["card_id"]
|
|
173
|
+
if offline:
|
|
174
|
+
fresh = fresh_map.get(cid)
|
|
175
|
+
if fresh is None:
|
|
176
|
+
events.append({"card_id": cid, "type": "disappeared"})
|
|
177
|
+
_h = pipeline._connect(db)
|
|
178
|
+
try:
|
|
179
|
+
_h.execute("INSERT INTO watch_history (card_id, price, status, at) VALUES (?,?,?,?)",
|
|
180
|
+
(cid, None, "disappeared", pipeline._now()))
|
|
181
|
+
_h.commit()
|
|
182
|
+
finally:
|
|
183
|
+
_h.close()
|
|
184
|
+
continue
|
|
185
|
+
old_price = row["price"]
|
|
186
|
+
new_price = _extract_price(fresh)
|
|
187
|
+
if old_price is not None and new_price is not None:
|
|
188
|
+
if new_price < old_price:
|
|
189
|
+
events.append({"card_id": cid, "type": "price_down", "amount": old_price - new_price})
|
|
190
|
+
elif new_price > old_price:
|
|
191
|
+
events.append({"card_id": cid, "type": "price_up", "amount": new_price - old_price})
|
|
192
|
+
st = _extract_status(fresh)
|
|
193
|
+
if st and st.lower() in {"sold", "removed", "inactive"}:
|
|
194
|
+
events.append({"card_id": cid, "type": "sold" if st.lower() == "sold" else "removed"})
|
|
195
|
+
title = str(fresh.get("title") or "").lower()
|
|
196
|
+
if "продан" in title or "снят" in title:
|
|
197
|
+
events.append({"card_id": cid, "type": "sold"})
|
|
198
|
+
_h = pipeline._connect(db)
|
|
199
|
+
try:
|
|
200
|
+
_h.execute("INSERT INTO watch_history (card_id, price, status, at) VALUES (?,?,?,?)",
|
|
201
|
+
(cid, new_price, st or "active", pipeline._now()))
|
|
202
|
+
_h.commit()
|
|
203
|
+
finally:
|
|
204
|
+
_h.close()
|
|
205
|
+
continue
|
|
206
|
+
|
|
207
|
+
# live path
|
|
208
|
+
try:
|
|
209
|
+
from .cian_live import get_card
|
|
210
|
+
card = get_card(cid)
|
|
211
|
+
except Exception as exc:
|
|
212
|
+
msg = str(exc).lower()
|
|
213
|
+
if "404" in msg or "not found" in msg or "removed" in msg:
|
|
214
|
+
events.append({"card_id": cid, "type": "disappeared"})
|
|
215
|
+
else:
|
|
216
|
+
events.append({"card_id": cid, "type": "source_degraded", "reason": str(exc)})
|
|
217
|
+
continue
|
|
218
|
+
# extract from card
|
|
219
|
+
try:
|
|
220
|
+
data = card.model_dump(mode="json", exclude_none=False) if hasattr(card, "model_dump") else {}
|
|
221
|
+
except Exception:
|
|
222
|
+
data = {}
|
|
223
|
+
new_price = _extract_price(data)
|
|
224
|
+
old_price = row["price"]
|
|
225
|
+
if old_price is not None and new_price is not None:
|
|
226
|
+
if new_price < old_price:
|
|
227
|
+
events.append({"card_id": cid, "type": "price_down", "amount": old_price - new_price})
|
|
228
|
+
elif new_price > old_price:
|
|
229
|
+
events.append({"card_id": cid, "type": "price_up", "amount": new_price - old_price})
|
|
230
|
+
st = _extract_status(data)
|
|
231
|
+
if st and st.lower() in {"sold", "removed", "inactive"}:
|
|
232
|
+
events.append({"card_id": cid, "type": "sold" if st.lower() == "sold" else "removed"})
|
|
233
|
+
|
|
234
|
+
return events
|
nedviga/wizard.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""v0.5.11 (the task brief) wizard."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
|
|
9
|
+
from .roles import load_roles
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def init(profile: str = "agent", out: Optional[str] = None) -> dict:
|
|
13
|
+
base = Path(out) if out else Path.cwd() / "wizard_out"
|
|
14
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
15
|
+
# roles.json
|
|
16
|
+
roles = [{"name": "agent", "role": "agent", "since": "2026-01-01"}]
|
|
17
|
+
if profile == "family":
|
|
18
|
+
roles.append({"name": "family", "role": "family", "since": "2026-01-01"})
|
|
19
|
+
(base / "roles.json").write_text(json.dumps(roles, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
20
|
+
# env template
|
|
21
|
+
(base / "env.sh").write_text(
|
|
22
|
+
'export NEDVIGA_EGRESS_PROXY="http://host:port"\n'
|
|
23
|
+
'export NEDVIGA_MOS_TOKEN_FILE="/path/token.txt"\n'
|
|
24
|
+
'export NEDVIGA_STATE_DIR="$HOME/.local/share/nedviga"\n',
|
|
25
|
+
encoding="utf-8",
|
|
26
|
+
)
|
|
27
|
+
# profile template
|
|
28
|
+
(base / "profile.json").write_text(json.dumps({"rooms": 2, "price_max": 20000000}, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
29
|
+
# cron example
|
|
30
|
+
(base / "cron.txt").write_text(
|
|
31
|
+
"0 */6 * * * nedviga-cli watch check --offline /path/fresh.json\n"
|
|
32
|
+
"0 8 * * * nedviga-cli digest --days 1 --format telegram\n",
|
|
33
|
+
encoding="utf-8",
|
|
34
|
+
)
|
|
35
|
+
# templates dir
|
|
36
|
+
tdir = base / "profiles" / "templates"
|
|
37
|
+
tdir.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
(tdir / "metro_secondary.json").write_text(json.dumps({"rooms": 2, "metro_walk_max": 15}, ensure_ascii=False), encoding="utf-8")
|
|
39
|
+
(tdir / "novostroyka.json").write_text(json.dumps({"rooms": 2, "price_max": 15000000}, ensure_ascii=False), encoding="utf-8")
|
|
40
|
+
(tdir / "investment.json").write_text(json.dumps({"price_max": 12000000}, ensure_ascii=False), encoding="utf-8")
|
|
41
|
+
return {"ok": True, "out": str(base)}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def doctor(json_out: bool = False) -> dict:
|
|
45
|
+
import sys
|
|
46
|
+
state_dir = Path(os.environ.get("NEDVIGA_STATE_DIR", str(Path.home() / ".local" / "share" / "nedviga")))
|
|
47
|
+
checks = []
|
|
48
|
+
checks.append({"name": "python", "ok": sys.version_info >= (3, 11), "detail": sys.version.split()[0]})
|
|
49
|
+
checks.append({"name": "state_dir_writable", "ok": _writable(state_dir), "detail": str(state_dir)})
|
|
50
|
+
egress = os.environ.get("NEDVIGA_EGRESS_PROXY")
|
|
51
|
+
checks.append({"name": "egress_mode", "ok": bool(egress), "detail": egress or "not set (direct mode)"})
|
|
52
|
+
tok_file = os.environ.get("NEDVIGA_MOS_TOKEN_FILE")
|
|
53
|
+
tok_ok = False
|
|
54
|
+
if tok_file:
|
|
55
|
+
try:
|
|
56
|
+
tok_ok = Path(tok_file).is_file() and Path(tok_file).stat().st_size > 0
|
|
57
|
+
except Exception:
|
|
58
|
+
tok_ok = False
|
|
59
|
+
checks.append({"name": "token_file", "ok": tok_ok, "detail": tok_file or "not set"})
|
|
60
|
+
# netguard import
|
|
61
|
+
try:
|
|
62
|
+
import nedviga.net_guard
|
|
63
|
+
netguard_ok = True
|
|
64
|
+
except Exception:
|
|
65
|
+
netguard_ok = False
|
|
66
|
+
checks.append({"name": "netguard", "ok": netguard_ok, "detail": "importable" if netguard_ok else "fail"})
|
|
67
|
+
# registry counts
|
|
68
|
+
try:
|
|
69
|
+
import sqlite3
|
|
70
|
+
db = state_dir / "mos_mkd.sqlite"
|
|
71
|
+
if db.exists():
|
|
72
|
+
conn = sqlite3.connect(str(db))
|
|
73
|
+
c1 = conn.execute("SELECT COUNT(*) FROM mkd").fetchone()[0]
|
|
74
|
+
c2 = 0
|
|
75
|
+
try:
|
|
76
|
+
c2 = conn.execute("SELECT COUNT(*) FROM mkd520").fetchone()[0]
|
|
77
|
+
except Exception:
|
|
78
|
+
c2 = 0
|
|
79
|
+
conn.close()
|
|
80
|
+
checks.append({"name": "registry", "ok": True, "detail": f"mkd={c1}, mkd520={c2}"})
|
|
81
|
+
else:
|
|
82
|
+
checks.append({"name": "registry", "ok": True, "detail": "no db yet"})
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
checks.append({"name": "registry", "ok": False, "detail": str(exc)})
|
|
85
|
+
ok = all(c["ok"] for c in checks if c["name"] in {"python", "state_dir_writable", "netguard"})
|
|
86
|
+
return {"ok": ok, "checks": checks}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _writable(p: Path) -> bool:
|
|
90
|
+
try:
|
|
91
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
92
|
+
test = p / ".wizard_write_test"
|
|
93
|
+
test.write_text("x")
|
|
94
|
+
test.unlink()
|
|
95
|
+
return True
|
|
96
|
+
except Exception:
|
|
97
|
+
return False
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def render_text(res: dict) -> str:
|
|
101
|
+
lines = []
|
|
102
|
+
for c in res["checks"]:
|
|
103
|
+
mark = "OK" if c["ok"] else "FAIL"
|
|
104
|
+
lines.append(f"[{mark}] {c['name']}: {c['detail']}")
|
|
105
|
+
lines.append("overall: " + ("OK" if res["ok"] else "FAIL"))
|
|
106
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nedviga-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI and SDK for Moscow secondary real estate monitoring
|
|
5
|
+
Author-email: lnfnt <lnfnt@proton.me>
|
|
6
|
+
License-Expression: AGPL-3.0-only
|
|
7
|
+
Keywords: real-estate,pricing,offline-analysis,cli
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
License-File: NOTICE
|
|
12
|
+
Requires-Dist: pydantic>=2
|
|
13
|
+
Requires-Dist: pyyaml
|
|
14
|
+
Requires-Dist: requests>=2.28
|
|
15
|
+
Requires-Dist: camoufox[geoip]>=0.4
|
|
16
|
+
Requires-Dist: Pillow>=10.0
|
|
17
|
+
Requires-Dist: beautifulsoup4[lxml]>=4.12
|
|
18
|
+
Provides-Extra: cli
|
|
19
|
+
Requires-Dist: click>=8; extra == "cli"
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
22
|
+
Provides-Extra: pdf
|
|
23
|
+
Requires-Dist: weasyprint>=60; extra == "pdf"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# nedviga-cli
|
|
27
|
+
|
|
28
|
+
    
|
|
29
|
+
|
|
30
|
+
<p align="center"><img src="assets/poster.jpg" width="820" alt="nedviga-cli poster"></p>
|
|
31
|
+
|
|
32
|
+
CLI и Python SDK для подбора и анализа жилой недвижимости Москвы. Рабочий цикл: поиск,
|
|
33
|
+
карточка решения, сравнение, торг, проверка новостроек/застройщиков, инфраструктура,
|
|
34
|
+
мониторинг изменений, отчёт. Для ИИ-агентов: MCP-сервер, JSON-конверты, роли, аудит.
|
|
35
|
+
|
|
36
|
+
**Русский** · [<img src="assets/flags/gb.svg" height="16" align="top" alt="EN"> English](README.en.md)
|
|
37
|
+
|
|
38
|
+
## Установка
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install git+https://github.com/lnfnt/nedviga-cli.git
|
|
42
|
+
nedviga-cli wizard doctor
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
На PyPI пакет не опубликован. Checkout: `python3.11 -m venv .venv && source .venv/bin/activate
|
|
46
|
+
&& pip install -e ".[cli,dev]"`. Python >= 3.11.
|
|
47
|
+
Один раз подготовить браузерный рантайм для live-адаптеров:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
XDG_CACHE_HOME="$HOME/.cache/nedviga-py" python3 -m camoufox fetch
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Возможности
|
|
54
|
+
|
|
55
|
+
- **Источники:** ЦИАН, Авито, Домклик (только через ваш egress `NEDVIGA_EGRESS_PROXY`), открытые
|
|
56
|
+
данные Москвы (досья домов, ЖКХ, реестр новостроек, застройщики по ИНН с банками и судами,
|
|
57
|
+
шум-мониторинг). ЕГРН не используется — модуль приёма и проверки выписки от собственника.
|
|
58
|
+
- **Аналитика решения:** семейная карта объекта, сравнительная матрица, медианы торга по
|
|
59
|
+
комплексу (`insufficient_sample` при малой выборке), антиприманочные флаги, risk-скоринг,
|
|
60
|
+
юридические триаж-маркеры (11 флагов).
|
|
61
|
+
- **Цикл сделки:** пайплайн статусов (shortlist→offer→…), watch цены/снятия/перевыставления,
|
|
62
|
+
дайджесты, автоподбор по профилю, цепочки альтернатив, скоринг соответствия.
|
|
63
|
+
- **Артефакты:** PDF/HTML-каталог, PPTX-презентации (`deck`), план показов, прозвон-листы,
|
|
64
|
+
чек-листы передачи юристу.
|
|
65
|
+
- **Инфраструктура:** POI в пешей доступности, загруженность дорог и почасовой прогноз
|
|
66
|
+
(web-оценка, помечена как таковая), замеры шума с датами и дистанциями.
|
|
67
|
+
- **Безопасность:** fail-closed сетевой страж, spotlighting внешнего текста
|
|
68
|
+
`<external_untrusted_data>`, роли и per-record аудит, ПДн-минимизация (контакты — только по
|
|
69
|
+
явному флагу с указанной целью), идемпотентный purge.
|
|
70
|
+
|
|
71
|
+
## Быстрый старт
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
nedviga-cli wizard init --profile family --out ./kit
|
|
75
|
+
nedviga-cli search --source cian --rooms 2 --price-max 20000000 \
|
|
76
|
+
--geo-districts "ЮАО,ЮЗАО" --out cards.json
|
|
77
|
+
nedviga-cli compare --file cards.json --format telegram
|
|
78
|
+
nedviga-cli card cards.json --format all --out ./out
|
|
79
|
+
nedviga-cli watch add --file cards.json
|
|
80
|
+
nedviga-cli watch check --offline fresh.json
|
|
81
|
+
nedviga-cli noise sync && nedviga-cli noise --lat 55.72 --lon 37.61
|
|
82
|
+
nedviga-cli dossier match --address "улица Эталонная, дом 4"
|
|
83
|
+
nedviga-cli source
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Примеры по каждой команде — в `--help`.
|
|
87
|
+
|
|
88
|
+
### Конфигурация
|
|
89
|
+
|
|
90
|
+
| Переменная | Назначение |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `NEDVIGA_EGRESS_PROXY` | HTTP-выход для браузерных адаптеров; без него живые источники = `source_disabled` |
|
|
93
|
+
| `NEDVIGA_MOS_TOKEN_FILE` | файл ключа api.mos.ru (не значение в env) |
|
|
94
|
+
| `NEDVIGA_STATE_DIR` | каталог состояния |
|
|
95
|
+
| `NEDVIGA_FEED_URL` | опциональный сервер манифестов свежести адаптеров |
|
|
96
|
+
|
|
97
|
+
## Контракт вывода
|
|
98
|
+
|
|
99
|
+
Один JSON-конверт в stdout (`ok/status/reason/source/data/metadata`), логи в stderr.
|
|
100
|
+
|
|
101
|
+
| Код | Значение |
|
|
102
|
+
|---|---|
|
|
103
|
+
| 0 | данные получены, контракт валиден |
|
|
104
|
+
| 1 | критично для окружения (нет браузерного рантайма/кэша) |
|
|
105
|
+
| 2 | деградация источника (WAF, капча, rate-limit) |
|
|
106
|
+
| 3 | ошибка источника/парсера (изменение DOM, `source_disabled`, `source_blocked`) |
|
|
107
|
+
| 4 | невалидные аргументы CLI |
|
|
108
|
+
|
|
109
|
+
## Ограничения
|
|
110
|
+
|
|
111
|
+
- Данные московские; для других городов адаптеров нет.
|
|
112
|
+
- Прогноза цен нет: медианы и watch-дельты — факты с датой.
|
|
113
|
+
- Прогноз транспорта — web-оценка, помечается как негарантийная.
|
|
114
|
+
- Контакты ПДн извлекаются только по явному флагу.
|
|
115
|
+
|
|
116
|
+
## Python API
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from nedviga.sources_avito import parse_livetext
|
|
120
|
+
from nedviga.deck import deck_from_cards
|
|
121
|
+
cards = parse_livetext(open("avito_text.txt").read())
|
|
122
|
+
deck_from_cards(cards, "подборка.pptx", title="ЮАО, двушки")
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Лицензия и проверка релиза
|
|
126
|
+
|
|
127
|
+
AGPL-3.0-only; сетевой сервис на этом коде обязан предоставить исходники (§13,
|
|
128
|
+
`nedviga-cli source`). Артефакты: `sha256sum -c SHA256SUMS`, `gpg --verify *.asc` (ключ в релизе).
|
|
129
|
+
|
|
130
|
+
Автор и правообладатель: [lnfnt](mailto:lnfnt@proton.me).
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
nedviga/__init__.py,sha256=YizGV0QnpF4IqZqnYfix-mI4jMjVdUV3DwfervHfnj8,1239
|
|
2
|
+
nedviga/__main__.py,sha256=ThepMt9BtHVElr7tT2VsFgwGReNtd5Pfofq1oRWCmPM,87
|
|
3
|
+
nedviga/_wm.py,sha256=pnU0CJvreeFOnQZ8gEZbsQA3olYOjdUB07jmr9KO_48,4409
|
|
4
|
+
nedviga/adaptive_selectors.py,sha256=3FfVBWOkYFtJYThIWJz0cUCs7K_cTMWYMlEBhElXKcE,4210
|
|
5
|
+
nedviga/auditlog.py,sha256=-CkvZJ8rkziK0_WDgohZ13AlyeP6kzQMrqSZLp2wv2o,2721
|
|
6
|
+
nedviga/auto.py,sha256=L5WC550XC2l9m7KPQL1rWbsOIp6GhKuky00H5sYZAxA,2770
|
|
7
|
+
nedviga/bargain.py,sha256=CFy_q5HcQkvmnmBWMbcx6GsFwrCy3wqLDXS22KXsR24,6688
|
|
8
|
+
nedviga/browser_native.py,sha256=IDjIwUChtkJDhspcmfmOP0Wth2xghDb8YMysu8A6MUY,8479
|
|
9
|
+
nedviga/callsheet.py,sha256=7z6P-IW2qiTkJKJk04O9iV-I-vghFgS7FXHUccntNA4,2562
|
|
10
|
+
nedviga/chains.py,sha256=wuDKTWRixEK77BvmpZDQdfVqWwil3jvs1Rkt1WRU8AY,2745
|
|
11
|
+
nedviga/checklist.py,sha256=_yUwMrv6lh9WX6tzWadfcBMWGmnrITqdBnfg0Z7YWE8,4238
|
|
12
|
+
nedviga/cian_card.py,sha256=YJtC0fxP4JlYgnB3_-zyaAV4v8isfdG70sHPgPOdSZk,51986
|
|
13
|
+
nedviga/cian_live.py,sha256=6kutTTjvwvP4JIMbohTddw2VAxwSVzXJDb1MDPSxvmg,32197
|
|
14
|
+
nedviga/cli.py,sha256=NqeKXU-UY5HD4CX_qcSkTiDiDu2L8Bmp4KRqTisiPqI,147630
|
|
15
|
+
nedviga/compare.py,sha256=wLQGiLp2dP__1bn94TfpgsxzU9cWr5PqgJ8LFzAGUMQ,7124
|
|
16
|
+
nedviga/deck.py,sha256=Th4Iz_XTr_UDTJ03I_FcQQvp6Itahegm7TMCHVd17K8,5391
|
|
17
|
+
nedviga/dedup.py,sha256=N524Ylh_V0L_KOnehla6_2LkXWwH8cr-_bVODu-1-5Y,7759
|
|
18
|
+
nedviga/developer.py,sha256=R5vwt80ixqIzSuCBWwbLAFoI94ieOUoFYTdcrs43SWQ,5685
|
|
19
|
+
nedviga/digest.py,sha256=Ko9ELqXQmPElxgCtVlD9vwMLy5sXM4hxO4AfTzOgAMw,7126
|
|
20
|
+
nedviga/doctor.py,sha256=KzOZiIzfmFta30GEnz4G5sEwVThj6j_3lI8Zjl5Urnk,5064
|
|
21
|
+
nedviga/dossier.py,sha256=-f_Rnz2PSg1K-O9YAn_1YWVmr9-TCMRkkqZPvtaHtYg,15611
|
|
22
|
+
nedviga/eco.py,sha256=w1XpFE6eh3p2RT2EeWhCmmxVMNDGIPH6kQt2lqSdeS4,3155
|
|
23
|
+
nedviga/errors.py,sha256=iQJK2bDJFOdu1qzsBgrLkfPoNr80sLe-aP0eIZMqui8,610
|
|
24
|
+
nedviga/export_html.py,sha256=NJsZPDXHc1kt3zonYTQl__lmc6K7o72GWNB78HxNMp0,11987
|
|
25
|
+
nedviga/export_pdf.py,sha256=0jeGnWEjhHVeNT6TK7m4D5Uw1Nkn0XJ8EWjgL2WOmDk,3544
|
|
26
|
+
nedviga/family_card.py,sha256=5xfjmuPIyHG0SmTLFdttiUi_Q0t2bRLlpy1DoJhuoxE,26761
|
|
27
|
+
nedviga/feedback.py,sha256=AFoppVCjIYysXT7PvFDbTdgDahA_cPOut50R6blmSLU,2867
|
|
28
|
+
nedviga/geo_routing.py,sha256=_k0t-dwCK9Md0gjzlahMrVNjPXTE6xgR4b0dkiJN8gA,7720
|
|
29
|
+
nedviga/graph.py,sha256=KqPElp4kAg2eAI7osogek7KjaSs1vokYc0fXFsCTyVA,6164
|
|
30
|
+
nedviga/legal_triage.py,sha256=KWWT2SOuBT7nN7FMvi3POILNj9B8Ud-FXYVj34G9QV4,3160
|
|
31
|
+
nedviga/litprecheck.py,sha256=-6V9qQd5mVnBNBPqCpzriOSIkapgOqMAI2_wi_2EAUs,3823
|
|
32
|
+
nedviga/market.py,sha256=-zzoXBOj7mnez0eGa-nZqK0DWK779sl-a8FAZ4vzROU,3581
|
|
33
|
+
nedviga/mcp_server.py,sha256=FYDcSABQSNWA60wYIRYXOOoU2KqW46pVDAWVu452c_s,7124
|
|
34
|
+
nedviga/metro_directory.json,sha256=Pnf89sBjX0pWYIAhk7Lw3AIx9yNDmTnhpL7uslIvq8M,164807
|
|
35
|
+
nedviga/metro_vestibules.py,sha256=-9rO1iF0em1Tl1SNs6F3cYh_IaTXnkJ-dSqFjIX_hoQ,6226
|
|
36
|
+
nedviga/models.py,sha256=j6natBfa3xMwTKx-9fn4iV3TTyTMqbZ8zpQJcNYoLQs,6057
|
|
37
|
+
nedviga/mosdata.py,sha256=rWUFbL3ydnAQFCT8Yw-KHx44IY6y_BVBYfB476DLJVU,3393
|
|
38
|
+
nedviga/net_guard.py,sha256=qTYZJl-ohEIl9467YXCNLNCWjeIGwQdfemBc0OFYeWY,39271
|
|
39
|
+
nedviga/noise.py,sha256=EGRcN2TCf6H1DIw3VP-LDpREkszTJgYMPOJIViuo-Ik,6906
|
|
40
|
+
nedviga/ops.py,sha256=sKaL2z3FVW3ntJHQr0x0SQcj8mzPzTkEVC8rg8GbK2w,3385
|
|
41
|
+
nedviga/outcomes.py,sha256=6GrsVITn1JofjoDNXakuP64oO7bkdzCz41fmcfuaB9I,791
|
|
42
|
+
nedviga/pii.py,sha256=QZh5IAkvLVLzM18x2TAwuzqc9t5e0lM6J4oAL-NjLh4,3497
|
|
43
|
+
nedviga/pipeline.py,sha256=iuaWwMQoOwiT5KrBk5K6tTi-kmuVRR--raCUhmBVj3I,8533
|
|
44
|
+
nedviga/poi.py,sha256=7Yt7qnJtdBCSEvL2Oi2_3h1aPmQbFsHoUinxn0k3tsI,6480
|
|
45
|
+
nedviga/price_guard.py,sha256=4_i0vZs-1ZPk-rn6ituzXHeplZPf_SqGCROazfcJLW0,3111
|
|
46
|
+
nedviga/profiles.py,sha256=7BsDTcc2ZO96_Dvm7cRpZoq4BfDsXWZ_plaQL-VsS8o,5103
|
|
47
|
+
nedviga/projects.py,sha256=DTHvP7peCYPwfEZynrSuLH3MmaG3IR3Ax6lYDJy8SCc,3985
|
|
48
|
+
nedviga/purge.py,sha256=TpgS2tLpJ7Yvc73Vmo4Cr7O1GlXVf8ZrbDCpTj--VKw,4175
|
|
49
|
+
nedviga/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
50
|
+
nedviga/risk.py,sha256=DrzE_SPxW_2uvfG6ECWbyAk6W7-SYnGPk664ZVItT4Q,4267
|
|
51
|
+
nedviga/roles.py,sha256=gpHq5R9RzAKqV0XR9rVoVJuAAX4ztfGL3MXbdsHHTVQ,3702
|
|
52
|
+
nedviga/routing_cache.py,sha256=-MsnZ-6R0Q5N6hens0VdRbO5AySwYJrS6H8yCI52eR4,2489
|
|
53
|
+
nedviga/rush.py,sha256=6CWpo0VpFpe5O3cxsTUxGdOObRWVolEZ051ZX2hIcaw,6245
|
|
54
|
+
nedviga/scorefit.py,sha256=wYaaY2ewiIozCInrgLEQjjqbiQReQs8JptK3ECm9rwA,4340
|
|
55
|
+
nedviga/sources.py,sha256=I4UOAqcMnO8YZiNTWzEZHaI6w9xJ5yMRMRQBiAjJq50,1345
|
|
56
|
+
nedviga/sources_avito.py,sha256=RaWo845E6EKqwkROFcsItRVK8K-Z7-QLmtJQcALUiqo,5773
|
|
57
|
+
nedviga/sources_domclick.py,sha256=GeH6AsrF3Z3IzJmAvENDDhD4L1hQk6fG9E8eTZYeLtE,5631
|
|
58
|
+
nedviga/stale.py,sha256=IhQVZmGVT_1AjpCBGz_jqDY8yIObIyWPPpRbdEDpt9o,2935
|
|
59
|
+
nedviga/textsearch.py,sha256=S7VxWhNtWwkpvNe_IG0Ax19Q5QHu11rPwCe1b7_2mIs,5497
|
|
60
|
+
nedviga/tour.py,sha256=m-nHVbBY_HJvII2hPTj8n9Tic46TmdX0pukLGn4Xxi0,5979
|
|
61
|
+
nedviga/watch.py,sha256=n25_sUlN0_RiOfrg40bgXhCjTrGhzBDNBno6iaVTgf0,8970
|
|
62
|
+
nedviga/wizard.py,sha256=5PaolSOQre1SevyW6h4dKEcGQiZGwodkIV3TTSE4Aoc,4377
|
|
63
|
+
nedviga/data/legal_flags.json,sha256=1enDxBota7ilVsvlZBRLAyNJgmjzHckZ3vsZ0l4BwWU,12812
|
|
64
|
+
nedviga/data/release_pubkey.asc,sha256=RD6vXL0Q8LAeeIeLwjDF-pYqvRbLfkKZo7NVd9n5K1k,1217
|
|
65
|
+
nedviga_cli-0.1.0.dist-info/licenses/LICENSE,sha256=DZak_2itbUtvHzD3E7GNUYSRK6jdOJ-GqncQ2weavLA,34523
|
|
66
|
+
nedviga_cli-0.1.0.dist-info/licenses/NOTICE,sha256=tsPyhh3tjjN8WqjLmHXMZXI995f8pShogFxTEJAKCc8,84
|
|
67
|
+
nedviga_cli-0.1.0.dist-info/METADATA,sha256=FBzidmrOJzNq53AlsKLizsLdWMB2mlb7X55nNtjAY1g,7031
|
|
68
|
+
nedviga_cli-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
69
|
+
nedviga_cli-0.1.0.dist-info/entry_points.txt,sha256=0QlKa7YpuULWp69G2FOS31vK0a8zZ_UN2-exYarYNFg,49
|
|
70
|
+
nedviga_cli-0.1.0.dist-info/top_level.txt,sha256=q_geT-MbWo9NAzfkOWH5WrRX-qVClPc_TIp-AQK4YXA,8
|
|
71
|
+
nedviga_cli-0.1.0.dist-info/RECORD,,
|