nedviga-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. nedviga/__init__.py +46 -0
  2. nedviga/__main__.py +6 -0
  3. nedviga/_wm.py +93 -0
  4. nedviga/adaptive_selectors.py +100 -0
  5. nedviga/auditlog.py +77 -0
  6. nedviga/auto.py +88 -0
  7. nedviga/bargain.py +183 -0
  8. nedviga/browser_native.py +242 -0
  9. nedviga/callsheet.py +63 -0
  10. nedviga/chains.py +83 -0
  11. nedviga/checklist.py +119 -0
  12. nedviga/cian_card.py +1371 -0
  13. nedviga/cian_live.py +812 -0
  14. nedviga/cli.py +3118 -0
  15. nedviga/compare.py +204 -0
  16. nedviga/data/legal_flags.json +146 -0
  17. nedviga/data/release_pubkey.asc +22 -0
  18. nedviga/deck.py +115 -0
  19. nedviga/dedup.py +242 -0
  20. nedviga/developer.py +169 -0
  21. nedviga/digest.py +205 -0
  22. nedviga/doctor.py +128 -0
  23. nedviga/dossier.py +303 -0
  24. nedviga/eco.py +81 -0
  25. nedviga/errors.py +22 -0
  26. nedviga/export_html.py +280 -0
  27. nedviga/export_pdf.py +96 -0
  28. nedviga/family_card.py +670 -0
  29. nedviga/feedback.py +79 -0
  30. nedviga/geo_routing.py +214 -0
  31. nedviga/graph.py +149 -0
  32. nedviga/legal_triage.py +105 -0
  33. nedviga/litprecheck.py +116 -0
  34. nedviga/market.py +102 -0
  35. nedviga/mcp_server.py +195 -0
  36. nedviga/metro_directory.json +9836 -0
  37. nedviga/metro_vestibules.py +134 -0
  38. nedviga/models.py +164 -0
  39. nedviga/mosdata.py +95 -0
  40. nedviga/net_guard.py +997 -0
  41. nedviga/noise.py +156 -0
  42. nedviga/ops.py +94 -0
  43. nedviga/outcomes.py +35 -0
  44. nedviga/pii.py +85 -0
  45. nedviga/pipeline.py +239 -0
  46. nedviga/poi.py +180 -0
  47. nedviga/price_guard.py +73 -0
  48. nedviga/profiles.py +131 -0
  49. nedviga/projects.py +112 -0
  50. nedviga/purge.py +116 -0
  51. nedviga/py.typed +0 -0
  52. nedviga/risk.py +127 -0
  53. nedviga/roles.py +124 -0
  54. nedviga/routing_cache.py +72 -0
  55. nedviga/rush.py +139 -0
  56. nedviga/scorefit.py +127 -0
  57. nedviga/sources.py +55 -0
  58. nedviga/sources_avito.py +138 -0
  59. nedviga/sources_domclick.py +122 -0
  60. nedviga/stale.py +83 -0
  61. nedviga/textsearch.py +141 -0
  62. nedviga/tour.py +180 -0
  63. nedviga/watch.py +234 -0
  64. nedviga/wizard.py +106 -0
  65. nedviga_cli-0.1.0.dist-info/METADATA +130 -0
  66. nedviga_cli-0.1.0.dist-info/RECORD +71 -0
  67. nedviga_cli-0.1.0.dist-info/WHEEL +5 -0
  68. nedviga_cli-0.1.0.dist-info/entry_points.txt +2 -0
  69. nedviga_cli-0.1.0.dist-info/licenses/LICENSE +661 -0
  70. nedviga_cli-0.1.0.dist-info/licenses/NOTICE +1 -0
  71. nedviga_cli-0.1.0.dist-info/top_level.txt +1 -0
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)