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/poi.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""v0.5.1 (the task brief) POI accessibility."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import math
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
from datetime import datetime, timedelta, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
from .models import ListingCard
|
|
14
|
+
|
|
15
|
+
_CACHE_DIR = Path(os.environ.get("NEDVIGA_STATE_DIR", str(Path.home() / ".local" / "share" / "nedviga"))) / "poi_cache"
|
|
16
|
+
_RATE_LIMIT_SECONDS = 5.0
|
|
17
|
+
_last_request: float = 0.0
|
|
18
|
+
|
|
19
|
+
CATEGORIES = {
|
|
20
|
+
"school": lambda t: t.get("amenity") == "school",
|
|
21
|
+
"kindergarten": lambda t: t.get("amenity") == "kindergarten",
|
|
22
|
+
"grocery": lambda t: t.get("shop") in {"supermarket", "convenience", "grocery"},
|
|
23
|
+
"park": lambda t: t.get("leisure") == "park",
|
|
24
|
+
"clinic": lambda t: t.get("amenity") in {"clinic", "doctor", "hospital"},
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
_OVERPASS_URLS = [ # v0.5.4: публичный зеркалируется; single-endpoint = SPOF (урок realtor 406/timeout)
|
|
28
|
+
"https://overpass-api.de/api/interpreter",
|
|
29
|
+
"https://overpass.kumi.systems/api/interpreter",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _haversine(lat1, lon1, lat2, lon2):
|
|
34
|
+
R = 6371000.0
|
|
35
|
+
phi1 = math.radians(lat1)
|
|
36
|
+
phi2 = math.radians(lat2)
|
|
37
|
+
dphi = math.radians(lat2 - lat1)
|
|
38
|
+
dl = math.radians(lon2 - lon1)
|
|
39
|
+
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dl / 2) ** 2
|
|
40
|
+
return 2 * R * math.asin(math.sqrt(a))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _cache_key(lat, lon, radius):
|
|
44
|
+
return hashlib.sha256(f"{lat:.6f}:{lon:.6f}:{radius}".encode()).hexdigest()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _cache_path(key):
|
|
48
|
+
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
49
|
+
return _CACHE_DIR / f"{key}.json"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _cache_get(key):
|
|
53
|
+
p = _cache_path(key)
|
|
54
|
+
if not p.exists():
|
|
55
|
+
return None
|
|
56
|
+
try:
|
|
57
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
58
|
+
except Exception:
|
|
59
|
+
return None
|
|
60
|
+
ts = data.get("_ts")
|
|
61
|
+
if ts:
|
|
62
|
+
dt = datetime.fromisoformat(ts)
|
|
63
|
+
if datetime.now(timezone.utc) - dt > timedelta(days=30):
|
|
64
|
+
return None
|
|
65
|
+
return data.get("payload")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _cache_set(key, payload):
|
|
69
|
+
p = _cache_path(key)
|
|
70
|
+
p.write_text(json.dumps({"_ts": datetime.now(timezone.utc).isoformat(), "payload": payload},
|
|
71
|
+
ensure_ascii=False), encoding="utf-8")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _rate_limit():
|
|
75
|
+
global _last_request
|
|
76
|
+
now = time.time()
|
|
77
|
+
wait = _RATE_LIMIT_SECONDS - (now - _last_request)
|
|
78
|
+
if wait > 0:
|
|
79
|
+
time.sleep(wait)
|
|
80
|
+
_last_request = time.time()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _query_overpass(lat, lon, radius):
|
|
84
|
+
# rate limit + cache handled by caller; this does network
|
|
85
|
+
from .net_guard import guarded_get
|
|
86
|
+
q = (f"[out:json];"
|
|
87
|
+
f"(node(around:{radius},{lat},{lon})[amenity~\"school|kindergarten|clinic|doctor|hospital\"];"
|
|
88
|
+
f"node(around:{radius},{lat},{lon})[shop~\"supermarket|convenience|grocery\"];"
|
|
89
|
+
f"node(around:{radius},{lat},{lon})[leisure=park];);out;")
|
|
90
|
+
last_err = None
|
|
91
|
+
for base in _OVERPASS_URLS[:2]:
|
|
92
|
+
url = f"{base}?data={q}"
|
|
93
|
+
_rate_limit()
|
|
94
|
+
try:
|
|
95
|
+
resp = guarded_get(url, timeout=25.0)
|
|
96
|
+
resp.raise_for_status()
|
|
97
|
+
return resp.json()
|
|
98
|
+
except Exception as exc: # noqa: BLE001 — деградация видима вызывающему
|
|
99
|
+
last_err = exc
|
|
100
|
+
continue
|
|
101
|
+
raise type(last_err)(f"overpass_all_endpoints_failed: {last_err}")
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _classify(elements, lat, lon, radius_m):
|
|
105
|
+
# returns dict category -> list of (distance, mode)
|
|
106
|
+
result = {cat: [] for cat in CATEGORIES}
|
|
107
|
+
for el in elements or []:
|
|
108
|
+
if el.get("type") != "node":
|
|
109
|
+
continue
|
|
110
|
+
elat = el.get("lat")
|
|
111
|
+
elon = el.get("lon")
|
|
112
|
+
if elat is None or elon is None:
|
|
113
|
+
continue
|
|
114
|
+
tags = el.get("tags") or {}
|
|
115
|
+
dist = _haversine(lat, lon, elat, elon)
|
|
116
|
+
# try walking graph
|
|
117
|
+
mode = "по прямой"
|
|
118
|
+
try:
|
|
119
|
+
from . import graph as _g
|
|
120
|
+
r = _g.route(lat, lon, elat, elon)
|
|
121
|
+
if r is not None:
|
|
122
|
+
meters, provider = r
|
|
123
|
+
dist = meters
|
|
124
|
+
mode = "пешком"
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
# v0.5.1 контроль качества: радиус запроса = фильтр. Школа в 13.9 км
|
|
128
|
+
# «пешком» при radius-min=15 — шум, а не инфраструктура.
|
|
129
|
+
if dist > radius_m:
|
|
130
|
+
continue
|
|
131
|
+
for cat, pred in CATEGORIES.items():
|
|
132
|
+
if pred(tags):
|
|
133
|
+
result[cat].append((dist, mode))
|
|
134
|
+
out = {}
|
|
135
|
+
for cat, items in result.items():
|
|
136
|
+
if not items:
|
|
137
|
+
out[cat] = {"count": 0, "nearest_m": None, "mode": None}
|
|
138
|
+
continue
|
|
139
|
+
nearest = min(items, key=lambda x: x[0])
|
|
140
|
+
out[cat] = {"count": len(items), "nearest_m": int(round(nearest[0])), "mode": nearest[1]}
|
|
141
|
+
return out
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def poi_for_card(card: Any, radius_min: int = 15, offline: Optional[str] = None) -> dict:
|
|
145
|
+
from .models import ListingCard
|
|
146
|
+
c = card if isinstance(card, ListingCard) else ListingCard(**card) if isinstance(card, dict) else None
|
|
147
|
+
if c is None:
|
|
148
|
+
raise TypeError("unsupported card")
|
|
149
|
+
geo = c.geo
|
|
150
|
+
lat = geo.lat if geo else None
|
|
151
|
+
lon = geo.lon if geo else None
|
|
152
|
+
if lat is None or lon is None:
|
|
153
|
+
return {"status": "no_coordinates", "categories": {}}
|
|
154
|
+
radius = max(100, radius_min * 80) # ~80m/min
|
|
155
|
+
key = _cache_key(lat, lon, radius)
|
|
156
|
+
cached = _cache_get(key)
|
|
157
|
+
if cached is not None:
|
|
158
|
+
payload = cached
|
|
159
|
+
elif offline:
|
|
160
|
+
raw = json.loads(Path(offline).read_text(encoding="utf-8"))
|
|
161
|
+
payload = raw
|
|
162
|
+
else:
|
|
163
|
+
payload = _query_overpass(lat, lon, radius)
|
|
164
|
+
_cache_set(key, payload)
|
|
165
|
+
categories = _classify(payload.get("elements", []) if isinstance(payload, dict) else [], lat, lon, radius)
|
|
166
|
+
return {"status": "ok", "lat": lat, "lon": lon, "radius_m": radius, "categories": categories}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def render_text(result: dict) -> str:
|
|
170
|
+
if result.get("status") == "no_coordinates":
|
|
171
|
+
return "no_coordinates"
|
|
172
|
+
cats = result.get("categories", {})
|
|
173
|
+
lines = ["🏫 Инфраструктура (прямая доступность, кэш на 30 дней):"]
|
|
174
|
+
for cat, info in cats.items():
|
|
175
|
+
if info.get("count", 0) == 0:
|
|
176
|
+
lines.append(f"{cat}: нет данных")
|
|
177
|
+
else:
|
|
178
|
+
mode = info.get("mode") or "по прямой"
|
|
179
|
+
lines.append(f"{cat}: {info['count']} шт., ближайший {info['nearest_m']} м ({mode})")
|
|
180
|
+
return "\n".join(lines)
|
nedviga/price_guard.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""P3-3 (2026-10-20): fail-closed для карточек без цены.
|
|
2
|
+
|
|
3
|
+
Живой дефект (ТЗ realtor): карточка без цены молча уезжала с price_total_rub=None.
|
|
4
|
+
Схема (status enum) контракта не меняет — карантин выражается отдельным
|
|
5
|
+
jsonl-журналом price_drift: «цена пропала» = событие, а не отсутствие поля.
|
|
6
|
+
|
|
7
|
+
Порядок для цены:
|
|
8
|
+
1) structured fields / itemprop (cian_card),
|
|
9
|
+
2) adaptive selectors (adaptive_selectors.extract_price_adaptive),
|
|
10
|
+
3) title-fallback (этот модуль: «12 500 000 ₽»/«12500000 руб.» из заголовка),
|
|
11
|
+
4) ничего не найдено -> None + запись в drift-журнал (карантин для приёмки).
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
_TITLE_PRICE_RE = re.compile(
|
|
23
|
+
r"(?<![\d.])(\d{1,3}(?:[ \u00a0]\d{3})+|\d{6,12})[ \u00a0]*(?:₽|руб)",
|
|
24
|
+
re.IGNORECASE,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def price_from_title(title: Optional[str]) -> Optional[float]:
|
|
29
|
+
"""Вытащить цену из заголовка, только если рядом стоит ₽/руб.
|
|
30
|
+
|
|
31
|
+
Голые числа (комнаты, этажи, «1 из 12») не считаются ценой — ложные
|
|
32
|
+
срабатывания дороже молчаливого None (тогда уж drift-событие)."""
|
|
33
|
+
if not title:
|
|
34
|
+
return None
|
|
35
|
+
m = _TITLE_PRICE_RE.search(title)
|
|
36
|
+
if not m:
|
|
37
|
+
return None
|
|
38
|
+
digits = m.group(1).replace(" ", "").replace("\u00a0", "")
|
|
39
|
+
try:
|
|
40
|
+
value = float(digits)
|
|
41
|
+
except ValueError:
|
|
42
|
+
return None
|
|
43
|
+
if value < 100_000: # квартир-ценников ниже нет; отсекает мусор вроде «1250 ₽/м2»
|
|
44
|
+
return None
|
|
45
|
+
return value
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def drift_file() -> Path:
|
|
49
|
+
return Path(os.environ.get(
|
|
50
|
+
"NEDVIGA_DRIFT_FILE",
|
|
51
|
+
str(Path.home() / ".cache" / "nedviga-cli" / "price_drift.jsonl"),
|
|
52
|
+
))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def record_price_drift(card_id: str, url: Optional[str],
|
|
56
|
+
title: Optional[str]) -> Path:
|
|
57
|
+
"""Карантин-событие: цена пропала после всех фолбэков. Возвращает путь к журналу."""
|
|
58
|
+
path = drift_file()
|
|
59
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
# v0.4.5 (review R15): drift-журнал в ~/.cache не должен нести заголовок и
|
|
61
|
+
# полный url (в title бывают ПДн) — только id и host источника.
|
|
62
|
+
from urllib.parse import urlsplit
|
|
63
|
+
entry = {
|
|
64
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
65
|
+
"card_id": card_id,
|
|
66
|
+
"url_host": urlsplit(url).netloc if url else None,
|
|
67
|
+
"event": "price_missing_quarantined",
|
|
68
|
+
}
|
|
69
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
70
|
+
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
71
|
+
fh.flush()
|
|
72
|
+
os.fsync(fh.fileno())
|
|
73
|
+
return path
|
nedviga/profiles.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""v0.4.6 (the task brief F-03): именованные профили поиска (client search profiles).
|
|
2
|
+
|
|
3
|
+
CLI: ``nedviga-cli search --profile parents [аргументы…]``
|
|
4
|
+
|
|
5
|
+
Семантика:
|
|
6
|
+
- профиль задаёт базовые значения фильтров (defaults);
|
|
7
|
+
- любой явно переданный CLI-аргумент переопределяет поле профиля;
|
|
8
|
+
- без ``--profile`` поведение не меняется (инвариант обратной совместимости);
|
|
9
|
+
- неизвестный профиль -> :class:`UnknownProfileError` (CLI: envelope
|
|
10
|
+
``reason="unknown_profile"``, exit code 3).
|
|
11
|
+
|
|
12
|
+
Пресеты встроены в модуль; дополнительные/переопределённые профили могут
|
|
13
|
+
приходить из JSON-файла, путь которого задан переменной окружения
|
|
14
|
+
``NEDVIGA_PROFILES_JSON`` (структура: ``{"<name>": {"<field>": value}}``).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
# Булевы флаги search: CLI-значение True трактуется как «задано явно».
|
|
25
|
+
BOOL_FIELDS = {"floor_not_first", "strict_mkad", "only_flat", "no_shares", "no_auction", "no_gas"}
|
|
26
|
+
# Значимые поля: CLI-значение «не None» трактуется как «задано явно».
|
|
27
|
+
VALUE_FIELDS = {
|
|
28
|
+
"rooms",
|
|
29
|
+
"price_min",
|
|
30
|
+
"price_max",
|
|
31
|
+
"kitchen_min",
|
|
32
|
+
"min_house_floors",
|
|
33
|
+
"geo_districts",
|
|
34
|
+
"metro_walk_max",
|
|
35
|
+
}
|
|
36
|
+
_ALLOWED_FIELDS = BOOL_FIELDS | VALUE_FIELDS
|
|
37
|
+
|
|
38
|
+
# Встроенные пресеты (ТЗ the task brief §3.1, автор agent-profile).
|
|
39
|
+
BUILTIN_PROFILES: dict[str, dict[str, Any]] = {
|
|
40
|
+
"parents": {
|
|
41
|
+
"rooms": 2,
|
|
42
|
+
"floor_not_first": True,
|
|
43
|
+
"min_house_floors": 10,
|
|
44
|
+
"geo_districts": ["ЮАО", "ЮЗАО"],
|
|
45
|
+
"kitchen_min": 7.5,
|
|
46
|
+
"price_max": 20_000_000,
|
|
47
|
+
"metro_walk_max": 20,
|
|
48
|
+
"strict_mkad": True,
|
|
49
|
+
"only_flat": True,
|
|
50
|
+
"no_shares": True,
|
|
51
|
+
"no_auction": True,
|
|
52
|
+
},
|
|
53
|
+
"lnfnt": {
|
|
54
|
+
"price_max": 16_000_000,
|
|
55
|
+
"floor_not_first": True,
|
|
56
|
+
"min_house_floors": 12,
|
|
57
|
+
"geo_districts": ["ЗАО", "ЮАО", "ЮВАО"],
|
|
58
|
+
"kitchen_min": 8.0,
|
|
59
|
+
"strict_mkad": True,
|
|
60
|
+
"only_flat": True,
|
|
61
|
+
"no_shares": True,
|
|
62
|
+
"no_auction": True,
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
_ENV_VAR = "NEDVIGA_PROFILES_JSON"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class UnknownProfileError(ValueError):
|
|
70
|
+
"""Профиль с таким именем не найден (ни во встроенных, ни в файле)."""
|
|
71
|
+
|
|
72
|
+
def __init__(self, requested: str, known: list[str]) -> None:
|
|
73
|
+
super().__init__(f"unknown search profile '{requested}'")
|
|
74
|
+
self.requested = requested
|
|
75
|
+
self.known = list(known)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def load_profiles() -> dict[str, dict[str, Any]]:
|
|
79
|
+
"""Встроенные профили + опциональный override-файл из NEDVIGA_PROFILES_JSON.
|
|
80
|
+
|
|
81
|
+
Некорректный/нечитаемый файл не роняет CLI: fallback на встроенные.
|
|
82
|
+
Поля, не входящие в известные фильтры search, из файла отбрасываются.
|
|
83
|
+
"""
|
|
84
|
+
profiles: dict[str, dict[str, Any]] = {
|
|
85
|
+
name: dict(values) for name, values in BUILTIN_PROFILES.items()
|
|
86
|
+
}
|
|
87
|
+
path = os.environ.get(_ENV_VAR, "").strip()
|
|
88
|
+
if path:
|
|
89
|
+
try:
|
|
90
|
+
raw = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
91
|
+
except (OSError, ValueError):
|
|
92
|
+
raw = None
|
|
93
|
+
if isinstance(raw, dict):
|
|
94
|
+
for name, values in raw.items():
|
|
95
|
+
if isinstance(name, str) and isinstance(values, dict):
|
|
96
|
+
merged = dict(profiles.get(name, {}))
|
|
97
|
+
merged.update(
|
|
98
|
+
{k: v for k, v in values.items() if k in _ALLOWED_FIELDS}
|
|
99
|
+
)
|
|
100
|
+
profiles[name] = merged
|
|
101
|
+
return profiles
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def available_profiles() -> list[str]:
|
|
105
|
+
return sorted(load_profiles())
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get_profile(name: str) -> dict[str, Any]:
|
|
109
|
+
profiles = load_profiles()
|
|
110
|
+
if name not in profiles:
|
|
111
|
+
raise UnknownProfileError(name, available_profiles())
|
|
112
|
+
return profiles[name]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def apply_profile(args: Any, name: str) -> list[str]:
|
|
116
|
+
"""Применить профиль к argparse-namespace; явные CLI-аргументы сильнее.
|
|
117
|
+
|
|
118
|
+
Возвращает список применённых полей (для metadata).
|
|
119
|
+
"""
|
|
120
|
+
applied: list[str] = []
|
|
121
|
+
for field, value in get_profile(name).items():
|
|
122
|
+
if isinstance(value, list):
|
|
123
|
+
value = ",".join(str(item) for item in value)
|
|
124
|
+
current = getattr(args, field, None)
|
|
125
|
+
if field in BOOL_FIELDS and bool(current):
|
|
126
|
+
continue # флаг задан в CLI
|
|
127
|
+
if field in VALUE_FIELDS and current is not None:
|
|
128
|
+
continue # значение задано в CLI
|
|
129
|
+
setattr(args, field, value)
|
|
130
|
+
applied.append(field)
|
|
131
|
+
return applied
|
nedviga/projects.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""v0.5.0 (the task brief) projects."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sqlite3
|
|
7
|
+
import threading
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
_STATE = lambda: Path(os.environ.get("NEDVIGA_STATE_DIR", str(Path.home() / ".local" / "share" / "nedviga")))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def db_path() -> Path:
|
|
16
|
+
p = _STATE() / "projects.sqlite"
|
|
17
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
return p
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _connect(db=None):
|
|
22
|
+
p = db or db_path()
|
|
23
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
24
|
+
conn = sqlite3.connect(str(p), timeout=30.0)
|
|
25
|
+
conn.row_factory = sqlite3.Row
|
|
26
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
27
|
+
conn.execute("""CREATE TABLE IF NOT EXISTS projects (
|
|
28
|
+
id TEXT PRIMARY KEY, name TEXT NOT NULL, query_json TEXT, created TEXT NOT NULL)""")
|
|
29
|
+
conn.execute("""CREATE TABLE IF NOT EXISTS project_cards (
|
|
30
|
+
project_id TEXT NOT NULL, card_id TEXT NOT NULL, added_at TEXT NOT NULL,
|
|
31
|
+
UNIQUE(project_id, card_id))""")
|
|
32
|
+
conn.commit()
|
|
33
|
+
return conn
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def project_new(name: str, query: Optional[str] = None, db=None) -> dict:
|
|
37
|
+
pid = name
|
|
38
|
+
conn = _connect(db)
|
|
39
|
+
try:
|
|
40
|
+
conn.execute("INSERT OR REPLACE INTO projects (id,name,query_json,created) VALUES (?,?,?,?)",
|
|
41
|
+
(pid, name, query, datetime.now(timezone.utc).isoformat()))
|
|
42
|
+
conn.commit()
|
|
43
|
+
return {"ok": True, "id": pid, "name": name}
|
|
44
|
+
finally:
|
|
45
|
+
conn.close()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def project_list(db=None) -> list[dict]:
|
|
49
|
+
conn = _connect(db)
|
|
50
|
+
try:
|
|
51
|
+
rows = conn.execute("SELECT * FROM projects ORDER BY name").fetchall()
|
|
52
|
+
return [dict(r) for r in rows]
|
|
53
|
+
finally:
|
|
54
|
+
conn.close()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def project_add(project: str, card_id: str, db=None) -> dict:
|
|
58
|
+
conn = _connect(db)
|
|
59
|
+
try:
|
|
60
|
+
# idempotent
|
|
61
|
+
exists = conn.execute("SELECT 1 FROM project_cards WHERE project_id=? AND card_id=?",
|
|
62
|
+
(project, card_id)).fetchone()
|
|
63
|
+
if exists:
|
|
64
|
+
return {"ok": True, "project": project, "card_id": card_id, "idempotent": True}
|
|
65
|
+
conn.execute("INSERT INTO project_cards (project_id, card_id, added_at) VALUES (?,?,?)",
|
|
66
|
+
(project, card_id, datetime.now(timezone.utc).isoformat()))
|
|
67
|
+
conn.commit()
|
|
68
|
+
return {"ok": True, "project": project, "card_id": card_id, "added": True}
|
|
69
|
+
finally:
|
|
70
|
+
conn.close()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def project_cards(project: str, db=None) -> list[str]:
|
|
74
|
+
conn = _connect(db)
|
|
75
|
+
try:
|
|
76
|
+
rows = conn.execute("SELECT card_id FROM project_cards WHERE project_id=? ORDER BY added_at",
|
|
77
|
+
(project,)).fetchall()
|
|
78
|
+
return [r["card_id"] for r in rows]
|
|
79
|
+
finally:
|
|
80
|
+
conn.close()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def project_open(project: str, db=None) -> dict:
|
|
84
|
+
cards = project_cards(project, db=db)
|
|
85
|
+
# skeletons
|
|
86
|
+
return {"project": project, "cards": [{"id": c} for c in cards]}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def project_refresh(project: str, file: str, db=None) -> dict:
|
|
90
|
+
# new search export
|
|
91
|
+
raw = json.loads(Path(file).read_text(encoding="utf-8"))
|
|
92
|
+
if isinstance(raw, dict):
|
|
93
|
+
if "cards" in raw and isinstance(raw["cards"], list):
|
|
94
|
+
raw = raw["cards"]
|
|
95
|
+
elif "data" in raw:
|
|
96
|
+
d = raw["data"]
|
|
97
|
+
raw = d if isinstance(d, list) else ([d] if isinstance(d, dict) else [])
|
|
98
|
+
else:
|
|
99
|
+
raw = [raw]
|
|
100
|
+
if not isinstance(raw, list):
|
|
101
|
+
raw = [raw]
|
|
102
|
+
new_ids = {str(x.get("id")) for x in raw if isinstance(x, dict) and x.get("id")}
|
|
103
|
+
old_ids = set(project_cards(project, db=db))
|
|
104
|
+
added = sorted(new_ids - old_ids)
|
|
105
|
+
removed = sorted(old_ids - new_ids)
|
|
106
|
+
# cheaper: compare price if card present in both? Need old snapshot not stored; skip
|
|
107
|
+
cheaper = []
|
|
108
|
+
# add new ids to project (idempotent)
|
|
109
|
+
for cid in added:
|
|
110
|
+
project_add(project, cid, db=db)
|
|
111
|
+
return {"project": project, "new": added, "removed": removed, "cheaper": cheaper,
|
|
112
|
+
"delta": {"added": len(added), "removed": len(removed)}}
|
nedviga/purge.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""v0.5.5 (the task brief) purge."""
|
|
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 . import pipeline
|
|
10
|
+
from .auditlog import log_action
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _state_dir() -> Path:
|
|
14
|
+
return Path(os.environ.get("NEDVIGA_STATE_DIR", str(Path.home() / ".local" / "share" / "nedviga")))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def purge_card(card_id: str, yes: bool = False, dry: bool = True) -> dict:
|
|
18
|
+
# dry run: list tables and counts
|
|
19
|
+
conn = pipeline._connect()
|
|
20
|
+
tables = ["pipeline_items", "status_history", "watch_snapshots", "watch_history",
|
|
21
|
+
"show_feedback", "deal_structures", "project_cards", "chain_links"]
|
|
22
|
+
counts = {}
|
|
23
|
+
for t in tables:
|
|
24
|
+
try:
|
|
25
|
+
if t == "project_cards":
|
|
26
|
+
c = conn.execute(f"SELECT COUNT(*) FROM {t} WHERE card_id=?", (card_id,)).fetchone()[0]
|
|
27
|
+
elif t in ("status_history", "watch_history"):
|
|
28
|
+
c = conn.execute(f"SELECT COUNT(*) FROM {t} WHERE card_id=?", (card_id,)).fetchone()[0]
|
|
29
|
+
elif t == "chain_links":
|
|
30
|
+
c = conn.execute(f"SELECT COUNT(*) FROM {t} WHERE a=? OR b=?", (card_id, card_id)).fetchone()[0]
|
|
31
|
+
else:
|
|
32
|
+
c = conn.execute(f"SELECT COUNT(*) FROM {t} WHERE card_id=?", (card_id,)).fetchone()[0]
|
|
33
|
+
counts[t] = c
|
|
34
|
+
except Exception:
|
|
35
|
+
counts[t] = 0
|
|
36
|
+
conn.close()
|
|
37
|
+
|
|
38
|
+
total = sum(counts.values())
|
|
39
|
+
if dry or not yes:
|
|
40
|
+
return {"dry": True, "card_id": card_id, "tables": counts, "total": total,
|
|
41
|
+
"note": "files on disk not removed; manual decision required"}
|
|
42
|
+
|
|
43
|
+
# execute
|
|
44
|
+
conn = pipeline._connect()
|
|
45
|
+
# ensure tables exist
|
|
46
|
+
try:
|
|
47
|
+
from .chains import _connect as _cc
|
|
48
|
+
_cc().close()
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
51
|
+
try:
|
|
52
|
+
from .feedback import _connect as _fc
|
|
53
|
+
_fc().close()
|
|
54
|
+
except Exception:
|
|
55
|
+
pass
|
|
56
|
+
for sql, params in [
|
|
57
|
+
("DELETE FROM pipeline_items WHERE card_id=?", (card_id,)),
|
|
58
|
+
("DELETE FROM status_history WHERE card_id=?", (card_id,)),
|
|
59
|
+
("DELETE FROM watch_snapshots WHERE card_id=?", (card_id,)),
|
|
60
|
+
("DELETE FROM watch_history WHERE card_id=?", (card_id,)),
|
|
61
|
+
("DELETE FROM show_feedback WHERE card_id=?", (card_id,)),
|
|
62
|
+
("DELETE FROM deal_structures WHERE card_id=?", (card_id,)),
|
|
63
|
+
("DELETE FROM chain_links WHERE a=? OR b=?", (card_id, card_id)),
|
|
64
|
+
("DELETE FROM project_cards WHERE card_id=?", (card_id,)),
|
|
65
|
+
]:
|
|
66
|
+
try:
|
|
67
|
+
conn.execute(sql, params)
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
try:
|
|
71
|
+
from .textsearch import db_path as tdb
|
|
72
|
+
import sqlite3
|
|
73
|
+
tconn = sqlite3.connect(str(tdb()))
|
|
74
|
+
try:
|
|
75
|
+
tconn.execute("DELETE FROM documents WHERE card_id=?", (card_id,))
|
|
76
|
+
tconn.execute("DELETE FROM docs_fts WHERE card_id=?", (card_id,))
|
|
77
|
+
tconn.commit()
|
|
78
|
+
except Exception:
|
|
79
|
+
pass
|
|
80
|
+
tconn.close()
|
|
81
|
+
except Exception:
|
|
82
|
+
pass
|
|
83
|
+
try:
|
|
84
|
+
conn.commit()
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
conn.close()
|
|
88
|
+
|
|
89
|
+
# idempotency: if total was 0 -> not_found
|
|
90
|
+
if total == 0:
|
|
91
|
+
return {"ok": True, "card_id": card_id, "deleted": 0, "reason": "not_found",
|
|
92
|
+
"tables": counts}
|
|
93
|
+
# audit trail
|
|
94
|
+
try:
|
|
95
|
+
log_action(role=os.environ.get("NEDVIGA_ROLE", "agent"), command="purge",
|
|
96
|
+
card_id=card_id, rc=0, reason="purge_card",
|
|
97
|
+
argv=[], pii=False)
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
return {"ok": True, "card_id": card_id, "deleted": total, "tables": counts,
|
|
101
|
+
"audit": "recorded"}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def purge_profile(name: str, yes: bool = False, dry: bool = True) -> dict:
|
|
105
|
+
path = _state_dir() / "profile_overrides.json"
|
|
106
|
+
if dry or not yes:
|
|
107
|
+
return {"dry": True, "profile": name, "path": str(path)}
|
|
108
|
+
# remove overrides file if exists
|
|
109
|
+
if path.exists():
|
|
110
|
+
path.unlink()
|
|
111
|
+
try:
|
|
112
|
+
log_action(role=os.environ.get("NEDVIGA_ROLE", "agent"), command="purge",
|
|
113
|
+
card_id=None, rc=0, reason="purge_profile", argv=[], pii=False)
|
|
114
|
+
except Exception:
|
|
115
|
+
pass
|
|
116
|
+
return {"ok": True, "profile": name, "deleted": 1}
|
nedviga/py.typed
ADDED
|
File without changes
|