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/doctor.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""System diagnostics command for nedviga-cli."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .browser_native import native_available, native_status
|
|
10
|
+
from .models import ApiOutcomeEnvelope
|
|
11
|
+
from .outcomes import make_envelope
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
CACHE_DIR = Path.home() / ".cache" / "nedviga-cli"
|
|
15
|
+
REQUIRED_DEPS = ("pydantic", "yaml", "camoufox")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _check_python() -> tuple[bool, str]:
|
|
19
|
+
version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
|
|
20
|
+
return sys.version_info >= (3, 11), version
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _check_dependencies() -> tuple[bool, list[str]]:
|
|
24
|
+
missing = [dep for dep in REQUIRED_DEPS if importlib.util.find_spec(dep) is None]
|
|
25
|
+
return not missing, missing
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _check_cache() -> tuple[bool, str]:
|
|
29
|
+
try:
|
|
30
|
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
31
|
+
probe = CACHE_DIR / ".write_probe"
|
|
32
|
+
probe.write_text("ok", encoding="utf-8")
|
|
33
|
+
probe.unlink()
|
|
34
|
+
return True, str(CACHE_DIR)
|
|
35
|
+
except Exception as exc: # pragma: no cover - hard to trigger in tests
|
|
36
|
+
return False, str(exc)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def verify_signature(file_path: str, pubkey_path: str | None = None) -> dict:
|
|
40
|
+
""" gpg-подпись релизного артефакта против бандл-публичного ключа.
|
|
41
|
+
Честные статусы: ok / fail / unsigned / gpg_unavailable. Молчание запрещено."""
|
|
42
|
+
import subprocess, tempfile, os
|
|
43
|
+
from pathlib import Path as _P
|
|
44
|
+
f = _P(file_path)
|
|
45
|
+
asc = f.with_name(f.name + ".asc")
|
|
46
|
+
if not f.exists():
|
|
47
|
+
return {"status": "fail", "reason": "file_missing"}
|
|
48
|
+
if not asc.exists():
|
|
49
|
+
return {"status": "unsigned", "reason": "no .asc next to file"}
|
|
50
|
+
keyfile = _P(pubkey_path) if pubkey_path else _P(__file__).parent / "data" / "release_pubkey.asc"
|
|
51
|
+
if not keyfile.exists():
|
|
52
|
+
return {"status": "fail", "reason": "bundled pubkey missing"}
|
|
53
|
+
with tempfile.TemporaryDirectory() as td_:
|
|
54
|
+
env = {**os.environ, "GNUPGHOME": td_}
|
|
55
|
+
try:
|
|
56
|
+
subprocess.run(["gpg", "--batch", "--quiet", "--import", str(keyfile)], env=env,
|
|
57
|
+
capture_output=True, timeout=30)
|
|
58
|
+
r = subprocess.run(["gpg", "--batch", "--quiet", "--status-fd", "1",
|
|
59
|
+
"--verify", str(asc), str(f)], env=env,
|
|
60
|
+
capture_output=True, text=True, timeout=60)
|
|
61
|
+
except FileNotFoundError:
|
|
62
|
+
return {"status": "gpg_unavailable", "reason": "gpg binary not found"}
|
|
63
|
+
except Exception as exc:
|
|
64
|
+
return {"status": "fail", "reason": f"verify_error: {type(exc).__name__}"}
|
|
65
|
+
out = r.stdout or ""
|
|
66
|
+
if r.returncode == 0 and ("VALIDSIG" in out or "GOODSIG" in out):
|
|
67
|
+
fpr = [x.split("\t")[-1] for x in out.splitlines() if "VALIDSIG" in x]
|
|
68
|
+
return {"status": "ok", "signer_fpr": (fpr or [""])[0][-16:]}
|
|
69
|
+
if "BADSIG" in out or "ERRSIG" in out:
|
|
70
|
+
return {"status": "fail", "reason": "bad_signature (key/file mismatch)"}
|
|
71
|
+
return {"status": "fail", "reason": "unparsable gpg output"}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def run_doctor() -> tuple[ApiOutcomeEnvelope, int]:
|
|
75
|
+
"""Run diagnostics and return (envelope, exit_code)."""
|
|
76
|
+
python_ok, python_version = _check_python()
|
|
77
|
+
deps_ok, missing_deps = _check_dependencies()
|
|
78
|
+
cache_ok, cache_detail = _check_cache()
|
|
79
|
+
browser_available = native_available()
|
|
80
|
+
browser_status = native_status()
|
|
81
|
+
|
|
82
|
+
critical = not python_ok or not deps_ok or not cache_ok or not browser_available
|
|
83
|
+
|
|
84
|
+
data = {
|
|
85
|
+
"python_ok": python_ok,
|
|
86
|
+
"python_version": python_version,
|
|
87
|
+
"dependencies_ok": deps_ok,
|
|
88
|
+
"missing_dependencies": missing_deps,
|
|
89
|
+
"cache_ok": cache_ok,
|
|
90
|
+
"cache_dir": cache_detail,
|
|
91
|
+
"browser": {
|
|
92
|
+
"status": "available" if browser_available else "missing",
|
|
93
|
+
"available": browser_available,
|
|
94
|
+
"running": browser_status["running"],
|
|
95
|
+
"mode": browser_status["mode"],
|
|
96
|
+
"note": (
|
|
97
|
+
"Camoufox stealth browser is required for live search/get-card. "
|
|
98
|
+
"Install with: pip install 'camoufox[geoip]' && python3 -m camoufox fetch"
|
|
99
|
+
),
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if critical:
|
|
104
|
+
reason_parts = []
|
|
105
|
+
if not python_ok:
|
|
106
|
+
reason_parts.append("python_version_too_old")
|
|
107
|
+
if not deps_ok:
|
|
108
|
+
reason_parts.append("missing_dependencies")
|
|
109
|
+
if not cache_ok:
|
|
110
|
+
reason_parts.append("cache_not_writable")
|
|
111
|
+
if not browser_available:
|
|
112
|
+
reason_parts.append("browser_not_available")
|
|
113
|
+
envelope = make_envelope(
|
|
114
|
+
status="error",
|
|
115
|
+
reason=",".join(reason_parts),
|
|
116
|
+
source="doctor",
|
|
117
|
+
data=data,
|
|
118
|
+
metadata={"engine": "native", "cached": False, "elapsed_seconds": 0.0},
|
|
119
|
+
)
|
|
120
|
+
return envelope, 1
|
|
121
|
+
|
|
122
|
+
envelope = make_envelope(
|
|
123
|
+
status="ok",
|
|
124
|
+
source="doctor",
|
|
125
|
+
data=data,
|
|
126
|
+
metadata={"engine": "native", "cached": False, "elapsed_seconds": 0.0},
|
|
127
|
+
)
|
|
128
|
+
return envelope, 0
|
nedviga/dossier.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""v0.5.8 (the task brief) building dossier."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sqlite3
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from .models import ListingCard
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def db_path() -> Path:
|
|
14
|
+
p = os.environ.get("NEDVIGA_MKD_DB")
|
|
15
|
+
if p:
|
|
16
|
+
return Path(p)
|
|
17
|
+
base = Path(os.environ.get("NEDVIGA_STATE_DIR", str(Path.home() / ".local" / "share" / "nedviga")))
|
|
18
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
return base / "mos_mkd.sqlite"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _connect(db=None):
|
|
23
|
+
p = db or db_path()
|
|
24
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
conn = sqlite3.connect(str(p))
|
|
26
|
+
conn.row_factory = sqlite3.Row
|
|
27
|
+
conn.execute("""CREATE TABLE IF NOT EXISTS mkd (
|
|
28
|
+
address TEXT, cadastral TEXT, year INTEGER, floors INTEGER,
|
|
29
|
+
series TEXT, overlaps TEXT, elevators TEXT, garbage TEXT, capremont TEXT,
|
|
30
|
+
raw TEXT)""")
|
|
31
|
+
conn.execute("""CREATE TABLE IF NOT EXISTS sync_state (
|
|
32
|
+
id INTEGER PRIMARY KEY, cursor TEXT, dataset TEXT, last_sync TEXT)""")
|
|
33
|
+
conn.commit()
|
|
34
|
+
return conn
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _norm_addr(s: Optional[str]) -> str:
|
|
38
|
+
if not s:
|
|
39
|
+
return ""
|
|
40
|
+
return " ".join(str(s).lower().split())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_MO_NOISE = ("российская", "федерация", "федерации", "город", "москва", "округ",
|
|
44
|
+
"муниципальный", "муниципальная", "муниципальные", "внутригородская",
|
|
45
|
+
"территория", "улица", "проспект", "переулок", "шоссе", "бульвар",
|
|
46
|
+
"проезд", "тупик", "дом", "корпус", "строение", "владение")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _addr_key(addr: str):
|
|
50
|
+
"""Ключ сопоставления: номер дома + корпус + слова-улица(район) из хвоста адреса.
|
|
51
|
+
v0.5.10 контроль (live): реестр хранит полный канон "... МО Зюзино, Азовская улица,
|
|
52
|
+
дом 4", карточка — короткий "Азовская, 4" — сравнение полных строк никогда не совпадало.
|
|
53
|
+
Ключ требует ПОЛНОЕ совпадение номера дома; без номера — ключ None (никаких
|
|
54
|
+
fuzzy-вердиктов)."""
|
|
55
|
+
import re
|
|
56
|
+
a = (addr or "").lower().strip()
|
|
57
|
+
if not a:
|
|
58
|
+
return None
|
|
59
|
+
m = re.search(r"(?:дом|вл|владение)?\s*(\d{1,4})(?![\d])", a)
|
|
60
|
+
if not m:
|
|
61
|
+
return None
|
|
62
|
+
num = m.group(1)
|
|
63
|
+
km = re.search(r"корпус[.,\s]*(\d+)", a) or re.search(r"\d\s*к(\d+)", a)
|
|
64
|
+
kor = km.group(1) if km else None
|
|
65
|
+
words = {w for w in re.findall(r"[а-яё]{5,}", a) if w not in _MO_NOISE}
|
|
66
|
+
return (num, kor, frozenset(words))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _addr_score(qk, rk):
|
|
70
|
+
if not qk or not rk:
|
|
71
|
+
return 0.0
|
|
72
|
+
qn, qkorr, qw = qk; rn, rkorr, rw = rk
|
|
73
|
+
if qn != rn or not (qw & rw):
|
|
74
|
+
return 0.0
|
|
75
|
+
if qkorr and rkorr and qkorr != rkorr:
|
|
76
|
+
return 0.0
|
|
77
|
+
if (qkorr is None) != (rkorr is None):
|
|
78
|
+
return 0.85 # корпус известен только с одной стороны — кандидат, не факт
|
|
79
|
+
return 1.0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def sync(dataset: str = "confirmed", limit: int = 20000, db=None, rows_func=None) -> dict:
|
|
83
|
+
"""Заполняет реестр живыми датасетами data.mos.ru: 3293 (спецсчета КР) и 520
|
|
84
|
+
(паспорта новостроек гор.стройки). v0.5.10 контроль: прежний sync молчал на
|
|
85
|
+
dataset='auto' (HTTP 400 → вставлено 0, статус ok) — теперь id валидируются,
|
|
86
|
+
ошибки не глотаются, форма поля читается из Cells (боевой ответ apidata v1)."""
|
|
87
|
+
import json
|
|
88
|
+
ids = {"3293": "spec", "520": "mkd520"}
|
|
89
|
+
if dataset not in ids:
|
|
90
|
+
return {"ok": False, "reason": f"unknown_dataset:{dataset} (разрешено: {', '.join(ids)})"}
|
|
91
|
+
conn = _connect(db)
|
|
92
|
+
if ids[dataset] == "mkd520":
|
|
93
|
+
conn.execute("CREATE TABLE IF NOT EXISTS mkd520 (address TEXT PRIMARY KEY, flats INTEGER, raw TEXT)")
|
|
94
|
+
elif not any(r["name"] == "mkd" for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")):
|
|
95
|
+
conn.execute("CREATE TABLE mkd (address TEXT, cadastral TEXT, year INTEGER, floors INTEGER,"
|
|
96
|
+
" series TEXT, overlaps TEXT, elevators TEXT, garbage TEXT, capremont TEXT, raw TEXT)")
|
|
97
|
+
conn.execute("CREATE TABLE IF NOT EXISTS sync_state (id INTEGER PRIMARY KEY AUTOINCREMENT, cursor TEXT, dataset TEXT, last_sync TEXT)")
|
|
98
|
+
if rows_func is None:
|
|
99
|
+
from .mosdata import rows as rows_func_
|
|
100
|
+
def rows_func_(dsid, version=None, top=1000, skip=0, flt=None):
|
|
101
|
+
from .mosdata import rows as R
|
|
102
|
+
return R(dsid, filter_expr=flt, top=top, skip=skip)
|
|
103
|
+
rows_func = rows_func_
|
|
104
|
+
skip = 0; inserted = 0; errors = []
|
|
105
|
+
while inserted < limit:
|
|
106
|
+
top = min(1000, limit - inserted)
|
|
107
|
+
try:
|
|
108
|
+
data = rows_func(dataset, None, top, skip)
|
|
109
|
+
except Exception as exc:
|
|
110
|
+
errors.append(str(exc)[:160]); break
|
|
111
|
+
items = data if isinstance(data, list) else (data.get("value") or data.get("Items") or [])
|
|
112
|
+
if not items:
|
|
113
|
+
break
|
|
114
|
+
for item in items:
|
|
115
|
+
cells = item.get("Cells") if isinstance(item, dict) and isinstance(item.get("Cells"), dict) else item
|
|
116
|
+
if dataset == "520":
|
|
117
|
+
addr = cells.get("Address")
|
|
118
|
+
if addr:
|
|
119
|
+
conn.execute("INSERT OR REPLACE INTO mkd520 (address,flats,raw) VALUES (?,?,?)",
|
|
120
|
+
(str(addr), cells.get("FlatsAmount"), json.dumps(item, ensure_ascii=False)))
|
|
121
|
+
inserted += 1
|
|
122
|
+
else: # 3293
|
|
123
|
+
c_addr = cells.get("AddressMKD")
|
|
124
|
+
if c_addr:
|
|
125
|
+
cap = json.dumps({"spec_account": {"owner": cells.get("SpecAccountOwnerName"),
|
|
126
|
+
"bank": cells.get("CreditOrganization"),
|
|
127
|
+
"monthly": cells.get("MonthlyContribution"),
|
|
128
|
+
"opened": cells.get("OpenDate"), "dataset": 3293}},
|
|
129
|
+
ensure_ascii=False)
|
|
130
|
+
conn.execute("INSERT INTO mkd (address,cadastral,year,floors,series,overlaps,elevators,garbage,capremont,raw)"
|
|
131
|
+
" VALUES (?,?,?,?,?,?,?,?,?,?)",
|
|
132
|
+
(str(c_addr), None, None, None, None, None, None, None, cap, json.dumps(item, ensure_ascii=False)))
|
|
133
|
+
inserted += 1
|
|
134
|
+
if inserted >= limit:
|
|
135
|
+
break
|
|
136
|
+
skip += len(items)
|
|
137
|
+
conn.execute("INSERT INTO sync_state (cursor,dataset,last_sync) VALUES (?,?,?)",
|
|
138
|
+
(str(skip), dataset, __import__("datetime").datetime.now(__import__("datetime").timezone.utc).isoformat()))
|
|
139
|
+
conn.commit()
|
|
140
|
+
if len(items) < top:
|
|
141
|
+
break
|
|
142
|
+
conn.commit(); conn.close()
|
|
143
|
+
if inserted == 0 and not errors:
|
|
144
|
+
return {"ok": False, "reason": "empty_source", "dataset": dataset}
|
|
145
|
+
out = {"ok": not errors, "inserted": inserted, "cursor": str(skip), "dataset": dataset}
|
|
146
|
+
if errors:
|
|
147
|
+
out["error"] = errors[0]
|
|
148
|
+
return out
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def match(card_or_address, db=None) -> dict:
|
|
152
|
+
conn = _connect(db)
|
|
153
|
+
# determine address
|
|
154
|
+
if isinstance(card_or_address, str):
|
|
155
|
+
addr = card_or_address
|
|
156
|
+
cadastral = None
|
|
157
|
+
else:
|
|
158
|
+
c = card_or_address
|
|
159
|
+
if isinstance(c, dict):
|
|
160
|
+
addr = (c.get("building") or {}).get("address_raw")
|
|
161
|
+
cadastral = None
|
|
162
|
+
else:
|
|
163
|
+
addr = getattr(c.building, "address_raw", None) if getattr(c, "building", None) else None
|
|
164
|
+
cadastral = None
|
|
165
|
+
norm = _norm_addr(addr)
|
|
166
|
+
tables = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
|
167
|
+
rows = conn.execute("SELECT * FROM mkd").fetchall() if "mkd" in tables else []
|
|
168
|
+
candidates = []
|
|
169
|
+
qkey = _addr_key(addr)
|
|
170
|
+
for r in rows:
|
|
171
|
+
rd = dict(r)
|
|
172
|
+
score = _addr_score(qkey, _addr_key(rd.get("address")))
|
|
173
|
+
if cadastral and rd.get("cadastral") == cadastral:
|
|
174
|
+
score = max(score, 0.9)
|
|
175
|
+
if score > 0:
|
|
176
|
+
cand = {"address": rd.get("address"), "cadastral": rd.get("cadastral"),
|
|
177
|
+
"year": rd.get("year"), "floors": rd.get("floors"), "match_score": score}
|
|
178
|
+
if rd.get("capremont"):
|
|
179
|
+
cand["capremont"] = __import__("json").loads(rd["capremont"])
|
|
180
|
+
candidates.append(cand)
|
|
181
|
+
# 520 novostroyka block with house-gate
|
|
182
|
+
# v0.5.10 контроль: conn.close() до этого блока закрывал курсор → mkd520
|
|
183
|
+
# не читался и match вечно возвращал no_match. Закрытие — строго в конце.
|
|
184
|
+
novostroyka = None
|
|
185
|
+
if "mkd520" in tables:
|
|
186
|
+
rows520 = conn.execute("SELECT * FROM mkd520").fetchall()
|
|
187
|
+
for r in rows520:
|
|
188
|
+
rd = dict(r)
|
|
189
|
+
# house gate: номер+корпус+улица (key), 0.85 допустим только односторонний корпус
|
|
190
|
+
sc = _addr_score(qkey, _addr_key(rd.get("address")))
|
|
191
|
+
if sc >= 0.85:
|
|
192
|
+
novostroyka = {"address": rd.get("address"), "flats": rd.get("flats"),
|
|
193
|
+
"match_score": sc,
|
|
194
|
+
"source": "data.mos.ru 520 (паспорта новостроек гор.стройки)"}
|
|
195
|
+
if sc < 1.0:
|
|
196
|
+
novostroyka["note"] = "корпус известен только с одной стороны — сверьте перед выводом клиенту"
|
|
197
|
+
break
|
|
198
|
+
conn.close()
|
|
199
|
+
if not candidates and novostroyka is None:
|
|
200
|
+
return {"status": "no_match"}
|
|
201
|
+
result = {"status": "ok", "candidates": candidates}
|
|
202
|
+
if novostroyka is not None:
|
|
203
|
+
result["novostroyka"] = novostroyka
|
|
204
|
+
return result
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _addr_tokens(address: str) -> list[str]:
|
|
208
|
+
"""Корневые токены улицы/ЖК из адреса: длинные кириллические слова, степпинг суффиксов."""
|
|
209
|
+
import re
|
|
210
|
+
words = re.findall(r"[а-яёА-ЯЁ]{6,}", address or "")
|
|
211
|
+
# v0.5.9 контроль:Generic-токены (город/федерация/округ...) матчат случайные дома —
|
|
212
|
+
# это ложные dossier-факты,Stop-words обязательны.
|
|
213
|
+
stop = {"российская", "федерация", "город", "москва", "улица", "дом", "корпус",
|
|
214
|
+
"муниципальный", "муниципальная", "муниципальные", "округ", "внутригородская",
|
|
215
|
+
"территория", "проспект", "переулок", "шоссе", "бульвар", "проезд", " тупик"}
|
|
216
|
+
toks = set()
|
|
217
|
+
for w in words:
|
|
218
|
+
wl = w.lower()
|
|
219
|
+
if wl in stop or any(s0 in wl for s0 in ("российск", "федераци", "муницип", "внутригород")):
|
|
220
|
+
continue
|
|
221
|
+
toks.add(wl)
|
|
222
|
+
for suf in ("ская","ская","ского","ской","ые","ие","ый","ая","ов","ев","ск","ин"):
|
|
223
|
+
if wl.endswith(suf) and len(wl) - len(suf) >= 5:
|
|
224
|
+
toks.add(wl[: -len(suf)]); break
|
|
225
|
+
return sorted(toks, key=len, reverse=True)[:4]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def mos_sources(card_address: str, db=None) -> dict:
|
|
229
|
+
"""Dossier live-sources через RU-egress: 3293 спецсчета капремонта (data.mos.ru).
|
|
230
|
+
Только подтверждённые факты канала; absent честный. Series/год/перекрытия —
|
|
231
|
+
легального источника нет (re-scope 23.10). 62963 отложен: адрес вложен,
|
|
232
|
+
серверный фильтр невозможен, полный дамп запрещён."""
|
|
233
|
+
from . import mosdata
|
|
234
|
+
import json, hashlib, time, os, re as _re2
|
|
235
|
+
|
|
236
|
+
cache_dir = os.path.join(os.environ.get("NEDVIGA_STATE_DIR",
|
|
237
|
+
os.path.expanduser("~/.local/share/nedviga")), "mos_cache")
|
|
238
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
239
|
+
key = hashlib.sha256(card_address.encode()).hexdigest()[:24]
|
|
240
|
+
cf = os.path.join(cache_dir, key + ".json")
|
|
241
|
+
if os.path.exists(cf) and time.time() - os.path.getmtime(cf) < 30 * 86400:
|
|
242
|
+
return json.load(open(cf))
|
|
243
|
+
|
|
244
|
+
out = {"spec_account": None, "spec_account_absent": False, "source": "data.mos.ru"}
|
|
245
|
+
tokens = _addr_tokens(card_address)[:3]
|
|
246
|
+
|
|
247
|
+
def _num_gate(hits):
|
|
248
|
+
mnum = _re2.search(r"(?:дом|владение|д\.?)\s*(\d+)", card_address or "", _re2.I)
|
|
249
|
+
if not mnum:
|
|
250
|
+
return hits, None
|
|
251
|
+
n = mnum.group(1)
|
|
252
|
+
def _ok(addr):
|
|
253
|
+
a = str(addr)
|
|
254
|
+
return bool(_re2.search(rf"(?:дом|вл|владение)\s*{n}(?:\b|\s|,|$)", a, _re2.I)) or \
|
|
255
|
+
bool(_re2.search(rf",\s*{n}\s*(?:к\d|,|$)", a, _re2.I))
|
|
256
|
+
exact = [r for r in hits if _ok(r["Cells"]["AddressMKD"])]
|
|
257
|
+
return exact, (None if exact else f"дом {n}: спецсчета по улице найдены, номер не сопоставлен — проверьте вручную")
|
|
258
|
+
|
|
259
|
+
tried = 0
|
|
260
|
+
matched = False
|
|
261
|
+
for base_tok in tokens:
|
|
262
|
+
variants = []
|
|
263
|
+
for v in (base_tok.capitalize(), base_tok.title(), base_tok):
|
|
264
|
+
if v not in variants:
|
|
265
|
+
variants.append(v)
|
|
266
|
+
for tok in variants:
|
|
267
|
+
if tried >= 8:
|
|
268
|
+
break
|
|
269
|
+
tried += 1
|
|
270
|
+
esc = tok.replace("'", "''")
|
|
271
|
+
try:
|
|
272
|
+
raw = mosdata.rows(3293, filter_expr=f"substringof('{esc}',AddressMKD)", top=20)
|
|
273
|
+
rows = raw if isinstance(raw, list) else (raw.get("Items") or raw.get("value") or [])
|
|
274
|
+
except Exception as exc:
|
|
275
|
+
out["error_3293"] = str(exc)[:120]
|
|
276
|
+
continue
|
|
277
|
+
hits = [r for r in rows if isinstance(r, dict) and r.get("Cells", {}).get("AddressMKD")]
|
|
278
|
+
if not hits:
|
|
279
|
+
continue
|
|
280
|
+
exact, unmatched = _num_gate(hits)
|
|
281
|
+
if unmatched:
|
|
282
|
+
out["candidates"] = [r["Cells"]["AddressMKD"] for r in hits][:5]
|
|
283
|
+
out["number_unmatched"] = unmatched
|
|
284
|
+
matched = True # нашли улицу — absent больше не ложный
|
|
285
|
+
break
|
|
286
|
+
c = exact[0]["Cells"]
|
|
287
|
+
out["spec_account"] = {"address": c.get("AddressMKD"), "owner": c.get("SpecAccountOwnerName"),
|
|
288
|
+
"bank": c.get("CreditOrganization"), "monthly": c.get("MonthlyContribution"),
|
|
289
|
+
"opened": c.get("OpenDate"), "dataset": 3293,
|
|
290
|
+
"source": "data.mos.ru, датасет 3293 (спецсчета КР), apidata v1, через RU-egress"}
|
|
291
|
+
matched = True
|
|
292
|
+
break
|
|
293
|
+
if matched:
|
|
294
|
+
break
|
|
295
|
+
|
|
296
|
+
if not matched and "number_unmatched" not in out:
|
|
297
|
+
out["spec_account_absent"] = True
|
|
298
|
+
out["absent_note"] = ("в реестре спецсчетов капремонта (3293) не найдено; "
|
|
299
|
+
"форма накопления не подтверждена этим датасетом")
|
|
300
|
+
out["_query_tokens"] = tokens
|
|
301
|
+
out["_attempts"] = tried
|
|
302
|
+
json.dump(out, open(cf, "w"), ensure_ascii=False)
|
|
303
|
+
return out
|
nedviga/eco.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""v0.5.6 (the task brief) eco proxy."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
from .poi import _haversine, _cache_get, _cache_key, _query_overpass, poi_for_card
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _classify_eco(elements, lat, lon):
|
|
11
|
+
categories = {
|
|
12
|
+
"highway": [],
|
|
13
|
+
"rail": [],
|
|
14
|
+
"aerodrome": [],
|
|
15
|
+
"industrial": [],
|
|
16
|
+
}
|
|
17
|
+
for el in elements or []:
|
|
18
|
+
if el.get("type") != "node":
|
|
19
|
+
continue
|
|
20
|
+
elat = el.get("lat"); elon = el.get("lon")
|
|
21
|
+
if elat is None or elon is None:
|
|
22
|
+
continue
|
|
23
|
+
tags = el.get("tags") or {}
|
|
24
|
+
dist = _haversine(lat, lon, elat, elon)
|
|
25
|
+
hw = tags.get("highway")
|
|
26
|
+
if hw in {"motorway", "trunk"}:
|
|
27
|
+
categories["highway"].append(dist)
|
|
28
|
+
if tags.get("railway") in {"station"} or tags.get("amenity") == "railway_station":
|
|
29
|
+
categories["rail"].append(dist)
|
|
30
|
+
if tags.get("aeroway") == "aerodrome" or tags.get("amenity") == "aerodrome":
|
|
31
|
+
categories["aerodrome"].append(dist)
|
|
32
|
+
if tags.get("landuse") == "industrial":
|
|
33
|
+
categories["industrial"].append(dist)
|
|
34
|
+
out = {}
|
|
35
|
+
for cat, dists in categories.items():
|
|
36
|
+
if not dists:
|
|
37
|
+
out[cat] = {"count": 0, "nearest_m": None}
|
|
38
|
+
else:
|
|
39
|
+
out[cat] = {"count": len(dists), "nearest_m": int(round(min(dists)))}
|
|
40
|
+
return out
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def eco_for_card(card, radius_min=15, offline=None):
|
|
44
|
+
from .models import ListingCard
|
|
45
|
+
c = card if isinstance(card, ListingCard) else ListingCard(**card) if isinstance(card, dict) else None
|
|
46
|
+
if c is None:
|
|
47
|
+
raise TypeError("unsupported card")
|
|
48
|
+
geo = c.geo
|
|
49
|
+
lat = geo.lat if geo else None
|
|
50
|
+
lon = geo.lon if geo else None
|
|
51
|
+
if lat is None or lon is None:
|
|
52
|
+
return {"status": "no_coordinates", "categories": {}}
|
|
53
|
+
radius = max(100, radius_min * 80)
|
|
54
|
+
key = _cache_key(lat, lon, radius)
|
|
55
|
+
cached = _cache_get(key)
|
|
56
|
+
if cached is not None:
|
|
57
|
+
payload = cached
|
|
58
|
+
elif offline:
|
|
59
|
+
import json
|
|
60
|
+
payload = json.loads(Path(offline).read_text(encoding="utf-8"))
|
|
61
|
+
else:
|
|
62
|
+
payload = _query_overpass(lat, lon, radius)
|
|
63
|
+
from .poi import _cache_set
|
|
64
|
+
_cache_set(key, payload)
|
|
65
|
+
cats = _classify_eco(payload.get("elements", []) if isinstance(payload, dict) else [], lat, lon)
|
|
66
|
+
return {"status": "ok", "categories": cats}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def render_text(result: dict) -> str:
|
|
70
|
+
if result.get("status") == "no_coordinates":
|
|
71
|
+
return "no_coordinates"
|
|
72
|
+
cats = result.get("categories", {})
|
|
73
|
+
lines = []
|
|
74
|
+
names = {"highway": "шоссе", "rail": "ж/д", "aerodrome": "аэропорт", "industrial": "промзона"}
|
|
75
|
+
for cat, info in cats.items():
|
|
76
|
+
if info.get("count", 0) == 0:
|
|
77
|
+
lines.append(f"{names[cat]}: нет данных")
|
|
78
|
+
else:
|
|
79
|
+
lines.append(f"{names[cat]}: {info['nearest_m']} м (по прямой)")
|
|
80
|
+
lines.append("это проксимити-прокси по OSM, НЕ замер уровня шума; официальный источник — data.mos.ru (ждёт токен владельца)")
|
|
81
|
+
return "\n".join(lines)
|
nedviga/errors.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Runtime exceptions used by the live fetch pipeline.
|
|
2
|
+
|
|
3
|
+
These are intentionally separate from :class:`ParseError` so that the CLI can
|
|
4
|
+
distinguish fetch-time problems (WAF, crash, missing runtime) from offline
|
|
5
|
+
parser failures.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FetchError(RuntimeError):
|
|
12
|
+
"""Raised when a live network fetch fails for any reason.
|
|
13
|
+
|
|
14
|
+
The message is actionable and never fabricates data.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ChallengeError(FetchError):
|
|
19
|
+
"""Raised when the target site returns a WAF/captcha/rate-limit page.
|
|
20
|
+
|
|
21
|
+
Consumers should treat this as ``DEGRADED`` (exit code 2).
|
|
22
|
+
"""
|