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/noise.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""v0.5.11 (the task brief) noise monitoring."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import sqlite3
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
from .models import ListingCard
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def db_path() -> Path:
|
|
16
|
+
base = Path(os.environ.get("NEDVIGA_STATE_DIR", str(Path.home() / ".local" / "share" / "nedviga")))
|
|
17
|
+
base.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
return base / "noise.sqlite"
|
|
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))
|
|
25
|
+
conn.row_factory = sqlite3.Row
|
|
26
|
+
conn.execute("""CREATE TABLE IF NOT EXISTS noise_measurements (
|
|
27
|
+
id INTEGER PRIMARY KEY, date TEXT, adm_area TEXT, district TEXT, lat REAL, lon REAL,
|
|
28
|
+
location TEXT, territory TEXT, category TEXT, results TEXT, raw TEXT)""")
|
|
29
|
+
conn.execute("""CREATE TABLE IF NOT EXISTS sync_state (cursor TEXT, last_sync TEXT)""")
|
|
30
|
+
conn.commit()
|
|
31
|
+
return conn
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _haversine(lat1, lon1, lat2, lon2):
|
|
35
|
+
R = 6371000.0
|
|
36
|
+
phi1 = math.radians(lat1); phi2 = math.radians(lat2)
|
|
37
|
+
dphi = math.radians(lat2-lat1); dl = math.radians(lon2-lon1)
|
|
38
|
+
x = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dl/2)**2
|
|
39
|
+
return 2*R*math.asin(math.sqrt(x))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _scalar(v):
|
|
43
|
+
"""sqlite-безопасное значение: списки/структуры — в JSON-строку (v0.5.11 контроль:
|
|
44
|
+
live/mock Results бывают list — bind падал ProgrammingError)."""
|
|
45
|
+
if v is None or isinstance(v, (str, int, float, bytes)):
|
|
46
|
+
return v
|
|
47
|
+
import json as _j
|
|
48
|
+
try:
|
|
49
|
+
return _j.dumps(v, ensure_ascii=False)
|
|
50
|
+
except Exception:
|
|
51
|
+
return str(v)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def sync(limit=1000, db=None, rows_func=None) -> dict:
|
|
55
|
+
conn = _connect(db)
|
|
56
|
+
row = conn.execute("SELECT cursor FROM sync_state ORDER BY rowid DESC LIMIT 1").fetchone()
|
|
57
|
+
skip = int(row["cursor"]) if row and row["cursor"] else 0
|
|
58
|
+
if rows_func is None:
|
|
59
|
+
from .mosdata import rows as rows_func
|
|
60
|
+
total = 0
|
|
61
|
+
while total < limit:
|
|
62
|
+
top = min(1000, limit - total)
|
|
63
|
+
try:
|
|
64
|
+
data = rows_func("2449", None, top, skip)
|
|
65
|
+
except Exception:
|
|
66
|
+
break
|
|
67
|
+
items = data if isinstance(data, list) else (data.get("value") or data.get("rows") or [])
|
|
68
|
+
if not items:
|
|
69
|
+
break
|
|
70
|
+
for it in items:
|
|
71
|
+
# map fields
|
|
72
|
+
# v0.5.11 замечание realtor (верное): apidata v1 кладёт поля в Cells —
|
|
73
|
+
# без unwrap половина замеров теряла координаты. Cells в приоритете,
|
|
74
|
+
# верхний уровень — фолбэк (формат моков/экспортов).
|
|
75
|
+
c = it.get("Cells") if isinstance(it.get("Cells"), dict) else it
|
|
76
|
+
lat = c.get("Latitude_WGS84") or it.get("Latitude_WGS84") or c.get("lat")
|
|
77
|
+
lon = c.get("Longitude_WGS84") or it.get("Longitude_WGS84") or c.get("lon")
|
|
78
|
+
row_id = c.get("Id") or it.get("Id") or it.get("id")
|
|
79
|
+
if row_id is None:
|
|
80
|
+
continue
|
|
81
|
+
conn.execute("INSERT OR REPLACE INTO noise_measurements (id,date,adm_area,district,lat,lon,location,territory,category,results,raw) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
|
|
82
|
+
(row_id, _scalar(c.get("Date") or it.get("Date") or it.get("date")),
|
|
83
|
+
_scalar(c.get("AdmArea") or it.get("AdmArea")), _scalar(c.get("District") or it.get("District")),
|
|
84
|
+
_scalar(lat), _scalar(lon),
|
|
85
|
+
_scalar(c.get("Location") or it.get("Location")),
|
|
86
|
+
_scalar(c.get("TerritoryType") or it.get("TerritoryType")),
|
|
87
|
+
_scalar(c.get("NoiseCategory") or it.get("NoiseCategory")),
|
|
88
|
+
_scalar(c.get("Results") or it.get("Results")), json.dumps(it, ensure_ascii=False)))
|
|
89
|
+
total += 1
|
|
90
|
+
if total >= limit:
|
|
91
|
+
break
|
|
92
|
+
skip += len(items)
|
|
93
|
+
conn.execute("INSERT INTO sync_state (cursor,last_sync) VALUES (?,?)",
|
|
94
|
+
(str(skip), datetime.now(timezone.utc).isoformat()))
|
|
95
|
+
conn.commit()
|
|
96
|
+
if len(items) < top:
|
|
97
|
+
break
|
|
98
|
+
conn.close()
|
|
99
|
+
return {"ok": True, "inserted": total, "cursor": str(skip)}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def nearest(lat, lon, limit=5) -> list[dict]:
|
|
103
|
+
conn = _connect()
|
|
104
|
+
rows = conn.execute("SELECT * FROM noise_measurements WHERE lat IS NOT NULL AND lon IS NOT NULL").fetchall()
|
|
105
|
+
conn.close()
|
|
106
|
+
items = []
|
|
107
|
+
for r in rows:
|
|
108
|
+
d = _haversine(lat, lon, r["lat"], r["lon"])
|
|
109
|
+
items.append({"distance_m": int(round(d)), "date": r["date"], "results": r["results"],
|
|
110
|
+
"territory": r["territory"], "source": "data.mos.ru 2449", "location": r["location"]})
|
|
111
|
+
items.sort(key=lambda x: x["distance_m"])
|
|
112
|
+
return items[:limit]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def noise_for_card(card, radius_m: int = 1500) -> dict:
|
|
116
|
+
"""v0.5.11 контроль качества: принимает карточку ListingCard/dict-карточку ИЛИ
|
|
117
|
+
чистые координаты {"geo": {"lat":..,"lon":..}} (CLI --lat/--lon).
|
|
118
|
+
Прежняя версия падала ValidationError на чистых координатах."""
|
|
119
|
+
from .models import ListingCard
|
|
120
|
+
lat = lon = None
|
|
121
|
+
c = None
|
|
122
|
+
if isinstance(card, str):
|
|
123
|
+
try:
|
|
124
|
+
import json, pathlib
|
|
125
|
+
raw = json.loads(pathlib.Path(card).read_text(encoding="utf-8"))
|
|
126
|
+
if isinstance(raw, list):
|
|
127
|
+
raw = raw[0] if raw else {}
|
|
128
|
+
card = raw
|
|
129
|
+
except Exception:
|
|
130
|
+
return {"status": "no_coordinates", "measurements": []}
|
|
131
|
+
if isinstance(card, dict) and "geo" in card and "id" not in card:
|
|
132
|
+
g = card.get("geo") or {}
|
|
133
|
+
lat, lon = g.get("lat"), g.get("lon")
|
|
134
|
+
else:
|
|
135
|
+
c = card if isinstance(card, ListingCard) else (ListingCard(**card) if isinstance(card, dict) else None)
|
|
136
|
+
if c is not None:
|
|
137
|
+
g = c.geo
|
|
138
|
+
lat = g.lat if g else None
|
|
139
|
+
lon = g.lon if g else None
|
|
140
|
+
if lat is None or lon is None:
|
|
141
|
+
return {"status": "no_coordinates", "measurements": []}
|
|
142
|
+
items = [x for x in nearest(lat, lon, limit=5) if x["distance_m"] <= radius_m]
|
|
143
|
+
return {"status": "ok", "measurements": items}
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
DISCLAIMER = "точечные измерения подвижной лабораторией, покрытие фрагментарное; отсутствие записи ≠ низкий шум"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def render_text(result: dict) -> str:
|
|
150
|
+
if result.get("status") == "no_coordinates":
|
|
151
|
+
return "no_coordinates"
|
|
152
|
+
lines = []
|
|
153
|
+
for m in result.get("measurements", []):
|
|
154
|
+
lines.append(f"{m['distance_m']} м — {m['date']}: {m['results']} ({m['territory']}) [{m['source']}]")
|
|
155
|
+
lines.append(DISCLAIMER)
|
|
156
|
+
return "\n".join(lines)
|
nedviga/ops.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""v0.5.7 (the task brief) ops panel."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
from .sources import list_sources
|
|
11
|
+
from . import pipeline
|
|
12
|
+
from .auditlog import read_entries
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _state_dir() -> Path:
|
|
16
|
+
return Path(os.environ.get("NEDVIGA_STATE_DIR", str(Path.home() / ".local" / "share" / "nedviga")))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def collect(days: int = 1, precheck: bool = False, live: bool = False) -> dict[str, Any]:
|
|
20
|
+
# sources
|
|
21
|
+
sources = []
|
|
22
|
+
for s in list_sources():
|
|
23
|
+
sources.append({"id": s["id"], "status": s["status"], "site_patterns": s["site_patterns"]})
|
|
24
|
+
# breaker state: try net_guard
|
|
25
|
+
breaker = {}
|
|
26
|
+
try:
|
|
27
|
+
from .net_guard import _DEFAULT_DB
|
|
28
|
+
breaker = {"db": str(_DEFAULT_DB)}
|
|
29
|
+
except Exception:
|
|
30
|
+
breaker = {}
|
|
31
|
+
# cache stats
|
|
32
|
+
poi_cache = _state_dir() / "poi_cache"
|
|
33
|
+
poi_count = len(list(poi_cache.glob("*.json"))) if poi_cache.exists() else 0
|
|
34
|
+
watch_count = 0
|
|
35
|
+
try:
|
|
36
|
+
conn = pipeline._connect()
|
|
37
|
+
watch_count = conn.execute("SELECT COUNT(*) FROM watch_snapshots").fetchone()[0]
|
|
38
|
+
conn.close()
|
|
39
|
+
except Exception:
|
|
40
|
+
pass
|
|
41
|
+
# errors top-n
|
|
42
|
+
entries = read_entries(days=days)
|
|
43
|
+
errors = [e for e in entries if e.get("rc", 0) != 0]
|
|
44
|
+
# aggregate by command
|
|
45
|
+
agg: dict[str, int] = {}
|
|
46
|
+
for e in errors:
|
|
47
|
+
c = e.get("command", "unknown")
|
|
48
|
+
agg[c] = agg.get(c, 0) + 1
|
|
49
|
+
top_errors = sorted(agg.items(), key=lambda x: x[1], reverse=True)[:10]
|
|
50
|
+
|
|
51
|
+
precheck_results = None
|
|
52
|
+
if precheck:
|
|
53
|
+
precheck_results = []
|
|
54
|
+
for s in sources:
|
|
55
|
+
if live:
|
|
56
|
+
# one ping via netguard
|
|
57
|
+
status = "ok"
|
|
58
|
+
try:
|
|
59
|
+
from .net_guard import guarded_get
|
|
60
|
+
# minimal HEAD not available; attempt GET root
|
|
61
|
+
resp = guarded_get(f"https://{s['site_patterns'][0]}", timeout=5.0)
|
|
62
|
+
if resp.status_code >= 400:
|
|
63
|
+
status = "error"
|
|
64
|
+
except Exception as exc:
|
|
65
|
+
msg = str(exc).lower()
|
|
66
|
+
if "challenge" in msg or "403" in msg:
|
|
67
|
+
status = "challenge"
|
|
68
|
+
elif "blocked" in msg:
|
|
69
|
+
status = "blocked"
|
|
70
|
+
else:
|
|
71
|
+
status = "error"
|
|
72
|
+
precheck_results.append({"source": s["id"], "status": status})
|
|
73
|
+
else:
|
|
74
|
+
precheck_results.append({"source": s["id"], "status": "ok"})
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
"sources": sources,
|
|
78
|
+
"breaker": breaker,
|
|
79
|
+
"cache": {"poi_cache_entries": poi_count, "watch_entries": watch_count},
|
|
80
|
+
"errors_top": top_errors,
|
|
81
|
+
"precheck": precheck_results,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def render_text(data: dict) -> str:
|
|
86
|
+
lines = ["OPS"]
|
|
87
|
+
lines.append("sources: " + ", ".join(f"{s['id']}={s['status']}" for s in data["sources"]))
|
|
88
|
+
lines.append(f"poi_cache: {data['cache']['poi_cache_entries']}")
|
|
89
|
+
lines.append(f"watch: {data['cache']['watch_entries']}")
|
|
90
|
+
if data["errors_top"]:
|
|
91
|
+
lines.append("errors: " + ", ".join(f"{k}:{v}" for k, v in data["errors_top"]))
|
|
92
|
+
if data.get("precheck"):
|
|
93
|
+
lines.append("precheck: " + ", ".join(f"{p['source']}={p['status']}" for p in data["precheck"]))
|
|
94
|
+
return "\n".join(lines)
|
nedviga/outcomes.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Helpers for building the ApiOutcome JSON envelope."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from .models import ApiOutcomeEnvelope
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
STATUS_EXIT_CODES = {
|
|
12
|
+
"ok": 0,
|
|
13
|
+
"degraded": 2,
|
|
14
|
+
"error": 3,
|
|
15
|
+
}
|
|
16
|
+
USAGE_ERROR_EXIT = 4
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def make_envelope(
|
|
20
|
+
status: Literal["ok", "degraded", "error"],
|
|
21
|
+
reason: str | None = None,
|
|
22
|
+
source: str | None = None,
|
|
23
|
+
data: Any = None,
|
|
24
|
+
metadata: dict | None = None,
|
|
25
|
+
) -> ApiOutcomeEnvelope:
|
|
26
|
+
"""Build a valid ApiOutcome envelope."""
|
|
27
|
+
return ApiOutcomeEnvelope(
|
|
28
|
+
ok=(status == "ok"),
|
|
29
|
+
status=status,
|
|
30
|
+
reason=reason,
|
|
31
|
+
source=source,
|
|
32
|
+
parsed_at=datetime.now(timezone.utc),
|
|
33
|
+
data=data,
|
|
34
|
+
metadata=metadata or {},
|
|
35
|
+
)
|
nedviga/pii.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""PII sanitisers for raw dumps and generated reports.
|
|
2
|
+
|
|
3
|
+
v0.4.6 (the task brief F-13): fail-closed маскирование телефонов по умолчанию
|
|
4
|
+
во всех форматах вывода CLI (search stdout/--json, HTML-экспорт).
|
|
5
|
+
Открытие контактов — только явным флагом ``--with-contacts`` с маркером
|
|
6
|
+
``contacts_open`` в envelope/metadata.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
|
|
13
|
+
# Единый плейсхолдер маски (ТЗ §2.1).
|
|
14
|
+
PHONE_MASK = "+7 *** ***-**-**"
|
|
15
|
+
|
|
16
|
+
# Российские мобильные/городские: +7 / 7 / 8 + 10 цифр (разделители опциональны).
|
|
17
|
+
_PHONE_PREFIXED_RE = re.compile(
|
|
18
|
+
r"(?:\+7|7|8)[\s\-]?\(?\d{3}\)?[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}"
|
|
19
|
+
)
|
|
20
|
+
# «Голый» группированный номер без кода страны: 495 123-45-67 / 916 123 45 67.
|
|
21
|
+
# Требует разделители внутри — слитные длинные числа (id, цены) не трогает.
|
|
22
|
+
_PHONE_BARE_GROUPED_RE = re.compile(
|
|
23
|
+
r"(?<![\dA-Za-z])\d{3}[\s\-]\d{3}[\s\-]\d{2}[\s\-]\d{2}(?!\d)"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def mask_phones(text: str) -> str:
|
|
28
|
+
"""Replace phone numbers with a masked placeholder."""
|
|
29
|
+
masked = _PHONE_PREFIXED_RE.sub(PHONE_MASK, text)
|
|
30
|
+
masked = _PHONE_BARE_GROUPED_RE.sub(PHONE_MASK, masked)
|
|
31
|
+
return masked
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def mask_phone_number(value: object) -> str:
|
|
35
|
+
"""Mask a single phone slot.
|
|
36
|
+
|
|
37
|
+
Fail-closed: формат не распознан — значение полностью заменяется маской
|
|
38
|
+
(в слоте телефонов неизвестную строку считаем номером).
|
|
39
|
+
"""
|
|
40
|
+
raw = str(value if value is not None else "")
|
|
41
|
+
if not raw.strip():
|
|
42
|
+
return PHONE_MASK
|
|
43
|
+
masked = mask_phones(raw)
|
|
44
|
+
if masked != raw:
|
|
45
|
+
return masked
|
|
46
|
+
return PHONE_MASK
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def mask_phones_in_obj(obj: object) -> object:
|
|
50
|
+
"""Recursively mask phone numbers inside strings in JSON-like structures."""
|
|
51
|
+
if isinstance(obj, str):
|
|
52
|
+
return mask_phones(obj)
|
|
53
|
+
if isinstance(obj, list):
|
|
54
|
+
return [mask_phones_in_obj(item) for item in obj]
|
|
55
|
+
if isinstance(obj, dict):
|
|
56
|
+
return {k: mask_phones_in_obj(v) for k, v in obj.items()}
|
|
57
|
+
return obj
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def sanitize_contacts(obj: object) -> object:
|
|
61
|
+
"""mask_phones_in_obj + fail-closed обработка телефонных слотов.
|
|
62
|
+
|
|
63
|
+
Ключи ``phones`` (seller.phones и любые списки телефонов) маскируются
|
|
64
|
+
поминутно через :func:`mask_phone_number`: нераспознанный формат
|
|
65
|
+
заменяется полной маской, а не остаётся сырым.
|
|
66
|
+
"""
|
|
67
|
+
if isinstance(obj, dict):
|
|
68
|
+
out: dict = {}
|
|
69
|
+
for key, value in obj.items():
|
|
70
|
+
if key == "phones" and isinstance(value, list):
|
|
71
|
+
out[key] = [
|
|
72
|
+
mask_phone_number(item)
|
|
73
|
+
for item in value
|
|
74
|
+
if str(item if item is not None else "").strip()
|
|
75
|
+
]
|
|
76
|
+
elif key in {"phone", "tel", "contact_phone"} and isinstance(value, str):
|
|
77
|
+
out[key] = mask_phone_number(value)
|
|
78
|
+
else:
|
|
79
|
+
out[key] = sanitize_contacts(value)
|
|
80
|
+
return out
|
|
81
|
+
if isinstance(obj, list):
|
|
82
|
+
return [sanitize_contacts(item) for item in obj]
|
|
83
|
+
if isinstance(obj, str):
|
|
84
|
+
return mask_phones(obj)
|
|
85
|
+
return obj
|
nedviga/pipeline.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""v0.4.8 (the task brief) F-04: pipeline funnel storage."""
|
|
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
|
+
STATUSES = ["new", "candidate", "shortlist", "call_planned", "showing",
|
|
13
|
+
"feedback", "reserved", "rejected", "bought"]
|
|
14
|
+
TERMINAL = {"rejected", "bought"}
|
|
15
|
+
|
|
16
|
+
_ALLOWED: dict[str, set[str]] = {
|
|
17
|
+
"new": {"candidate", "rejected"},
|
|
18
|
+
"candidate": {"shortlist", "rejected"},
|
|
19
|
+
"shortlist": {"call_planned", "rejected"},
|
|
20
|
+
"call_planned": {"showing", "rejected"},
|
|
21
|
+
"showing": {"feedback", "rejected"},
|
|
22
|
+
"feedback": {"reserved", "rejected", "bought"},
|
|
23
|
+
"reserved": {"bought", "rejected"},
|
|
24
|
+
"rejected": set(),
|
|
25
|
+
"bought": set(),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
_STATE_DIR = Path(os.environ.get("NEDVIGA_STATE_DIR", str(Path.home() / ".local" / "share" / "nedviga")))
|
|
29
|
+
_DB_PATH = _STATE_DIR / "pipeline.sqlite"
|
|
30
|
+
_lock = threading.Lock()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def state_dir() -> Path:
|
|
34
|
+
p = Path(os.environ.get("NEDVIGA_STATE_DIR", str(Path.home() / ".local" / "share" / "nedviga")))
|
|
35
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
return p
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def db_path() -> Path:
|
|
40
|
+
return state_dir() / "pipeline.sqlite"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _connect(db: Optional[Path] = None) -> sqlite3.Connection:
|
|
44
|
+
p = db or db_path()
|
|
45
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
conn = sqlite3.connect(str(p), timeout=30.0)
|
|
47
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
48
|
+
conn.execute("PRAGMA busy_timeout=5000")
|
|
49
|
+
conn.row_factory = sqlite3.Row
|
|
50
|
+
conn.execute(
|
|
51
|
+
"CREATE TABLE IF NOT EXISTS pipeline_items ("
|
|
52
|
+
" card_id TEXT PRIMARY KEY,"
|
|
53
|
+
" title TEXT,"
|
|
54
|
+
" price_snapshot REAL,"
|
|
55
|
+
" status TEXT NOT NULL,"
|
|
56
|
+
" updated_at TEXT NOT NULL,"
|
|
57
|
+
" note TEXT)"
|
|
58
|
+
)
|
|
59
|
+
conn.execute(
|
|
60
|
+
"CREATE TABLE IF NOT EXISTS status_history ("
|
|
61
|
+
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
|
62
|
+
" card_id TEXT NOT NULL,"
|
|
63
|
+
" from_status TEXT,"
|
|
64
|
+
" to_status TEXT NOT NULL,"
|
|
65
|
+
" at TEXT NOT NULL,"
|
|
66
|
+
" note TEXT)"
|
|
67
|
+
)
|
|
68
|
+
conn.execute(
|
|
69
|
+
"CREATE TABLE IF NOT EXISTS watch_snapshots ("
|
|
70
|
+
" card_id TEXT PRIMARY KEY,"
|
|
71
|
+
" price REAL,"
|
|
72
|
+
" status TEXT,"
|
|
73
|
+
" floors_total INTEGER,"
|
|
74
|
+
" snapshot_at TEXT NOT NULL,"
|
|
75
|
+
" raw_json TEXT)"
|
|
76
|
+
)
|
|
77
|
+
conn.execute(
|
|
78
|
+
"CREATE TABLE IF NOT EXISTS watch_history ("
|
|
79
|
+
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
|
|
80
|
+
" card_id TEXT NOT NULL,"
|
|
81
|
+
" price REAL,"
|
|
82
|
+
" status TEXT,"
|
|
83
|
+
" at TEXT NOT NULL)"
|
|
84
|
+
)
|
|
85
|
+
conn.commit()
|
|
86
|
+
return conn
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _now() -> str:
|
|
90
|
+
return datetime.now(timezone.utc).isoformat()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _snapshot_from_file(card_id: str, from_file: Optional[str]) -> tuple[Optional[str], Optional[float]]:
|
|
94
|
+
if not from_file:
|
|
95
|
+
return None, None
|
|
96
|
+
try:
|
|
97
|
+
raw = json.loads(Path(from_file).read_text(encoding="utf-8"))
|
|
98
|
+
except Exception as exc: # v0.4.8 контроль качества: молчание = ложный «снапшота нет»
|
|
99
|
+
raise ValueError(f"snapshot_file_unreadable: {type(exc).__name__}") from exc
|
|
100
|
+
if isinstance(raw, dict):
|
|
101
|
+
if "cards" in raw and isinstance(raw["cards"], list):
|
|
102
|
+
raw = raw["cards"]
|
|
103
|
+
elif "data" in raw:
|
|
104
|
+
d = raw["data"]
|
|
105
|
+
raw = d if isinstance(d, list) else [d] if isinstance(d, dict) else []
|
|
106
|
+
else:
|
|
107
|
+
raw = [raw]
|
|
108
|
+
if not isinstance(raw, list):
|
|
109
|
+
raw = [raw]
|
|
110
|
+
for item in raw:
|
|
111
|
+
if not isinstance(item, dict):
|
|
112
|
+
continue
|
|
113
|
+
if item.get("id") == card_id:
|
|
114
|
+
title = item.get("title")
|
|
115
|
+
pricing = item.get("pricing") or {}
|
|
116
|
+
price = pricing.get("price_total_rub")
|
|
117
|
+
return title, price
|
|
118
|
+
# fallback first
|
|
119
|
+
if raw and isinstance(raw[0], dict):
|
|
120
|
+
item = raw[0]
|
|
121
|
+
title = item.get("title")
|
|
122
|
+
pricing = item.get("pricing") or {}
|
|
123
|
+
price = pricing.get("price_total_rub")
|
|
124
|
+
return title, price
|
|
125
|
+
return None, None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def has_phone(text: Optional[str]) -> bool:
|
|
129
|
+
if not text:
|
|
130
|
+
return False
|
|
131
|
+
from .pii import mask_phones
|
|
132
|
+
return mask_phones(text) != text
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def set_status(
|
|
136
|
+
card_id: str,
|
|
137
|
+
status: str,
|
|
138
|
+
note: Optional[str] = None,
|
|
139
|
+
from_file: Optional[str] = None,
|
|
140
|
+
title: Optional[str] = None,
|
|
141
|
+
price: Optional[float] = None,
|
|
142
|
+
db: Optional[Path] = None,
|
|
143
|
+
) -> dict[str, Any]:
|
|
144
|
+
if status not in STATUSES:
|
|
145
|
+
return {"ok": False, "reason": "invalid_status", "card_id": card_id}
|
|
146
|
+
snap_title, snap_price = _snapshot_from_file(card_id, from_file)
|
|
147
|
+
if title is not None:
|
|
148
|
+
snap_title = title
|
|
149
|
+
if price is not None:
|
|
150
|
+
snap_price = price
|
|
151
|
+
|
|
152
|
+
warning = None
|
|
153
|
+
if has_phone(note):
|
|
154
|
+
warning = "phone_detected_in_note"
|
|
155
|
+
|
|
156
|
+
with _lock:
|
|
157
|
+
conn = _connect(db)
|
|
158
|
+
try:
|
|
159
|
+
row = conn.execute("SELECT * FROM pipeline_items WHERE card_id=?", (card_id,)).fetchone()
|
|
160
|
+
now = _now()
|
|
161
|
+
if row is None:
|
|
162
|
+
conn.execute(
|
|
163
|
+
"INSERT INTO pipeline_items (card_id, title, price_snapshot, status, updated_at, note) VALUES (?,?,?,?,?,?)",
|
|
164
|
+
(card_id, snap_title, snap_price, status, now, note),
|
|
165
|
+
)
|
|
166
|
+
conn.execute(
|
|
167
|
+
"INSERT INTO status_history (card_id, from_status, to_status, at, note) VALUES (?,?,?,?,?)",
|
|
168
|
+
(card_id, None, status, now, note),
|
|
169
|
+
)
|
|
170
|
+
conn.commit()
|
|
171
|
+
return {"ok": True, "card_id": card_id, "status": status, "created": True,
|
|
172
|
+
"warning": warning}
|
|
173
|
+
current = row["status"]
|
|
174
|
+
if current == status:
|
|
175
|
+
# idempotent: no history
|
|
176
|
+
conn.execute(
|
|
177
|
+
"UPDATE pipeline_items SET updated_at=?, note=COALESCE(?, note), "
|
|
178
|
+
"title=COALESCE(?, title), price_snapshot=COALESCE(?, price_snapshot) WHERE card_id=?",
|
|
179
|
+
(now, note, snap_title, snap_price, card_id),
|
|
180
|
+
)
|
|
181
|
+
conn.commit()
|
|
182
|
+
return {"ok": True, "card_id": card_id, "status": status, "idempotent": True,
|
|
183
|
+
"warning": warning}
|
|
184
|
+
if status not in _ALLOWED.get(current, set()):
|
|
185
|
+
return {"ok": False, "reason": "invalid_transition", "from": current, "to": status,
|
|
186
|
+
"card_id": card_id, "warning": warning}
|
|
187
|
+
conn.execute(
|
|
188
|
+
"UPDATE pipeline_items SET status=?, updated_at=?, note=COALESCE(?, note), "
|
|
189
|
+
"title=COALESCE(?, title), price_snapshot=COALESCE(?, price_snapshot) WHERE card_id=?",
|
|
190
|
+
(status, now, note, snap_title, snap_price, card_id),
|
|
191
|
+
)
|
|
192
|
+
conn.execute(
|
|
193
|
+
"INSERT INTO status_history (card_id, from_status, to_status, at, note) VALUES (?,?,?,?,?)",
|
|
194
|
+
(card_id, current, status, now, note),
|
|
195
|
+
)
|
|
196
|
+
conn.commit()
|
|
197
|
+
return {"ok": True, "card_id": card_id, "status": status, "from": current,
|
|
198
|
+
"warning": warning}
|
|
199
|
+
finally:
|
|
200
|
+
conn.close()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def list_items(status: Optional[str] = None, db: Optional[Path] = None) -> list[dict[str, Any]]:
|
|
204
|
+
with _lock:
|
|
205
|
+
conn = _connect(db)
|
|
206
|
+
try:
|
|
207
|
+
if status:
|
|
208
|
+
rows = conn.execute("SELECT * FROM pipeline_items WHERE status=? ORDER BY updated_at DESC", (status,)).fetchall()
|
|
209
|
+
else:
|
|
210
|
+
rows = conn.execute("SELECT * FROM pipeline_items ORDER BY updated_at DESC").fetchall()
|
|
211
|
+
return [dict(r) for r in rows]
|
|
212
|
+
finally:
|
|
213
|
+
conn.close()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def show_history(card_id: str, db: Optional[Path] = None) -> dict[str, Any]:
|
|
217
|
+
with _lock:
|
|
218
|
+
conn = _connect(db)
|
|
219
|
+
try:
|
|
220
|
+
item = conn.execute("SELECT * FROM pipeline_items WHERE card_id=?", (card_id,)).fetchone()
|
|
221
|
+
hist = conn.execute("SELECT * FROM status_history WHERE card_id=? ORDER BY id", (card_id,)).fetchall()
|
|
222
|
+
return {
|
|
223
|
+
"card_id": card_id,
|
|
224
|
+
"item": dict(item) if item else None,
|
|
225
|
+
"history": [dict(r) for r in hist],
|
|
226
|
+
}
|
|
227
|
+
finally:
|
|
228
|
+
conn.close()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def clear(db: Optional[Path] = None) -> None:
|
|
232
|
+
with _lock:
|
|
233
|
+
conn = _connect(db)
|
|
234
|
+
try:
|
|
235
|
+
conn.execute("DELETE FROM pipeline_items")
|
|
236
|
+
conn.execute("DELETE FROM status_history")
|
|
237
|
+
conn.commit()
|
|
238
|
+
finally:
|
|
239
|
+
conn.close()
|