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
@@ -0,0 +1,134 @@
1
+ """Directory of Moscow metro vestibules with real WGS-84 coordinates.
2
+
3
+ The coordinates are taken from public cartographic sources
4
+ (OpenStreetMap / Wikipedia) and represent the actual street-level exits.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from pathlib import Path
11
+
12
+
13
+ def _normalize(name: str) -> str:
14
+ """Normalize a station name for directory lookup."""
15
+ return name.strip().casefold().replace("ё", "е")
16
+
17
+
18
+ #: Mapping station name -> list of (vestibule_name, lat, lon).
19
+ VESTIBULES: dict[str, list[tuple[str, float, float]]] = {
20
+ # 1. Комсомольская (Sokolnicheskaya + Koltsevaya lines, Komsomolskaya square)
21
+ _normalize("Комсомольская"): [
22
+ ("Вестибюль на Комсомольской площади (Сокольническая линия)", 55.7753, 37.6555),
23
+ ("Вестибюль у Ленинградского вокзала (Кольцевая линия)", 55.7747, 37.6548),
24
+ ],
25
+ # 2. Чертановская
26
+ _normalize("Чертановская"): [
27
+ ("Вестибюль 1 (южный)", 55.6405, 37.6067),
28
+ ("Вестибюль 2 (северный)", 55.6412, 37.6073),
29
+ ],
30
+ # 3. Тверская
31
+ _normalize("Тверская"): [
32
+ ("Вестибюль на Пушкинской площади", 55.7648, 37.6042),
33
+ ],
34
+ # 4. Арбатская (Arbatsko-Pokrovskaya + Filyovskaya)
35
+ _normalize("Арбатская"): [
36
+ ("Вестибюль на улице Воздвиженка (Арбатско-Покровская линия)", 55.7523, 37.6039),
37
+ ("Вестибюль у Старого Арбата (Филёвская линия)", 55.7521, 37.6019),
38
+ ],
39
+ # 5. Курская (Arbatsko-Pokrovskaya + Koltsevaya)
40
+ _normalize("Курская"): [
41
+ ("Вестибюль у Курского вокзала (Арбатско-Покровская линия)", 55.7572, 37.6591),
42
+ ("Вестибюль на Земляном валу (Кольцевая линия)", 55.7566, 37.6597),
43
+ ],
44
+ # 6. Киевская (Filyovskaya + Arbatsko-Pokrovskaya + Koltsevaya)
45
+ _normalize("Киевская"): [
46
+ ("Вестибюль у Киевского вокзала (Филёвская линия)", 55.7440, 37.5666),
47
+ ("Вестибюль на площади Киевского вокзала (Арбатско-Покровская линия)", 55.7437, 37.5645),
48
+ ("Вестибюль на Киевской площади (Кольцевая линия)", 55.7436, 37.5663),
49
+ ],
50
+ # 7. Таганская (Tagansko-Krasnopresnenskaya + Koltsevaya)
51
+ _normalize("Таганская"): [
52
+ ("Вестибюль на Таганской площади (Таганско-Краснопресненская линия)", 55.7393, 37.6538),
53
+ ("Вестибюль у Таганской радиальной (Кольцевая линия)", 55.7390, 37.6534),
54
+ ],
55
+ # 8. Баррикадная
56
+ _normalize("Баррикадная"): [
57
+ ("Вестибюль на Баррикадной улице", 55.7612, 37.5812),
58
+ ],
59
+ # 9. ВДНХ
60
+ _normalize("ВДНХ"): [
61
+ ("Вестибюль у главного входа ВДНХ", 55.8203, 37.6411),
62
+ ],
63
+ # 10. Сокольники
64
+ _normalize("Сокольники"): [
65
+ ("Вестибюль у Сокольнического круга", 55.7891, 37.6803),
66
+ ],
67
+ # 11. Автозаводская
68
+ _normalize("Автозаводская"): [
69
+ ("Вестибюль на Автозаводской улице", 55.7066, 37.6571),
70
+ ],
71
+ # 12. Пролетарская
72
+ _normalize("Пролетарская"): [
73
+ ("Вестибюль на Крутицком валу", 55.7319, 37.6658),
74
+ ],
75
+ # 13. Тульская
76
+ _normalize("Тульская"): [
77
+ ("Вестибюль на Большой Тульской улице", 55.7095, 37.6229),
78
+ ],
79
+ # 14. Нагатинская
80
+ _normalize("Нагатинская"): [
81
+ ("Вестибюль на Варшавском шоссе", 55.6830, 37.6224),
82
+ ],
83
+ # 15. Павелецкая (Zamoskvoretskaya + Koltsevaya)
84
+ _normalize("Павелецкая"): [
85
+ ("Вестибюль у Павелецкого вокзала (Замоскворецкая линия)", 55.7304, 37.6364),
86
+ ("Вестибюль на Павелецкой площади (Кольцевая линия)", 55.7317, 37.6386),
87
+ ],
88
+ }
89
+
90
+
91
+ def get_vestibules(station: str) -> list[tuple[str, float, float]] | None:
92
+ """Return vestibule list for a station, or ``None`` if unknown.
93
+
94
+ v0.2.0: сначала сгенерированный справочник из OSM
95
+ (metro_directory.json, 237 станций метро + МЦД), затем ручной хардкод MVP.
96
+ """
97
+ k = _normalize(station)
98
+ generated = _load_generated()
99
+ if generated is not None and k in generated:
100
+ entry = generated[k]
101
+ return [(v["name"], v["lat"], v["lon"]) for v in entry["vestibules"]]
102
+ return VESTIBULES.get(k)
103
+
104
+
105
+ _GENERATED_CACHE = None
106
+
107
+
108
+ def _load_generated() -> dict | None:
109
+ """Load the generated OSM directory (metro_directory.json), cached."""
110
+ global _GENERATED_CACHE
111
+ if _GENERATED_CACHE is not None:
112
+ return _GENERATED_CACHE
113
+ candidates = [
114
+ Path(__file__).parent / "metro_directory.json",
115
+ Path(__file__).parent.parent / "metro_directory.json",
116
+ ]
117
+ import os
118
+ env = os.environ.get("NEDVIGA_METRO_DIR")
119
+ if env:
120
+ candidates.insert(0, Path(env))
121
+ for c in candidates:
122
+ try:
123
+ if c.exists():
124
+ _GENERATED_CACHE = json.loads(c.read_text(encoding="utf-8"))
125
+ return _GENERATED_CACHE
126
+ except Exception:
127
+ continue
128
+ return None
129
+
130
+
131
+ # Директория станций из сгенерированного справочника (для CLI "list-stations" и тестов)
132
+ def all_stations() -> dict[str, dict]:
133
+ """Return {norm_key: {name, transit_type, vestibules}} из сгенерированного справочника."""
134
+ return _load_generated() or {}
nedviga/models.py ADDED
@@ -0,0 +1,164 @@
1
+ """Pydantic data models for the nedviga-cli JSON contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from typing import Any, Literal, Optional
7
+
8
+ from pydantic import BaseModel, Field, computed_field
9
+
10
+
11
+ class ApiOutcomeEnvelope(BaseModel):
12
+ """Strict single-JSON contract for every CLI command."""
13
+
14
+ ok: bool
15
+ status: Literal["ok", "degraded", "error"]
16
+ reason: Optional[str] = None
17
+ source: Optional[str] = None
18
+ parsed_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
19
+ data: Optional[Any] = None
20
+ metadata: dict = Field(default_factory=dict)
21
+
22
+
23
+ class PriceHistoryPoint(BaseModel):
24
+ date: str
25
+ price: float
26
+
27
+
28
+ class Pricing(BaseModel):
29
+ price_total_rub: Optional[float] = None
30
+ price_per_m2_rub: Optional[float] = None
31
+ price_history: list[PriceHistoryPoint] = Field(default_factory=list)
32
+ # v0.3.1 (реалтор-аудит §1): юридические условия сделки
33
+ sale_type: Optional[str] = None # free / alternative
34
+ mortgage_allowed: Optional[bool] = None
35
+ price_usd: Optional[float] = None
36
+ price_eur: Optional[float] = None
37
+ # v0.3.4 (ТЗ реалтора): торги/аукционы ДГИ/ЦУГИ
38
+ is_auction: Optional[bool] = None
39
+ trading_link: Optional[str] = None
40
+
41
+
42
+ class Parameters(BaseModel):
43
+ rooms: Optional[int] = None
44
+ rooms_type: Optional[str] = None
45
+ area_total_m2: Optional[float] = None
46
+ area_living_m2: Optional[float] = None
47
+ area_kitchen_m2: Optional[float] = None
48
+ floor: Optional[int] = None
49
+ floors_total: Optional[int] = None
50
+ ceiling_height_m: Optional[float] = None
51
+ balcony: Optional[str] = None
52
+ bathroom: Optional[str] = None
53
+ renovation: Optional[str] = None
54
+ window_view: Optional[str] = None
55
+ # v0.3.1 (реалтор-аудит §3): физические параметры раздельно
56
+ loggias_count: Optional[int] = None
57
+ balconies_count: Optional[int] = None
58
+ separate_wcs_count: Optional[int] = None
59
+ combined_wcs_count: Optional[int] = None
60
+ furniture: Optional[str] = None # furnished / partially / no
61
+ layouts: list[str] = Field(default_factory=list) # URL схем БТИ
62
+ # v0.4.0 (ТЗ реалтора): доля в квартире
63
+ share_amount: Optional[float] = None
64
+
65
+
66
+ class Building(BaseModel):
67
+ address_raw: Optional[str] = None
68
+ # v0.4.3 (ТЗ the task brief, realtor): ЖК/комплекс из сниппета; только если явно помечен.
69
+ complex: Optional[str] = None
70
+ house_series: Optional[str] = None
71
+ year_built: Optional[int] = None
72
+ building_type: Optional[str] = None
73
+ floor_material: Optional[str] = None
74
+ renovation_program: Optional[bool] = None
75
+ elevators_passenger: Optional[int] = None
76
+ elevators_cargo: Optional[int] = None
77
+ gas: Optional[bool] = None
78
+ # v0.3.1 (реалтор-аудит §2, §4): здание + флаги
79
+ has_garbage_chute: Optional[bool] = None
80
+ parking_type: Optional[str] = None # ground / underground
81
+ is_apartments: Optional[bool] = None
82
+ is_illegal_construction: Optional[bool] = None
83
+
84
+
85
+ class CianClaimedMetro(BaseModel):
86
+ station: Optional[str] = None
87
+ claimed_time_minutes: Optional[int] = None
88
+ transport_type: Optional[str] = None
89
+
90
+
91
+ class HonestWalkRoute(BaseModel):
92
+ target_station: Optional[str] = None
93
+ target_vestibule: Optional[str] = None
94
+ walk_distance_meters: Optional[int] = None
95
+ walk_duration_minutes: Optional[float] = None
96
+ routing_provider: Optional[str] = None
97
+ route_verified: bool = False
98
+
99
+
100
+ class Geo(BaseModel):
101
+ lat: Optional[float] = None
102
+ lon: Optional[float] = None
103
+ district: Optional[str] = None
104
+ area_name: Optional[str] = None
105
+ cian_claimed_metro: Optional[CianClaimedMetro] = None
106
+ honest_walk_route: Optional[HonestWalkRoute] = None
107
+ honest_overclaim: Optional[dict] = None # v0.3.2: {'overclaim_minutes': N, 'flag': bool}
108
+
109
+
110
+ class Seller(BaseModel):
111
+ seller_type: Optional[str] = None
112
+ name: Optional[str] = None
113
+ agent_phone_masked: Optional[str] = None
114
+ # v0.3.1 (реалтор-аудит §4, §5)
115
+ is_by_homeowner: Optional[bool] = None
116
+ phones: list[str] = Field(default_factory=list)
117
+ created_at: Optional[str] = None # дата публикации
118
+ edit_date: Optional[str] = None # дата последнего изменения
119
+
120
+
121
+ class Image(BaseModel):
122
+ url: str
123
+ category: Optional[str] = None
124
+
125
+
126
+ class ListingCard(BaseModel):
127
+ """Full listing card schema used by import/get-card/search."""
128
+
129
+ id: str
130
+ source: str
131
+ url: str
132
+ status: Literal["active", "inactive", "sold", "removed"] = "active"
133
+ title: Optional[str] = None
134
+
135
+ pricing: Pricing = Field(default_factory=Pricing)
136
+ parameters: Parameters = Field(default_factory=Parameters)
137
+ building: Building = Field(default_factory=Building)
138
+ geo: Geo = Field(default_factory=Geo)
139
+
140
+ description_text: Optional[str] = None
141
+ # v0.4.0 (ТЗ реалтора): категория объявления (flatShare и т.д.)
142
+ category: Optional[str] = None
143
+ seller: Seller = Field(default_factory=Seller)
144
+ images: list[Image] = Field(default_factory=list)
145
+
146
+ @computed_field
147
+ @property
148
+ def missing_fields(self) -> list[str]:
149
+ missing: list[str] = []
150
+ if self.pricing.price_total_rub is None:
151
+ missing.append("price_total_rub")
152
+ if self.parameters.area_total_m2 is None:
153
+ missing.append("area_total_m2")
154
+ if self.parameters.rooms is None:
155
+ missing.append("rooms")
156
+ if self.parameters.floor is None:
157
+ missing.append("floor")
158
+ if self.parameters.floors_total is None:
159
+ missing.append("floors_total")
160
+ if not (self.building.address_raw or "").strip():
161
+ missing.append("address_raw")
162
+ if not (self.geo.district or "").strip():
163
+ missing.append("geo.district")
164
+ return missing
nedviga/mosdata.py ADDED
@@ -0,0 +1,95 @@
1
+ """v0.5.8 (the task brief) mosdata client."""
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
+ import requests
10
+
11
+ BASE_URL = "https://apidata.mos.ru/v1"
12
+
13
+
14
+ def _token() -> Optional[str]:
15
+ p = os.environ.get("NEDVIGA_MOS_TOKEN_FILE")
16
+ if not p:
17
+ return None
18
+ try:
19
+ return Path(p).read_text(encoding="utf-8").strip()
20
+ except Exception:
21
+ return None
22
+
23
+
24
+ def _proxy() -> Optional[str]:
25
+ return os.environ.get("NEDVIGA_EGRESS_PROXY") or None
26
+
27
+
28
+
29
+
30
+ def _redact(s: str) -> str:
31
+ import re
32
+ return re.sub(r"api_key=[^&\s]+", "api_key=REDACTED", str(s))
33
+
34
+
35
+ class _SafeError(RuntimeError):
36
+ def __str__(self):
37
+ return _redact(super().__str__())
38
+
39
+ def _request(method: str, path: str, params=None, timeout=30.0) -> Any:
40
+ from .net_guard import guarded_get
41
+ url = BASE_URL.rstrip("/") + "/" + path.lstrip("/")
42
+ headers = {}
43
+ tok = _token()
44
+ if tok is None:
45
+ raise RuntimeError("token_missing")
46
+ # v0.5.8 live: apidata v1 принимает ключ КАК QUERY-ПАРАМЕТР api_key (заголовок не канон).
47
+ # Ключ в URL = риск утечки в exception/лог: все строки ошибок прогоняются через redact.
48
+ params = dict(params or {})
49
+ params["api_key"] = tok
50
+ # v0.5.8 контроль качества: ЕДИНСТВЕННЫЙ транспорт — guarded_get (allowlist + RU-egress
51
+ # внутри netguard по env NEDVIGA_EGRESS_PROXY). Прежний двойной путь (прямые requests
52
+ # c прокси в обход пина) вырезан: он нарушал инвариант транспорта и молча глотал ошибки.
53
+ import urllib.parse
54
+ if params:
55
+ url = url + "?" + urllib.parse.urlencode(params)
56
+ try:
57
+ resp = guarded_get(url, timeout=timeout, headers=headers)
58
+ resp.raise_for_status()
59
+ return resp.json()
60
+ except Exception as exc:
61
+ msg = _redact(str(exc))
62
+ err = _SafeError(f"{type(exc).__name__}: {msg}")
63
+ raise err from None
64
+
65
+
66
+ def search_datasets(query: str) -> dict:
67
+ params = {"$filter": f"contains(Name,'{query}')"}
68
+ return _request("GET", "datasets", params=params)
69
+
70
+
71
+ def dataset_versions(dataset_id: str) -> Any:
72
+ return _request("GET", f"datasets/{dataset_id}/versions")
73
+
74
+
75
+ def rows(dataset_id: str, version: Optional[str] = None, top: int = 1000, skip: int = 0, filter_expr: Optional[str] = None) -> Any:
76
+ path = f"datasets/{dataset_id}/rows"
77
+ params = {"$top": top, "$skip": skip}
78
+ if version:
79
+ params["versionNumber"] = version
80
+ if filter_expr:
81
+ params["$filter"] = filter_expr
82
+ return _request("GET", path, params=params)
83
+
84
+
85
+ def save_discovery(data: dict) -> None:
86
+ base = Path(os.environ.get("NEDVIGA_STATE_DIR", str(Path.home() / ".local" / "share" / "nedviga")))
87
+ base.mkdir(parents=True, exist_ok=True)
88
+ p = base / "mos_datasets.json"
89
+ # store only ids from live payload
90
+ ids = []
91
+ for d in (data.get("datasets") or data.get("value") or []):
92
+ if isinstance(d, dict) and d.get("Id") is not None:
93
+ ids.append(d["Id"])
94
+ payload = {"datasets": data.get("datasets") or data.get("value") or [], "ids": ids}
95
+ p.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")