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/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
+ """