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/dedup.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""v0.5.2 (the task brief) dedup."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import math
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Optional
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
|
|
10
|
+
from .models import ListingCard
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _listing(card: Any) -> ListingCard:
|
|
14
|
+
if isinstance(card, ListingCard):
|
|
15
|
+
return card
|
|
16
|
+
if isinstance(card, dict):
|
|
17
|
+
return ListingCard(**card)
|
|
18
|
+
raise TypeError("unsupported card")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _norm_addr(addr: Optional[str]) -> str:
|
|
22
|
+
if not addr:
|
|
23
|
+
return ""
|
|
24
|
+
return " ".join(str(addr).lower().split())
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _coords(c: ListingCard):
|
|
28
|
+
g = c.geo
|
|
29
|
+
if g and g.lat is not None and g.lon is not None:
|
|
30
|
+
return (g.lat, g.lon)
|
|
31
|
+
return None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _haversine(a, b):
|
|
35
|
+
R = 6371000.0
|
|
36
|
+
phi1 = math.radians(a[0]); phi2 = math.radians(b[0])
|
|
37
|
+
dphi = math.radians(b[0]-a[0]); dl = math.radians(b[1]-a[1])
|
|
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 _first_photo_url(card: ListingCard) -> str:
|
|
43
|
+
imgs = card.images or []
|
|
44
|
+
if not imgs:
|
|
45
|
+
return ""
|
|
46
|
+
url = getattr(imgs[0], "url", None) or ""
|
|
47
|
+
url = str(url).strip().lower()
|
|
48
|
+
# normalize: strip query
|
|
49
|
+
if "?" in url:
|
|
50
|
+
url = url.split("?")[0]
|
|
51
|
+
if "#" in url:
|
|
52
|
+
url = url.split("#")[0]
|
|
53
|
+
return url
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _layout(c: ListingCard):
|
|
57
|
+
p = c.parameters
|
|
58
|
+
area = p.area_total_m2
|
|
59
|
+
rooms = p.rooms
|
|
60
|
+
floor = p.floor
|
|
61
|
+
floors = p.floors_total
|
|
62
|
+
return rooms, area, floor, floors
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _geo_or_address_match(a: ListingCard, b: ListingCard) -> tuple[bool, str]:
|
|
66
|
+
ca = _coords(a); cb = _coords(b)
|
|
67
|
+
if ca is not None and cb is not None:
|
|
68
|
+
if _haversine(ca, cb) <= 120:
|
|
69
|
+
return True, "geo_anchor"
|
|
70
|
+
# address canonical
|
|
71
|
+
aa = _norm_addr(a.building.address_raw if a.building else None)
|
|
72
|
+
ab = _norm_addr(b.building.address_raw if b.building else None)
|
|
73
|
+
if aa and aa == ab:
|
|
74
|
+
return True, "address_canonical"
|
|
75
|
+
# same complex
|
|
76
|
+
ca_c = a.building.complex if a.building else None
|
|
77
|
+
cb_c = b.building.complex if b.building else None
|
|
78
|
+
if ca_c and ca_c == cb_c:
|
|
79
|
+
return True, "address_canonical"
|
|
80
|
+
return False, ""
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _layout_match(a: ListingCard, b: ListingCard) -> bool:
|
|
84
|
+
pa = a.parameters; pb = b.parameters
|
|
85
|
+
if pa.rooms is not None and pb.rooms is not None and pa.rooms != pb.rooms:
|
|
86
|
+
return False
|
|
87
|
+
# area ±3%
|
|
88
|
+
if pa.area_total_m2 is not None and pb.area_total_m2 is not None:
|
|
89
|
+
if pa.area_total_m2 == 0:
|
|
90
|
+
return False
|
|
91
|
+
diff = abs(pa.area_total_m2 - pb.area_total_m2) / max(pa.area_total_m2, pb.area_total_m2)
|
|
92
|
+
if diff > 0.03:
|
|
93
|
+
return False
|
|
94
|
+
# floor ±1
|
|
95
|
+
if pa.floor is not None and pb.floor is not None and abs(pa.floor - pb.floor) > 1:
|
|
96
|
+
return False
|
|
97
|
+
# floors_total ±1
|
|
98
|
+
if pa.floors_total is not None and pb.floors_total is not None and abs(pa.floors_total - pb.floors_total) > 1:
|
|
99
|
+
return False
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _photo_match(a: ListingCard, b: ListingCard) -> bool:
|
|
104
|
+
ua = _first_photo_url(a)
|
|
105
|
+
ub = _first_photo_url(b)
|
|
106
|
+
return bool(ua) and ua == ub
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def signature(card: Any) -> tuple:
|
|
110
|
+
c = _listing(card)
|
|
111
|
+
ca = _coords(c)
|
|
112
|
+
geo_key = (round(ca[0], 3), round(ca[1], 3)) if ca else None
|
|
113
|
+
addr = _norm_addr(c.building.address_raw if c.building else None)
|
|
114
|
+
p = c.parameters
|
|
115
|
+
photo = _first_photo_url(c)
|
|
116
|
+
return (geo_key, addr, p.rooms, p.area_total_m2, p.floor, p.floors_total, photo)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _candidate(a: ListingCard, b: ListingCard) -> tuple[bool, list[str]]:
|
|
120
|
+
signals = []
|
|
121
|
+
ok, sig = _geo_or_address_match(a, b)
|
|
122
|
+
if ok:
|
|
123
|
+
signals.append(sig)
|
|
124
|
+
layout_ok = _layout_match(a, b)
|
|
125
|
+
if layout_ok:
|
|
126
|
+
signals.append("layout")
|
|
127
|
+
photo_ok = _photo_match(a, b)
|
|
128
|
+
if photo_ok:
|
|
129
|
+
signals.append("photo_url")
|
|
130
|
+
# candidate requires geo/address + layout
|
|
131
|
+
if ok and layout_ok:
|
|
132
|
+
return True, signals
|
|
133
|
+
return False, signals
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def dedup_cards(cards: list, strict: bool = False) -> list[dict]:
|
|
137
|
+
lst = [_listing(c) for c in cards]
|
|
138
|
+
n = len(lst)
|
|
139
|
+
# union-find
|
|
140
|
+
parent = list(range(n))
|
|
141
|
+
def find(x):
|
|
142
|
+
while parent[x] != x:
|
|
143
|
+
parent[x] = parent[parent[x]]
|
|
144
|
+
x = parent[x]
|
|
145
|
+
return x
|
|
146
|
+
def union(x, y):
|
|
147
|
+
rx, ry = find(x), find(y)
|
|
148
|
+
if rx != ry:
|
|
149
|
+
parent[rx] = ry
|
|
150
|
+
|
|
151
|
+
pair_signals: dict[tuple[int,int], list[str]] = {}
|
|
152
|
+
for i in range(n):
|
|
153
|
+
for j in range(i+1, n):
|
|
154
|
+
cand, sigs = _candidate(lst[i], lst[j])
|
|
155
|
+
if cand:
|
|
156
|
+
if strict and len(sigs) < 2:
|
|
157
|
+
continue
|
|
158
|
+
union(i, j)
|
|
159
|
+
pair_signals[(i, j)] = sigs
|
|
160
|
+
|
|
161
|
+
# build groups
|
|
162
|
+
groups_map: dict[int, list[int]] = {}
|
|
163
|
+
for i in range(n):
|
|
164
|
+
groups_map.setdefault(find(i), []).append(i)
|
|
165
|
+
groups = []
|
|
166
|
+
for root, idxs in groups_map.items():
|
|
167
|
+
if len(idxs) < 2:
|
|
168
|
+
continue
|
|
169
|
+
# collect signals across pairs in group
|
|
170
|
+
sigs_set = set()
|
|
171
|
+
for (i, j), s in pair_signals.items():
|
|
172
|
+
if i in idxs and j in idxs:
|
|
173
|
+
sigs_set.update(s)
|
|
174
|
+
prices = [c.pricing.price_total_rub for c in [lst[i] for i in idxs] if c.pricing.price_total_rub is not None]
|
|
175
|
+
price_range = [min(prices), max(prices)] if prices else None
|
|
176
|
+
groups.append({
|
|
177
|
+
"cards": [lst[i].id for i in idxs],
|
|
178
|
+
"signals": sorted(sigs_set),
|
|
179
|
+
"price_range": price_range,
|
|
180
|
+
"reason": "кандидат-дубль: " + ", ".join(sorted(sigs_set)),
|
|
181
|
+
})
|
|
182
|
+
return groups
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _created_date(card):
|
|
186
|
+
if isinstance(card, dict):
|
|
187
|
+
v = card.get("created_at") or card.get("created") or (card.get("seller") or {}).get("created_at")
|
|
188
|
+
else:
|
|
189
|
+
v = getattr(card, "created_at", None)
|
|
190
|
+
if v is None and getattr(card, "seller", None) is not None:
|
|
191
|
+
v = getattr(card.seller, "created_at", None)
|
|
192
|
+
if isinstance(v, str):
|
|
193
|
+
try:
|
|
194
|
+
return datetime.fromisoformat(v.replace("Z", "+00:00"))
|
|
195
|
+
except ValueError:
|
|
196
|
+
return None
|
|
197
|
+
return None
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def detect_relist(old_cards, new_cards, window_days: int = 45) -> list[dict]:
|
|
201
|
+
old_raw = list(old_cards)
|
|
202
|
+
new_raw = list(new_cards)
|
|
203
|
+
old = [_listing(c) for c in old_raw]
|
|
204
|
+
new = [_listing(c) for c in new_raw]
|
|
205
|
+
events = []
|
|
206
|
+
for oi, oc in enumerate(old):
|
|
207
|
+
for ni, nc in enumerate(new):
|
|
208
|
+
if nc.id == oc.id:
|
|
209
|
+
continue
|
|
210
|
+
cand, sigs = _candidate(oc, nc)
|
|
211
|
+
if not cand:
|
|
212
|
+
continue
|
|
213
|
+
old_date = _created_date(old_raw[oi])
|
|
214
|
+
new_date = _created_date(new_raw[ni])
|
|
215
|
+
if old_date is not None and new_date is not None:
|
|
216
|
+
if (new_date - old_date).days > window_days or (new_date - old_date).days < 0:
|
|
217
|
+
continue
|
|
218
|
+
events.append({"type": "relist_suspect", "old_id": oc.id, "new_id": nc.id,
|
|
219
|
+
"reason": "подпись совпадает, окно {} дней".format(window_days)})
|
|
220
|
+
seen = set()
|
|
221
|
+
out = []
|
|
222
|
+
for e in events:
|
|
223
|
+
key = (e["old_id"], e["new_id"])
|
|
224
|
+
if key in seen:
|
|
225
|
+
continue
|
|
226
|
+
seen.add(key)
|
|
227
|
+
out.append(e)
|
|
228
|
+
return out
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def detect_multi_listing(cards) -> list[dict]:
|
|
232
|
+
lst = [_listing(c) for c in cards]
|
|
233
|
+
# group by signature
|
|
234
|
+
groups: dict[Any, list] = {}
|
|
235
|
+
for c in lst:
|
|
236
|
+
groups.setdefault(signature(c), []).append(c)
|
|
237
|
+
events = []
|
|
238
|
+
for sig, grp in groups.items():
|
|
239
|
+
if len(grp) >= 2:
|
|
240
|
+
events.append({"type": "multi_listing", "cards": [c.id for c in grp],
|
|
241
|
+
"reason": "несколько id с одинаковой подписью"})
|
|
242
|
+
return events
|
nedviga/developer.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
"""v0.5.10 (the task brief) developer dossier."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import hashlib
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Optional
|
|
11
|
+
|
|
12
|
+
from .mosdata import _request
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class _SafeError(Exception):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _cache_dir() -> Path:
|
|
20
|
+
base = Path(os.environ.get("NEDVIGA_STATE_DIR", str(Path.home() / ".local" / "share" / "nedviga")))
|
|
21
|
+
d = base / "developer_cache"
|
|
22
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
return d
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _cache_key(q: str) -> str:
|
|
27
|
+
return hashlib.sha256(q.encode()).hexdigest()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _cache_get(key: str) -> Optional[dict]:
|
|
31
|
+
p = _cache_dir() / f"{key}.json"
|
|
32
|
+
if not p.exists():
|
|
33
|
+
return None
|
|
34
|
+
try:
|
|
35
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
36
|
+
except Exception:
|
|
37
|
+
return None
|
|
38
|
+
ts = data.get("_ts")
|
|
39
|
+
if ts:
|
|
40
|
+
dt = datetime.fromisoformat(ts)
|
|
41
|
+
if datetime.now(timezone.utc) - dt > timedelta(days=30):
|
|
42
|
+
return None
|
|
43
|
+
return data.get("payload")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _cache_set(key: str, payload: dict) -> None:
|
|
47
|
+
p = _cache_dir() / f"{key}.json"
|
|
48
|
+
p.write_text(json.dumps({"_ts": datetime.now(timezone.utc).isoformat(), "payload": payload},
|
|
49
|
+
ensure_ascii=False), encoding="utf-8")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _variants(name: str) -> list[str]:
|
|
53
|
+
return list(dict.fromkeys([name, name.capitalize(), name.title(), name.lower()]))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _extract_inns(text: str) -> list[tuple[str, str]]:
|
|
57
|
+
# returns list of (inn, context)
|
|
58
|
+
res = []
|
|
59
|
+
for m in re.finditer(r"(?<!\d)(\d{10}|\d{12})(?!\d)", text):
|
|
60
|
+
inn = m.group(1)
|
|
61
|
+
start = max(0, m.start() - 40)
|
|
62
|
+
end = min(len(text), m.end() + 40)
|
|
63
|
+
ctx = text[start:end].strip()
|
|
64
|
+
res.append((inn, ctx))
|
|
65
|
+
return res
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _from_file(path: str) -> dict:
|
|
69
|
+
raw = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
70
|
+
if isinstance(raw, list):
|
|
71
|
+
raw = raw[0]
|
|
72
|
+
if isinstance(raw, dict) and "cards" in raw and isinstance(raw["cards"], list):
|
|
73
|
+
raw = raw["cards"][0]
|
|
74
|
+
text = (raw.get("description_text") or "") + " " + (raw.get("title") or "")
|
|
75
|
+
# also "застройщик: ..." extraction
|
|
76
|
+
cands = []
|
|
77
|
+
for inn, ctx in _extract_inns(text):
|
|
78
|
+
cands.append({"inn": inn, "context": ctx})
|
|
79
|
+
# застройщик name
|
|
80
|
+
m = re.search(r"застройщик[:\s]+([^\n,]+)", text, re.IGNORECASE)
|
|
81
|
+
if m:
|
|
82
|
+
cands.append({"name_candidate": m.group(1).strip(), "context": m.group(0)})
|
|
83
|
+
return {"candidates": cands}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def search_by_inn(inn: str) -> dict:
|
|
87
|
+
key = "inn:" + inn
|
|
88
|
+
cached = _cache_get(key)
|
|
89
|
+
if cached is not None:
|
|
90
|
+
cached["cache_hit"] = True
|
|
91
|
+
return cached
|
|
92
|
+
try:
|
|
93
|
+
# exact INN search via filter
|
|
94
|
+
data = _request("GET", "datasets/2114/rows", params={"$filter": f"INN eq '{inn}'", "$top": 10})
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
raise _SafeError(str(exc))
|
|
97
|
+
result = {"status": "ok", "inn": inn, "items": data.get("value", []) if isinstance(data, dict) else data, "cache_hit": False}
|
|
98
|
+
_cache_set(key, result)
|
|
99
|
+
return result
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def search_by_name(name: str) -> dict:
|
|
103
|
+
key = "name:" + name
|
|
104
|
+
cached = _cache_get(key)
|
|
105
|
+
if cached is not None:
|
|
106
|
+
cached["cache_hit"] = True
|
|
107
|
+
return cached
|
|
108
|
+
all_items = []
|
|
109
|
+
for v in _variants(name):
|
|
110
|
+
try:
|
|
111
|
+
data = _request("GET", "datasets/2114/rows", params={"$filter": f"substringof('{v}', FullName)", "$top": 10})
|
|
112
|
+
except Exception as exc:
|
|
113
|
+
raise _SafeError(str(exc))
|
|
114
|
+
items = data.get("value", []) if isinstance(data, dict) else (data if isinstance(data, list) else [])
|
|
115
|
+
all_items.extend(items)
|
|
116
|
+
# dedupe by INN
|
|
117
|
+
seen = set()
|
|
118
|
+
uniq = []
|
|
119
|
+
for it in all_items:
|
|
120
|
+
inn = _cells(it).get("INN")
|
|
121
|
+
if inn in seen:
|
|
122
|
+
continue
|
|
123
|
+
if inn:
|
|
124
|
+
seen.add(inn)
|
|
125
|
+
uniq.append(it)
|
|
126
|
+
result = {"status": "ok", "name": name, "items": uniq, "cache_hit": False}
|
|
127
|
+
_cache_set(key, result)
|
|
128
|
+
return result
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def litcheck_developer(name_or_inn: str) -> dict:
|
|
132
|
+
from .litprecheck import litcheck
|
|
133
|
+
# query by name/inn
|
|
134
|
+
res = litcheck(address=None, developer=name_or_inn)
|
|
135
|
+
return res
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _cells(item) -> dict:
|
|
139
|
+
"""Строка 2114 приходит как {global_id, Number, Cells:{...}} — поля внутри Cells.
|
|
140
|
+
Живой контроль v0.5.10 поймал рендер/дедуп, читавшие верхний уровень (пустой вывод)."""
|
|
141
|
+
if not isinstance(item, dict):
|
|
142
|
+
return {}
|
|
143
|
+
return item.get("Cells") or item
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def render_text(result: dict, lit: Optional[dict] = None) -> str:
|
|
147
|
+
lines = []
|
|
148
|
+
items = result.get("items", [])
|
|
149
|
+
if not items:
|
|
150
|
+
lines.append("реестр: совпадений нет")
|
|
151
|
+
for it in items:
|
|
152
|
+
if not isinstance(it, dict):
|
|
153
|
+
continue
|
|
154
|
+
c = _cells(it)
|
|
155
|
+
name = c.get("ShortName") or c.get("FullName") or ""
|
|
156
|
+
inn = c.get("INN") or ""
|
|
157
|
+
lines.append(f"🏗 Застройщик: {name} (ИНН {inn}) (реестр 2114)")
|
|
158
|
+
if c.get("LinkToProjectDeclaration"):
|
|
159
|
+
lines.append(f"декларация: {c.get('LinkToProjectDeclaration')}")
|
|
160
|
+
if c.get("CooperationWithBanks"):
|
|
161
|
+
lines.append(f"банки: {c.get('CooperationWithBanks')}")
|
|
162
|
+
if c.get("PermitNum"):
|
|
163
|
+
lines.append(f"разрешение: {c.get('PermitNum')}")
|
|
164
|
+
if lit is not None:
|
|
165
|
+
if lit.get("status") != "ok":
|
|
166
|
+
lines.append(f"суды: unavailable — {lit.get('reason')}")
|
|
167
|
+
else:
|
|
168
|
+
lines.append(f"суды: {lit.get('count')} записей")
|
|
169
|
+
return "\n".join(lines)
|
nedviga/digest.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""v0.4.8 (the task brief) F-14b: family digest."""
|
|
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 . import pipeline
|
|
11
|
+
|
|
12
|
+
STATE_DIR = lambda: Path(os.environ.get("NEDVIGA_STATE_DIR", str(Path.home() / ".local" / "share" / "nedviga")))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _cutoff(days: int) -> datetime:
|
|
16
|
+
return datetime.now(timezone.utc) - timedelta(days=days)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _parse_dt(s: Any) -> Optional[datetime]:
|
|
20
|
+
if not s:
|
|
21
|
+
return None
|
|
22
|
+
try:
|
|
23
|
+
dt = datetime.fromisoformat(str(s).replace("Z", "+00:00"))
|
|
24
|
+
if dt.tzinfo is None:
|
|
25
|
+
dt = dt.replace(tzinfo=timezone.utc)
|
|
26
|
+
return dt
|
|
27
|
+
except ValueError:
|
|
28
|
+
return None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _pipeline_items(db=None) -> list[dict]:
|
|
32
|
+
return pipeline.list_items(db=db)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _status_history(db=None, days: int = 7) -> list[dict]:
|
|
36
|
+
conn = pipeline._connect(db)
|
|
37
|
+
try:
|
|
38
|
+
rows = conn.execute("SELECT * FROM status_history ORDER BY id").fetchall()
|
|
39
|
+
finally:
|
|
40
|
+
conn.close()
|
|
41
|
+
cutoff = _cutoff(days)
|
|
42
|
+
out = []
|
|
43
|
+
for r in rows:
|
|
44
|
+
d = dict(r)
|
|
45
|
+
dt = _parse_dt(d.get("at"))
|
|
46
|
+
if dt is None or dt >= cutoff:
|
|
47
|
+
out.append(d)
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _watch_history(db=None, days: int = 7) -> list[dict]:
|
|
52
|
+
conn = pipeline._connect(db)
|
|
53
|
+
try:
|
|
54
|
+
rows = conn.execute("SELECT * FROM watch_history ORDER BY id").fetchall()
|
|
55
|
+
finally:
|
|
56
|
+
conn.close()
|
|
57
|
+
cutoff = _cutoff(days)
|
|
58
|
+
out = []
|
|
59
|
+
for r in rows:
|
|
60
|
+
d = dict(r)
|
|
61
|
+
dt = _parse_dt(d.get("at"))
|
|
62
|
+
if dt is None or dt >= cutoff:
|
|
63
|
+
out.append(d)
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _fmt_money(v) -> str:
|
|
68
|
+
return f"{float(v):,.0f}".replace(",", " ")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _price_reductions(db=None, days: int = 7) -> list[dict]:
|
|
72
|
+
# group watch_history by card, find drops between consecutive snapshots
|
|
73
|
+
hist = _watch_history(db=db, days=days)
|
|
74
|
+
by_card: dict[str, list[dict]] = {}
|
|
75
|
+
for h in hist:
|
|
76
|
+
by_card.setdefault(h["card_id"], []).append(h)
|
|
77
|
+
res = []
|
|
78
|
+
for cid, items in by_card.items():
|
|
79
|
+
items.sort(key=lambda x: x.get("at") or "")
|
|
80
|
+
# v0.4.8 контроль качества: дайджест = ЧИСТАЯ дельта периода (первый снимок ->
|
|
81
|
+
# последний), повторные check одного и того же снижения не плодят строк.
|
|
82
|
+
prices = [it.get("price") for it in items if it.get("price") is not None]
|
|
83
|
+
if len(prices) >= 2 and prices[-1] < prices[0]:
|
|
84
|
+
res.append({"card_id": cid, "amount": prices[0] - prices[-1], "price": prices[-1]})
|
|
85
|
+
return res
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _new_shortlist(db=None, days: int = 7) -> list[dict]:
|
|
89
|
+
hist = _status_history(db=db, days=days)
|
|
90
|
+
seen = set()
|
|
91
|
+
out = []
|
|
92
|
+
for h in hist:
|
|
93
|
+
if h.get("to_status") == "shortlist":
|
|
94
|
+
cid = h["card_id"]
|
|
95
|
+
if cid not in seen:
|
|
96
|
+
seen.add(cid)
|
|
97
|
+
out.append({"card_id": cid, "at": h.get("at")})
|
|
98
|
+
return out
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _removed_sold(db=None, days: int = 7) -> list[dict]:
|
|
102
|
+
# pipeline terminal + watch status sold/removed/disappeared
|
|
103
|
+
res = []
|
|
104
|
+
seen = set()
|
|
105
|
+
for h in _status_history(db=db, days=days):
|
|
106
|
+
if h.get("to_status") in {"rejected", "bought"}:
|
|
107
|
+
cid = h["card_id"]
|
|
108
|
+
if cid not in seen:
|
|
109
|
+
seen.add(cid)
|
|
110
|
+
res.append({"card_id": cid, "status": h.get("to_status")})
|
|
111
|
+
# watch
|
|
112
|
+
hist = _watch_history(db=db, days=days)
|
|
113
|
+
for h in hist:
|
|
114
|
+
st = (h.get("status") or "").lower()
|
|
115
|
+
if st in {"sold", "removed", "disappeared", "inactive"}:
|
|
116
|
+
cid = h["card_id"]
|
|
117
|
+
if cid not in seen:
|
|
118
|
+
seen.add(cid)
|
|
119
|
+
res.append({"card_id": cid, "status": st})
|
|
120
|
+
return res
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _pipeline_moves(db=None, days: int = 7) -> list[dict]:
|
|
124
|
+
return _status_history(db=db, days=days)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def build_digest(days: int = 7, db=None) -> dict[str, Any]:
|
|
128
|
+
reductions = _price_reductions(db=db, days=days)
|
|
129
|
+
new_sl = _new_shortlist(db=db, days=days)
|
|
130
|
+
removed = _removed_sold(db=db, days=days)
|
|
131
|
+
moves = _pipeline_moves(db=db, days=days)
|
|
132
|
+
return {
|
|
133
|
+
"days": days,
|
|
134
|
+
"reductions": reductions,
|
|
135
|
+
"new_shortlist": new_sl,
|
|
136
|
+
"removed": removed,
|
|
137
|
+
"moves": moves,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def render_telegram(digest: dict[str, Any]) -> str:
|
|
142
|
+
days = digest["days"]
|
|
143
|
+
lines: list[str] = []
|
|
144
|
+
if digest["reductions"]:
|
|
145
|
+
lines.append("🔻Снижения:")
|
|
146
|
+
for r in digest["reductions"]:
|
|
147
|
+
lines.append(f"• {r['card_id']}: -{_fmt_money(r['amount'])} ₽ (теперь {_fmt_money(r['price'])})")
|
|
148
|
+
lines.append("")
|
|
149
|
+
if digest["new_shortlist"]:
|
|
150
|
+
lines.append("✨Новое в шорт-листе:")
|
|
151
|
+
for r in digest["new_shortlist"]:
|
|
152
|
+
lines.append(f"• {r['card_id']}")
|
|
153
|
+
lines.append("")
|
|
154
|
+
if digest["removed"]:
|
|
155
|
+
lines.append("❌Снято/продано:")
|
|
156
|
+
for r in digest["removed"]:
|
|
157
|
+
lines.append(f"• {r['card_id']} ({r['status']})")
|
|
158
|
+
lines.append("")
|
|
159
|
+
if digest["moves"]:
|
|
160
|
+
lines.append("📋Движения воронки:")
|
|
161
|
+
for m in digest["moves"]:
|
|
162
|
+
lines.append(f"• {m['card_id']}: {m.get('from_status') or 'старт'} → {m.get('to_status')}")
|
|
163
|
+
lines.append("")
|
|
164
|
+
if not (digest["reductions"] or digest["new_shortlist"] or digest["removed"] or digest["moves"]):
|
|
165
|
+
lines.append(f"за {days} дней без изменений")
|
|
166
|
+
return "\n".join(lines).strip()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def render_md(digest: dict[str, Any]) -> str:
|
|
170
|
+
days = digest["days"]
|
|
171
|
+
from ._wm import render_line as _wl
|
|
172
|
+
try:
|
|
173
|
+
from . import __version__ as _ver
|
|
174
|
+
except Exception:
|
|
175
|
+
_ver = "0"
|
|
176
|
+
lines: list[str] = ["# " + _wl("digest_header", _ver, {"days": days}), ""]
|
|
177
|
+
if digest["reductions"]:
|
|
178
|
+
lines.append("## 🔻 Снижения")
|
|
179
|
+
for r in digest["reductions"]:
|
|
180
|
+
lines.append(f"- {r['card_id']}: -{_fmt_money(r['amount'])} ₽")
|
|
181
|
+
lines.append("")
|
|
182
|
+
if digest["new_shortlist"]:
|
|
183
|
+
lines.append("## ✨ Новое в шорт-листе")
|
|
184
|
+
for r in digest["new_shortlist"]:
|
|
185
|
+
lines.append(f"- {r['card_id']}")
|
|
186
|
+
lines.append("")
|
|
187
|
+
if digest["removed"]:
|
|
188
|
+
lines.append("## ❌ Снято/продано")
|
|
189
|
+
for r in digest["removed"]:
|
|
190
|
+
lines.append(f"- {r['card_id']} ({r['status']})")
|
|
191
|
+
lines.append("")
|
|
192
|
+
if digest["moves"]:
|
|
193
|
+
lines.append("## 📋 Движения воронки")
|
|
194
|
+
for m in digest["moves"]:
|
|
195
|
+
lines.append(f"- {m['card_id']}: {m.get('from_status') or 'старт'} → {m.get('to_status')}")
|
|
196
|
+
lines.append("")
|
|
197
|
+
if not (digest["reductions"] or digest["new_shortlist"] or digest["removed"] or digest["moves"]):
|
|
198
|
+
lines.append(f"за {days} дней без изменений")
|
|
199
|
+
return "\n".join(lines).strip()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def render(digest: dict[str, Any], fmt: str = "telegram") -> str:
|
|
203
|
+
if fmt == "md":
|
|
204
|
+
return render_md(digest)
|
|
205
|
+
return render_telegram(digest)
|