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
|
@@ -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")
|