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/compare.py ADDED
@@ -0,0 +1,204 @@
1
+ """v0.4.9 (the task brief) F-12: comparison matrix."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Any, Iterable, Optional
6
+
7
+ from .models import ListingCard
8
+
9
+
10
+ def _listing(card: Any) -> ListingCard:
11
+ if isinstance(card, ListingCard):
12
+ return card
13
+ if isinstance(card, dict):
14
+ return ListingCard(**card)
15
+ raise TypeError("unsupported card")
16
+
17
+
18
+
19
+
20
+ from .family_card import _human as _decoat # v0.4.9 контроль качества: тот же контракт очистки
21
+
22
+
23
+ def _md_cell(s):
24
+ return _decoat(str(s)).replace("|", "\|")
25
+
26
+
27
+ def _html_cell(s):
28
+ from html import escape
29
+ return escape(_decoat(str(s)))
30
+
31
+
32
+ def load_cards(path: str, ids: Optional[str] = None) -> list[ListingCard]:
33
+ raw = __import__("json").loads(Path(path).read_text(encoding="utf-8"))
34
+ if isinstance(raw, dict):
35
+ if "cards" in raw and isinstance(raw["cards"], list):
36
+ raw = raw["cards"]
37
+ elif "data" in raw:
38
+ d = raw["data"]
39
+ raw = d if isinstance(d, list) else ([d] if isinstance(d, dict) else [])
40
+ else:
41
+ raw = [raw]
42
+ if not isinstance(raw, list):
43
+ raw = [raw]
44
+ cards = [_listing(x) for x in raw if isinstance(x, (dict, ListingCard))]
45
+ if ids:
46
+ wanted = {x.strip() for x in ids.split(",") if x.strip()}
47
+ cards = [c for c in cards if c.id in wanted]
48
+ return cards
49
+
50
+
51
+ def _ppm2(c: ListingCard) -> float:
52
+ v = c.pricing.price_per_m2_rub
53
+ return float(v) if v is not None else float("inf")
54
+
55
+
56
+ def _walk(c: ListingCard) -> str:
57
+ w = c.geo.honest_walk_route
58
+ if w and w.walk_duration_minutes is not None:
59
+ return f"{int(w.walk_duration_minutes)} мин ({w.walk_distance_meters} м)"
60
+ claimed = c.geo.cian_claimed_metro.claimed_time_minutes if c.geo.cian_claimed_metro else None
61
+ if claimed is not None:
62
+ return f"заявлено {claimed} мин"
63
+ return "нет данных"
64
+
65
+
66
+ def _missing(c: ListingCard) -> str:
67
+ mf = getattr(c, "missing_fields", None) or []
68
+ return ", ".join(mf) if mf else ""
69
+
70
+
71
+ def _risk_str(c: ListingCard) -> str:
72
+ try:
73
+ from .risk import score_card
74
+ r = score_card(c)
75
+ if r.get("risk_score", 0) >= 0.5:
76
+ return "⚠ risk"
77
+ except Exception:
78
+ pass
79
+ return ""
80
+
81
+
82
+ def sort_cards(cards: list[ListingCard]) -> list[ListingCard]:
83
+ return sorted(cards, key=_ppm2)
84
+
85
+
86
+ def _best_worst(cards: list[ListingCard]) -> tuple[Optional[str], Optional[str]]:
87
+ if not cards:
88
+ return None, None
89
+ s = sort_cards(cards)
90
+ return s[0].id, s[-1].id
91
+
92
+
93
+ def render_markdown(cards: list[ListingCard]) -> str:
94
+ if not cards:
95
+ return "empty_input"
96
+ cards = sort_cards(cards)
97
+ best, worst = _best_worst(cards)
98
+ lines = ["| # | Адрес/ID | Цена | ₽/м² | Район | Этаж | Пешком | Площадь | Серия/год | ⚠ |",
99
+ "|---|---|---|---|---|---|---|---|---|---|"]
100
+ for i, c in enumerate(cards, 1):
101
+ mark = ""
102
+ if c.id == best:
103
+ mark = " (best)"
104
+ if c.id == worst:
105
+ mark = " (worst)"
106
+ price = f"{c.pricing.price_total_rub:,.0f}".replace(",", " ") if c.pricing.price_total_rub else "—"
107
+ ppm2 = f"{c.pricing.price_per_m2_rub:,.0f}".replace(",", " ") if c.pricing.price_per_m2_rub else "—"
108
+ floor = f"{c.parameters.floor}/{c.parameters.floors_total}" if c.parameters.floor else "—"
109
+ series = c.building.house_series or ""
110
+ year = c.building.year_built or ""
111
+ series_year = f"{series} {year}".strip() or "—"
112
+ missing = _missing(c)
113
+ risk = _risk_str(c)
114
+ triage = _triage_str(c)
115
+ from collections import Counter
116
+ # compute dup inns once outside? do here cheap
117
+ dev_marker = ""
118
+ try:
119
+ inns = Counter(_developer_inn(x) for x in cards)
120
+ if _developer_inn(c) and inns[_developer_inn(c)] > 1:
121
+ dev_marker = "≈застр. " + _developer_inn(c)
122
+ except Exception:
123
+ pass
124
+ warn = "; ".join(x for x in (missing, risk, triage, dev_marker) if x) or "—"
125
+ lines.append(
126
+ f"| {i} | {_md_cell(c.title or c.id)}{mark} | {price} | {ppm2} | {c.geo.district or '—'} | {floor} | {_walk(c)} | {c.parameters.area_total_m2 or '—'} | {_md_cell(series_year)} | {_md_cell(warn)} |"
127
+ )
128
+ return "\n".join(lines)
129
+
130
+
131
+ def render_telegram(cards: list[ListingCard]) -> str:
132
+ if not cards:
133
+ return "empty_input"
134
+ cards = sort_cards(cards)
135
+ best, worst = _best_worst(cards)
136
+ lines = []
137
+ for c in cards:
138
+ mark = ""
139
+ if c.id == best:
140
+ mark = " ⭐"
141
+ if c.id == worst:
142
+ mark = " ⬇"
143
+ ppm2 = f"{c.pricing.price_per_m2_rub:,.0f}".replace(",", " ") if c.pricing.price_per_m2_rub else "—"
144
+ price = f"{c.pricing.price_total_rub:,.0f}".replace(",", " ") if c.pricing.price_total_rub else "—"
145
+ lines.append(f"• {_decoat(c.title or c.id)}{mark}: {price} ₽, {ppm2} ₽/м², {c.geo.district or '—'}, {_walk(c)}")
146
+ mf = _missing(c)
147
+ if mf:
148
+ lines.append(f" ⚠ Не хватает данных: {mf}")
149
+ rs = _risk_str(c)
150
+ if rs:
151
+ lines.append(f" {rs}")
152
+ return "\n".join(lines)
153
+
154
+
155
+ def render_html(cards: list[ListingCard]) -> str:
156
+ if not cards:
157
+ return "empty_input"
158
+ cards = sort_cards(cards)
159
+ best, worst = _best_worst(cards)
160
+ rows = ""
161
+ for c in cards:
162
+ mark = ""
163
+ if c.id == best:
164
+ mark = " (best)"
165
+ if c.id == worst:
166
+ mark = " (worst)"
167
+ price = f"{c.pricing.price_total_rub:,.0f}".replace(",", " ") if c.pricing.price_total_rub else "—"
168
+ ppm2 = f"{c.pricing.price_per_m2_rub:,.0f}".replace(",", " ") if c.pricing.price_per_m2_rub else "—"
169
+ rows += f"<tr><td>{_html_cell(c.title or c.id)}{mark}</td><td>{price}</td><td>{ppm2}</td><td>{_html_cell(c.geo.district if c.geo else '')}</td><td>{c.parameters.floor or ''}/{c.parameters.floors_total or ''}</td><td>{_walk(c)}</td></tr>\n"
170
+ return f"<!DOCTYPE html><html><body><table>{rows}</table></body></html>"
171
+
172
+
173
+ def render(cards: list[ListingCard], fmt: str = "md") -> str:
174
+ if not cards:
175
+ return "empty_input"
176
+ if fmt == "telegram":
177
+ return render_telegram(cards)
178
+ if fmt == "html":
179
+ return render_html(cards)
180
+ return render_markdown(cards)
181
+
182
+
183
+ def _developer_inn(c: ListingCard) -> str:
184
+ try:
185
+ import re
186
+ text = (c.description_text or "") + " " + (c.title or "")
187
+ m = re.search(r"(?<!\d)(\d{10}|\d{12})(?!\d)", text)
188
+ if m and ("застройщик" in text.lower() or "девелопер" in text.lower()):
189
+ return m.group(1)
190
+ except Exception:
191
+ pass
192
+ return ""
193
+
194
+
195
+ def _triage_str(c: ListingCard) -> str:
196
+ try:
197
+ from .legal_triage import triage
198
+ t = triage(c)
199
+ codes = [f["code"] for f in t.get("flags", [])]
200
+ if codes:
201
+ return "⚠ " + ", ".join(codes)
202
+ except Exception:
203
+ pass
204
+ return ""
@@ -0,0 +1,146 @@
1
+ {
2
+ "version": "1.0.1",
3
+ "approved_by": "colleague",
4
+ "source": "internal data-provenance review (v1.0.0) + litcheck v1.0.1",
5
+ "disclaimer": "Выявленные маркеры не являются юридическим заключением или правовой консультацией. Сведения носят предварительный ориентировочный характер и требуют проверки документов профильным юристом.",
6
+ "flags": [
7
+ {
8
+ "code": "SHARE_IN_RIGHT",
9
+ "severity": "amber",
10
+ "title": "Маркер долевой собственности",
11
+ "markers": [
12
+ "доля",
13
+ "долей",
14
+ "доле"
15
+ ],
16
+ "check": "Требуется установить размер доли, порядок пользования объектом и соблюдение преимущественного права покупки сособственниками (ст. 250 ГК РФ)."
17
+ },
18
+ {
19
+ "code": "APARTMENTS_NON_RESIDENTIAL",
20
+ "severity": "amber",
21
+ "title": "Маркер нежилого фонда (апартаменты)",
22
+ "markers": [
23
+ "апартамент"
24
+ ],
25
+ "check": "Требуется проверить правовой статус помещения (нежилое помещение, отсутствие статуса жилого фонда, особенности налогообложения, тарифов ЖКУ и невозможности постоянной регистрации по месту жительства)."
26
+ },
27
+ {
28
+ "code": "BANKRUPTCY_AUCTION_MARKERS",
29
+ "severity": "red",
30
+ "title": "Маркер реализации на торгах / банкротства",
31
+ "markers": [
32
+ "торги",
33
+ "торгов",
34
+ "банкрот",
35
+ "конкурс",
36
+ "конкурсн"
37
+ ],
38
+ "check": "Требуется проверить основания отчуждения, статус процедуры банкротства продавца/правопредшественника по картотеке ЕФРСБ и kad.arbitr.ru, полномочия финансового управляющего и судебные акты."
39
+ },
40
+ {
41
+ "code": "ENCUMBRANCE_RESTRICTION",
42
+ "severity": "red",
43
+ "title": "Маркер обременения / ограничения прав",
44
+ "markers": [
45
+ "обременен",
46
+ "арест",
47
+ "ипотек",
48
+ "залог"
49
+ ],
50
+ "check": "Требуется истребовать актуальную выписку из ЕГРН от собственника для проверки действующих записей об ипотеке, арестах, запретах на совершение регистрационных действий и судебных спорах."
51
+ },
52
+ {
53
+ "code": "RECENT_INHERITANCE",
54
+ "severity": "conditional",
55
+ "title": "Маркер основания приобретения права по наследству",
56
+ "markers": [
57
+ "наследств",
58
+ "по наследству"
59
+ ],
60
+ "severity_rule": {
61
+ "if_text": "лет",
62
+ "threshold_years": 3,
63
+ "red_if_under": true,
64
+ "else": "amber"
65
+ },
66
+ "check": "Требуется проверить основание приобретения права (по закону / по завещанию), состав наследственной массы, круг наследников и риски появления непризванных наследников или восстановления сроков принятия наследства (ст. 1155 ГК РФ)."
67
+ },
68
+ {
69
+ "code": "MINOR_OWNERS",
70
+ "severity": "red",
71
+ "title": "Маркер участия несовершеннолетних",
72
+ "markers": [
73
+ "несовершеннолетн",
74
+ "малолетн",
75
+ "детей-собственник"
76
+ ],
77
+ "check": "Требуется проверить наличие несовершеннолетних собственников/сособственников и необходимость получения предварительного разрешения органа опеки и попечительства на отчуждение имущества (ст. 37 ГК РФ, ст. 21 ФЗ N 48-ФЗ)."
78
+ },
79
+ {
80
+ "code": "MATERNITY_CAPITAL",
81
+ "severity": "red",
82
+ "title": "Маркер использования средств материнского капитала",
83
+ "markers": [
84
+ "материнск",
85
+ "маткапитал",
86
+ "семейного капитал"
87
+ ],
88
+ "check": "Требуется проверить факт и полноту исполнения обязательства по выделению долей детям и супругу при покупке/погашении ипотеки с привлечением средств материнского (семейного) капитала (ФЗ N 256-ФЗ)."
89
+ },
90
+ {
91
+ "code": "REDEVELOPMENT_UNAPPROVED",
92
+ "severity": "amber",
93
+ "title": "Маркер перепланировки / переустройства",
94
+ "markers": [
95
+ "перепланиров",
96
+ "узаконим",
97
+ "узаконена",
98
+ "соединил",
99
+ "объединил"
100
+ ],
101
+ "check": "Требуется проверить соответствие фактической планировки поэтажному плану и экспликации БТИ/техпаспорту, наличие акта приемочной комиссии и решения о согласовании перепланировки (ст. 26, 28 ЖК РФ)."
102
+ },
103
+ {
104
+ "code": "POWER_OF_ATTORNEY",
105
+ "severity": "red",
106
+ "title": "Маркер отчуждения по доверенности",
107
+ "markers": [
108
+ "по доверенност",
109
+ "доверенность",
110
+ "доверенност"
111
+ ],
112
+ "check": "Требуется проверить действительность доверенности через реестр доверенностей ФНП (reestr-dover.ru), объем специальных полномочий на продажу и получение денежных средств, а также факт личного волеизъявления и правоспособности собственника."
113
+ },
114
+ {
115
+ "code": "RENTAL_TENANCY_BURDEN",
116
+ "severity": "amber",
117
+ "title": "Маркер действующего договора найма/аренды",
118
+ "markers": [
119
+ "с арендатор",
120
+ "сдается",
121
+ "арендный бизнес",
122
+ "проживают нанимател",
123
+ "снимают"
124
+ ],
125
+ "check": "Требуется проверить условия договора найма/аренды, основания и сроки прекращения пользования, а также соблюдение прав нанимателей при смене собственника (ст. 675 ГК РФ)."
126
+ }
127
+ ],
128
+ "checklist": [
129
+ "Истребовать актуальную выписку из ЕГРН об основных характеристиках и зарегистрированных правах, полученную собственником (включая проверку общедоступных сведений и данных о правообладателе с учетом ограничений ст. 36.3 ФЗ N 218-ФЗ).",
130
+ "Запросить и проверить правоустанавливающие документы продавца (договор купли-продажи / дарения / приватизации, свидетельство о праве на наследство, решение суда) и историю перехода прав.",
131
+ "Проверить сведения о продавце и правопредшественниках в Едином федеральном реестре сведений о банкротстве (ЕФРСБ) и картотеке арбитражных дел (kad.arbitr.ru) на предмет процедур банкротства или заявлений о несостоятельности.",
132
+ "Проверить наличие судебных споров и исполнительных производств в отношении собственника по базам судов общей юрисдикции и Банку данных исполнительных производств ФССП России.",
133
+ "Истребовать документ жилищного учета: единый жилищный документ (ЕЖД) либо выписку из домовой книги (справку о зарегистрированных лицах, включая архивную/расширенную) и справку об отсутствии задолженности по оплате ЖКУ и взносам на капитальный ремонт.",
134
+ "Запросить нотариально удостоверенное согласие супруга продавца на отчуждение объекта (ст. 35 СК РФ) либо нотариальное заявление/брачный договор об отсутствии брачных обязательств в отношении объекта.",
135
+ "Проверить привлечение средств материнского капитала: истребовать справку СФР об остатке маткапитала (при наличии детей) и проверить исполнение обязательства по наделению долями несовершеннолетних.",
136
+ "При участии несовершеннолетних собственников: истребовать предварительное разрешение органа опеки и попечительства на совершение сделки по отчуждению имущества.",
137
+ "При отчуждении по доверенности: сверить реквизиты доверенности по публичному сервису проверки ФНП, подтвердить полномочия на отчуждение и получение средств, установить факт личного подтверждения намерений доверителем.",
138
+ "Сверить фактическое состояние объекта с поэтажным планом и экспликацией БТИ / техническим паспортом на предмет несогласованных перепланировок и переустройств.",
139
+ "Уточнить структуру и условия сделки (прямая продажа либо альтернативная цепочка, сроки освобождения объекта и порядок расчетов)."
140
+ ],
141
+ "litcheck_phrases": {
142
+ "found": "По запросу «{query}» в судебных базах выявлено записей: {count}. Сведения носят ориентировочный характер, не являются подтверждением порока объекта/стороны и не подменяют юридическую экспертизу. Требуется проверка совпадения сторон, предмета спора (ст. 209, 301, 302 ГК РФ) и процессуального статуса дел.",
143
+ "unavailable": "Судебная проверка по запросу «{query}» недоступна (сервис sud-cli вернул статус unavailable). Сведения об отсутствии судебных споров не подтверждены. Вывод о юридической чистоте объекта/субъекта не сформирован.",
144
+ "checklist_addendum": "При наличии сигналов судебной проверки: сверить предмет найденных дел с кадастровым номером и историей объекта, исключить однофамильцев и споры третьих лиц по смежным помещениям."
145
+ }
146
+ }
@@ -0,0 +1,22 @@
1
+ -----BEGIN PGP PUBLIC KEY BLOCK-----
2
+
3
+ mDMEatuNdRYJKwYBBAHaRw8BAQdAhCwTDMQJHb058gVK5gVxruoOWkVkNQca6ypB
4
+ 6iFKTb20F2xuZm50IDxsbmZudEBwcm90b24ubWU+iJYEExYKAD4CGyMFCwkIBwIC
5
+ IgIGFQoJCAsCBBYCAwECHgcCF4AWIQT0SS1Ht18S0CVADBkpHkrKyliQKgUCaty5
6
+ +gIZAQAKCRApHkrKyliQKtzGAQC9QT7oLj4VtKVuZGtGO4aKeD59amBS5swMNmmV
7
+ 0udlvgD/ckrnxQgdbeYd4SsOMwXMgEnErARQ+zFuB7xGpRMTuQ60KUxFWCBSZWxl
8
+ YXNlIFNpZ25pbmcgPHJlbGVhc2VAbGV4LmludmFsaWQ+iHgEMBYKACAWIQT0SS1H
9
+ t18S0CVADBkpHkrKyliQKgUCaty7SwIdIAAKCRApHkrKyliQKrk7AP451tr+kWhv
10
+ 3NU036l2oob3N1qieaOdfW/OU//6EP3z3AD/fVEUIRAMSWk5pWO3nr88oNSFCsOp
11
+ ljlBeVe+6bz9pQGIkwQTFgoAOxYhBPRJLUe3XxLQJUAMGSkeSsrKWJAqBQJq2411
12
+ AhsjBQsJCAcCAiICBhUKCQgLAgQWAgMBAh4HAheAAAoJECkeSsrKWJAqLYYBAIB2
13
+ 97PGcYgWCMay3OCdZLHaw94Y8EM7cANUXkEkXZBaAP9TqDXXdIckVD0PgaR7uqlf
14
+ xy5joOiiJpD+7DNTCOAwCbgzBGrbjXUWCSsGAQQB2kcPAQEHQK0hQe3FIYrB81xc
15
+ pAMzpslpaPtnsw/lnrM+nwh/bGdsiO8EGBYKACAWIQT0SS1Ht18S0CVADBkpHkrK
16
+ yliQKgUCatuNdQIbIgCBCRApHkrKyliQKnYgBBkWCgAdFiEEGcY2JlH+o1CD/Xpo
17
+ 0srydffPPYgFAmrbjXUACgkQ0srydffPPYg2mwEAwWHbAUoLVkt7IqRZWFw+PzQ9
18
+ aypjfPDE+U1cIBsO7tMA/AzivcCd8eeePj1ZG6bg2IC+w+6wAGNnU3B3AXtbUs4K
19
+ dI0BALKkwaKcnXvjBgkUTU4Oam/sLX5D6Mi592/fZ/FCjS84AP4xpNOfHT3RafH3
20
+ qxiLNL+Ar2wmyztibvEbVDkJ/xYUAg==
21
+ =ogkP
22
+ -----END PGP PUBLIC KEY BLOCK-----
nedviga/deck.py ADDED
@@ -0,0 +1,115 @@
1
+ """v0.5.13 F-33: генератор презентаций (python-pptx) из подборок nedviga.
2
+
3
+ Шаблон нейтральный встроенный (мастер-слиды дефолтные), никаких внешних
4
+ файлов — продукт отдаёт структуру и данные, «дизайн» = чистая таблица.
5
+ Последний слайд — обязательный дисклеймер внешних источников.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import statistics
10
+ from datetime import datetime, timezone
11
+ from pathlib import Path
12
+
13
+ DISCLAIMER = ("Данные получены из внешних источников (ЦИАН, Авито, open data Москвы) "
14
+ "и могут быть неактуальны. Проверяйте цену, статус объявления и юридические "
15
+ "параметры на источнике перед решениями. Прогноз пробок не включён: "
16
+ "текущая загруженность ≠ прогноз час-пика.")
17
+
18
+ _CAP = 15
19
+
20
+
21
+ def _fmt_money(v) -> str:
22
+ try:
23
+ return f"{int(v):,}".replace(",", " ")
24
+ except (TypeError, ValueError):
25
+ return "—"
26
+
27
+
28
+ def _card_cells(c: dict) -> list[str]:
29
+ pr = c.get("pricing") or {}
30
+ pa = c.get("parameters") or {}
31
+ bu = c.get("building") or {}
32
+ loc = c.get("location") or {}
33
+ return [
34
+ str(c.get("id") or "—"),
35
+ _fmt_money(pr.get("price_total_rub")),
36
+ _fmt_money(pr.get("price_per_m2_rub")),
37
+ f"{pa.get('rooms', '—')}/{pa.get('area_total_m2', '—')}/{pa.get('floor', '—')}-{pa.get('floors_total', '—')}",
38
+ (bu.get("address_raw") or loc.get("address") or c.get("title") or "—")[:60],
39
+ ]
40
+
41
+
42
+ def deck_from_cards(cards: list[dict], out_path: str, title: str = "Подборка недвижимости") -> dict:
43
+ """Собирает pptx: титул → таблица топ-N → сводка рынка → дисклеймер
44
+ (+ family-слайд если карточка семейной карты присутствует)."""
45
+ from pptx import Presentation
46
+ from pptx.util import Inches, Pt
47
+
48
+ cards = cards[:_CAP]
49
+ prs = Presentation()
50
+
51
+ # 1. титул
52
+ s = prs.slides.add_slide(prs.slide_layouts[5])
53
+ s.shapes.title.text = title
54
+ sub = s.placeholders[1] if len(s.placeholders) > 1 else None
55
+ if sub is not None:
56
+ sub.text = f"nedviga-cli · {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')} · {len(cards)} объектов"
57
+
58
+ # 2. таблица карточек
59
+ s = prs.slides.add_slide(prs.slide_layouts[5])
60
+ s.shapes.title.text = "Объекты"
61
+ rows, cols = len(cards) + 1, 5
62
+ tbl = s.shapes.add_table(rows, cols, Inches(0.3), Inches(1.4),
63
+ Inches(9.4), Inches(0.4 * rows)).table
64
+ for j, h in enumerate(("ID", "Цена ₽", "₽/м²", "комн/м²/эт", "Адрес")):
65
+ cell = tbl.cell(0, j)
66
+ cell.text = h
67
+ for p in cell.text_frame.paragraphs:
68
+ for r in p.runs:
69
+ r.font.size = Pt(11)
70
+ r.font.bold = True
71
+ for i, c in enumerate(cards, start=1):
72
+ for j, v in enumerate(_card_cells(c)):
73
+ cell = tbl.cell(i, j)
74
+ cell.text = v
75
+ for p in cell.text_frame.paragraphs:
76
+ for r in p.runs:
77
+ r.font.size = Pt(10)
78
+
79
+ # 3. family-слайд если есть
80
+ fam = next((c for c in cards if c.get("family_card")), None)
81
+ if fam is not None:
82
+ s = prs.slides.add_slide(prs.slide_layouts[1])
83
+ s.shapes.title.text = "Семейная карта"
84
+ body = s.placeholders[1] if len(s.placeholders) > 1 else None
85
+ if body is not None:
86
+ body.text = str(fam.get("verdict") or fam.get("summary") or "")[:800]
87
+
88
+ # 4. сводка рынка (все числа с n — продуктовая константа)
89
+ prices = [c["pricing"]["price_total_rub"] for c in cards
90
+ if (c.get("pricing") or {}).get("price_total_rub")]
91
+ ppms = [c["pricing"]["price_per_m2_rub"] for c in cards
92
+ if (c.get("pricing") or {}).get("price_per_m2_rub")]
93
+ s = prs.slides.add_slide(prs.slide_layouts[1])
94
+ s.shapes.title.text = "Сводка"
95
+ body = s.placeholders[1] if len(s.placeholders) > 1 else None
96
+ lines = [f"Объектов в подборке: n={len(cards)}"]
97
+ if prices:
98
+ lines.append(f"Цена: медиана {_fmt_money(statistics.median(prices))} ₽, "
99
+ f"разброс {_fmt_money(min(prices))}–{_fmt_money(max(prices))} ₽ (n={len(prices)})")
100
+ if ppms:
101
+ lines.append(f"₽/м²: медиана {_fmt_money(statistics.median(ppms))} (n={len(ppms)})")
102
+ if body is not None:
103
+ body.text = "\n".join(lines)
104
+
105
+ # 5. обязательный дисклеймер
106
+ s = prs.slides.add_slide(prs.slide_layouts[1])
107
+ s.shapes.title.text = "Ограничения"
108
+ body = s.placeholders[1] if len(s.placeholders) > 1 else None
109
+ if body is not None:
110
+ body.text = DISCLAIMER
111
+
112
+ out = Path(out_path)
113
+ out.parent.mkdir(parents=True, exist_ok=True)
114
+ prs.save(str(out))
115
+ return {"status": "ok", "slides": len(prs.slides), "cards": len(cards), "out": str(out)}