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/family_card.py ADDED
@@ -0,0 +1,670 @@
1
+ """v0.4.7 (the task brief) F-11: Family Decision Card.
2
+
3
+ Контент-модель карточки из ListingCard + рендеры HTML/Telegram.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import html as html_mod
8
+ from dataclasses import dataclass, field
9
+ from typing import Any, Iterable, Optional
10
+
11
+ from .models import ListingCard
12
+ from .pii import PHONE_MASK, sanitize_contacts
13
+
14
+
15
+ @dataclass
16
+ class FamilyCard:
17
+ listing: ListingCard
18
+ title: str = ""
19
+ address: str = ""
20
+ district: str = ""
21
+ okrug: str = ""
22
+ price_total_rub: Optional[float] = None
23
+ price_per_m2_rub: Optional[float] = None
24
+ rooms: Optional[int] = None
25
+ area_total_m2: Optional[float] = None
26
+ area_living_m2: Optional[float] = None
27
+ area_kitchen_m2: Optional[float] = None
28
+ floor: Optional[int] = None
29
+ floors_total: Optional[int] = None
30
+ house_series: Optional[str] = None
31
+ year_built: Optional[int] = None
32
+ building_type: Optional[str] = None
33
+ floor_material: Optional[str] = None
34
+ elevators_passenger: Optional[int] = None
35
+ elevators_cargo: Optional[int] = None
36
+ has_garbage_chute: Optional[bool] = None
37
+ renovation_program: Optional[bool] = None
38
+ metro_station: Optional[str] = None
39
+ claimed_time_minutes: Optional[int] = None
40
+ walk_distance_meters: Optional[int] = None
41
+ walk_duration_minutes: Optional[float] = None
42
+ routing_provider: Optional[str] = None
43
+ route_verified: bool = False
44
+ photos: list[str] = field(default_factory=list)
45
+ pros: list[str] = field(default_factory=list)
46
+ cons: list[str] = field(default_factory=list)
47
+
48
+ # aliases for compatibility
49
+ @property
50
+ def card(self) -> ListingCard:
51
+ return self.listing
52
+
53
+ @property
54
+ def source_card(self) -> ListingCard:
55
+ return self.listing
56
+
57
+ @property
58
+ def listing_card(self) -> ListingCard:
59
+ return self.listing
60
+
61
+ @property
62
+ def images(self) -> list[str]:
63
+ return self.photos
64
+
65
+
66
+ FamilyDecisionCard = FamilyCard
67
+
68
+
69
+ def _listing(card: Any) -> ListingCard:
70
+ if isinstance(card, ListingCard):
71
+ return card
72
+ if isinstance(card, FamilyCard):
73
+ return card.listing
74
+ if isinstance(card, dict):
75
+ try:
76
+ return ListingCard(**card)
77
+ except Exception:
78
+ # maybe already a family card dump
79
+ if "listing" in card:
80
+ return _listing(card["listing"])
81
+ raise
82
+ raise TypeError(f"unsupported card type: {type(card)!r}")
83
+
84
+
85
+ def _none(v: Any) -> str:
86
+ if v is None:
87
+ return "нет данных"
88
+ if isinstance(v, bool):
89
+ return "да" if v else "нет"
90
+ s = str(v).strip()
91
+ return s if s else "нет данных"
92
+
93
+
94
+ def _evaluate(card: ListingCard) -> tuple[list[str], list[str]]:
95
+ pros: list[str] = []
96
+ cons: list[str] = []
97
+ p = card.parameters
98
+ b = card.building
99
+ g = card.geo
100
+
101
+ # kitchen
102
+ if p.area_kitchen_m2 is not None and p.area_kitchen_m2 >= 7.5:
103
+ pros.append(f"Кухня {p.area_kitchen_m2:g} м² (подходит под стандарт семьи)")
104
+ elif p.area_kitchen_m2 is not None:
105
+ cons.append(f"Кухня {p.area_kitchen_m2:g} м² — небольшая")
106
+
107
+ # floor
108
+ if p.floor is not None:
109
+ if p.floor == 1:
110
+ cons.append("Первый этаж")
111
+ elif p.floors_total and p.floor == p.floors_total:
112
+ cons.append("Крайний (последний) этаж")
113
+ else:
114
+ if p.floors_total:
115
+ pros.append(f"Средний комфортный этаж ({p.floor} из {p.floors_total})")
116
+ else:
117
+ pros.append(f"Этаж {p.floor}")
118
+
119
+ # ceilings / floor material
120
+ fm = (b.floor_material or "").lower()
121
+ if "железобетон" in fm or fm == "жб" or "жб" in fm:
122
+ pros.append("ЖБ перекрытия")
123
+ elif b.floor_material:
124
+ # mixed/wood -> nuance
125
+ if "дерев" in fm:
126
+ cons.append("Деревянные перекрытия")
127
+ elif "смеш" in fm:
128
+ cons.append("Смешанные перекрытия")
129
+
130
+ # gas
131
+ if b.gas is True:
132
+ cons.append("Газ (газовая плита)")
133
+
134
+ # balcony / loggia
135
+ if p.balcony:
136
+ pros.append("Балкон/лоджия")
137
+ else:
138
+ if p.balconies_count is None and p.loggias_count is None:
139
+ pass
140
+ else:
141
+ if (p.balconies_count or 0) + (p.loggias_count or 0) == 0:
142
+ cons.append("Без балкона/лоджии")
143
+
144
+ if p.balcony is None and (p.balconies_count or 0) + (p.loggias_count or 0) == 0 and p.balcony is None:
145
+ # only flag if explicitly no balcony info? keep conservative: no minus without data
146
+ pass
147
+
148
+ # bathroom
149
+ if p.bathroom and "раздел" in p.bathroom.lower():
150
+ pros.append("С/у раздельный")
151
+
152
+ # rooms isolated (best-effort from description? skip)
153
+
154
+ # window view
155
+ wv = (p.window_view or "").lower()
156
+ if "двор" in wv or "зелен" in wv or "парк" in wv:
157
+ pros.append("Окна во двор / зелень")
158
+
159
+ # walk
160
+ walk = g.honest_walk_route
161
+ if walk and walk.walk_duration_minutes is not None:
162
+ if walk.walk_duration_minutes < 15:
163
+ pros.append(f"{int(walk.walk_duration_minutes)} минут пешком до метро без горок")
164
+ elif walk.walk_duration_minutes > 15:
165
+ cons.append(f"Длинный пеший маршрут ({int(walk.walk_duration_minutes)} мин)")
166
+
167
+ # cargo elevator
168
+ if b.elevators_cargo is None or (b.elevators_cargo or 0) == 0:
169
+ # only minus if building has elevators info? conservative: flag if elevators_passenger known
170
+ if b.elevators_passenger is not None:
171
+ cons.append("Нет грузового лифта")
172
+
173
+ # house series note
174
+ if b.house_series:
175
+ pros.append(f"Дом серии {b.house_series}")
176
+
177
+ # renovation
178
+ if p.renovation and "евро" in p.renovation.lower():
179
+ pros.append("Евроремонт")
180
+
181
+ return pros, cons
182
+
183
+
184
+ def build_family_card(card: Any, with_contacts: bool = False) -> FamilyCard:
185
+ c = _listing(card)
186
+ p = c.parameters
187
+ b = c.building
188
+ g = c.geo
189
+ walk = g.honest_walk_route
190
+ claimed = g.cian_claimed_metro.claimed_time_minutes if g.cian_claimed_metro else None
191
+ metro = g.cian_claimed_metro.station if g.cian_claimed_metro else None
192
+ if not metro and walk:
193
+ metro = walk.target_station
194
+ photos = [img.url for img in (c.images or []) if getattr(img, "url", None)]
195
+ pros, cons = _evaluate(c)
196
+
197
+ addr = _human(b.address_raw or c.title or "")
198
+ # v0.4.7 контроль качества: ТЗ §2.2 — серия с фолбэком «Индивидуальный проект»
199
+ # (у кодера был мёртвый тернарщик `... if False else ...`, всегда None).
200
+ series = b.house_series or "Индивидуальный проект"
201
+
202
+ fc = FamilyCard(
203
+ listing=c,
204
+ title=_human(c.title) or "",
205
+ address=addr,
206
+ district=g.district or "",
207
+ okrug=g.area_name or "",
208
+ price_total_rub=c.pricing.price_total_rub,
209
+ price_per_m2_rub=c.pricing.price_per_m2_rub,
210
+ rooms=p.rooms,
211
+ area_total_m2=p.area_total_m2,
212
+ area_living_m2=p.area_living_m2,
213
+ area_kitchen_m2=p.area_kitchen_m2,
214
+ floor=p.floor,
215
+ floors_total=p.floors_total,
216
+ house_series=series,
217
+ year_built=b.year_built,
218
+ building_type=b.building_type,
219
+ floor_material=b.floor_material,
220
+ elevators_passenger=b.elevators_passenger,
221
+ elevators_cargo=b.elevators_cargo,
222
+ has_garbage_chute=b.has_garbage_chute,
223
+ renovation_program=b.renovation_program,
224
+ metro_station=metro,
225
+ claimed_time_minutes=claimed,
226
+ walk_distance_meters=walk.walk_distance_meters if walk else None,
227
+ walk_duration_minutes=walk.walk_duration_minutes if walk else None,
228
+ routing_provider=walk.routing_provider if walk else None,
229
+ route_verified=walk.route_verified if walk else False,
230
+ photos=photos,
231
+ pros=pros,
232
+ cons=cons,
233
+ )
234
+ return fc
235
+
236
+
237
+ # aliases
238
+ build_card = build_family_card
239
+ make_family_card = build_family_card
240
+ family_card = build_family_card
241
+
242
+
243
+ _SPOT_RE = __import__("re").compile(r"</?external_untrusted_data[^>]*>")
244
+
245
+
246
+ def _human(s: Any) -> str:
247
+ """v0.4.7 контроль качества: семейный формат очищаем от служебных маркеров
248
+ spotlighting (это аннотация парсера, а не текст для людей)."""
249
+ return _SPOT_RE.sub("", str(s or ""))
250
+
251
+
252
+ def _safe_url(u: Any) -> str:
253
+ s = str(u or "").strip()
254
+ low = s.lower()
255
+ if not (low.startswith("http://") or low.startswith("https://")
256
+ or low.startswith("data:image/jpeg") or low.startswith("data:image/png")
257
+ or low.startswith("data:image/webp") or low.startswith("data:image/gif")):
258
+ return ""
259
+ return html_mod.escape(s, quote=True)
260
+
261
+
262
+ def _fmt_price(v: Optional[float]) -> str:
263
+ if v is None:
264
+ return "—"
265
+ return f"{v:,.0f}".replace(",", " ")
266
+
267
+
268
+ def _floor_material_text(v: Optional[str]) -> str:
269
+ if not v:
270
+ return "нет данных"
271
+ low = v.lower()
272
+ if "железобетон" in low or low == "жб" or "жб" in low:
273
+ return "ЖБ"
274
+ return v
275
+
276
+
277
+ def _garbage_text(v: Optional[bool]) -> str:
278
+ if v is None:
279
+ return "нет данных"
280
+ return "есть" if v else "нет"
281
+
282
+
283
+ def _dossier_enrichment(card) -> str:
284
+ try:
285
+ from .dossier import match as _match, db_path as _dbp
286
+ if not _dbp().exists():
287
+ return ""
288
+ res = _match(card)
289
+ if res.get("status") != "ok":
290
+ return ""
291
+ cand = (res.get("candidates") or [None])[0]
292
+ if not cand:
293
+ return ""
294
+ parts = []
295
+ if cand.get("year"):
296
+ parts.append(f"год {cand['year']}")
297
+ if cand.get("floors"):
298
+ parts.append(f"эт. {cand['floors']}")
299
+ if cand.get("cadastral"):
300
+ parts.append(f"кадастр {cand['cadastral']}")
301
+ src = "реестр ЖК data.mos.ru"
302
+ if parts:
303
+ return "🏢 Официально (" + src + "): " + ", ".join(parts)
304
+ return "🏢 Официально (" + src + "): данные реестра"
305
+ except Exception:
306
+ return ""
307
+
308
+
309
+ def _developer_str(card) -> str:
310
+ try:
311
+ import re
312
+ text = (card.description_text or "") + " " + (card.title or "")
313
+ m = re.search(r"(?<!\d)(\d{10}|\d{12})(?!\d)", text)
314
+ if not m:
315
+ return ""
316
+ # only if developer context
317
+ if "застройщик" not in text.lower() and "девелопер" not in text.lower():
318
+ return ""
319
+ return "🏗 Застройщик: ИНН " + m.group(1) + " (кандидат из объявления, реестр 2114)"
320
+ except Exception:
321
+ return ""
322
+
323
+
324
+ def _poi_cached_block(card) -> str:
325
+ try:
326
+ from .poi import _cache_get, _cache_key, _classify
327
+ g = card.geo
328
+ if g.lat is None or g.lon is None:
329
+ return ""
330
+ radius = 15 * 80
331
+ key = _cache_key(g.lat, g.lon, radius)
332
+ cached = _cache_get(key)
333
+ if cached is None:
334
+ return ""
335
+ cats = _classify(cached.get("elements", []) if isinstance(cached, dict) else [], g.lat, g.lon)
336
+ parts = []
337
+ for cat, info in cats.items():
338
+ if isinstance(info, dict) and info.get("count", 0) > 0 and info.get("nearest_m") is not None:
339
+ parts.append(f"{cat} {info['nearest_m']} м")
340
+ if not parts:
341
+ return ""
342
+ return "🏫 Инфраструктура (прямая доступность, кэш на 30 дней): " + ", ".join(parts)
343
+ except Exception:
344
+ return ""
345
+
346
+
347
+ def _walk_text(fc: FamilyCard) -> str:
348
+ if fc.walk_distance_meters is not None and fc.walk_duration_minutes is not None and fc.metro_station:
349
+ return f"м. {fc.metro_station} — {int(fc.walk_duration_minutes)} мин пешком ({fc.walk_distance_meters} м)"
350
+ # v0.4.7 контроль качества: честный фолбэк на заявку Циана с явной пометкой
351
+ # «графом не проверено» — раньше при отсутствии графа блок был «нет данных»,
352
+ # хотя станция и заявленное время в карточке есть (ТЗ §2.3).
353
+ if fc.metro_station and fc.claimed_time_minutes is not None:
354
+ return f"м. {fc.metro_station} — заявлено {int(fc.claimed_time_minutes)} мин пешком (графом не проверено)"
355
+ if fc.metro_station:
356
+ return f"м. {fc.metro_station} — время в пути нет данных"
357
+ return "нет данных"
358
+
359
+
360
+ def _contacts_text(fc: FamilyCard, with_contacts: bool) -> str:
361
+ card = fc.listing
362
+ phones = [str(p) for p in (card.seller.phones if card.seller else []) if str(p or "").strip()]
363
+ if not phones:
364
+ return ""
365
+ if with_contacts:
366
+ return " · ".join(phones)
367
+ return " · ".join(PHONE_MASK for _ in phones)
368
+
369
+
370
+ def render_telegram(card: Any, with_contacts: bool = False) -> str:
371
+ fc = _listing(card) if isinstance(card, ListingCard) else None
372
+ if fc is None:
373
+ # maybe FamilyCard
374
+ if isinstance(card, FamilyCard):
375
+ fc_card = card
376
+ else:
377
+ fc_card = build_family_card(card, with_contacts=with_contacts)
378
+ else:
379
+ fc_card = build_family_card(fc, with_contacts=with_contacts)
380
+
381
+ # ensure with_contacts default from render arg
382
+ c = fc_card.listing
383
+ p = c.parameters
384
+ b = c.building
385
+
386
+ # title
387
+ title = _human(c.title) or f"{c.id}"
388
+ price = _fmt_price(fc_card.price_total_rub)
389
+ ppm2 = _fmt_price(fc_card.price_per_m2_rub)
390
+
391
+ lines: list[str] = []
392
+ lines.append(f"🏠 {title}")
393
+ lines.append(f"💰 {price} ₽ ({ppm2} ₽/м²)")
394
+ lines.append("")
395
+ lines.append(f"📍 Локация: {_walk_text(fc_card)}")
396
+ # v0.4.7 контроль качества: район/округ в карточку (ТЗ §2.2.1) и тип здания в линию дома
397
+ loc_bits = [b for b in (fc_card.okrug, fc_card.district) if b]
398
+ if loc_bits:
399
+ lines.insert(len(lines) - 1, f"🗺 Район: {', '.join(dict.fromkeys(loc_bits))}")
400
+ # house line
401
+ series = _none(fc_card.house_series)
402
+ year = _none(fc_card.year_built)
403
+ fm = _floor_material_text(fc_card.floor_material)
404
+ elevs = []
405
+ if fc_card.elevators_passenger is not None:
406
+ elevs.append(f"{fc_card.elevators_passenger} пасс.")
407
+ if fc_card.elevators_cargo is not None:
408
+ elevs.append(f"{fc_card.elevators_cargo} груз.")
409
+ elev_text = ", ".join(elevs) if elevs else "нет данных"
410
+ if fc_card.elevators_passenger is None and fc_card.elevators_cargo is None:
411
+ elev_text = "нет данных"
412
+ garbage = _garbage_text(fc_card.has_garbage_chute)
413
+ floor_line = ""
414
+ if fc_card.floor is not None or fc_card.floors_total is not None:
415
+ fl = fc_card.floor if fc_card.floor is not None else "?"
416
+ ft = fc_card.floors_total if fc_card.floors_total is not None else "?"
417
+ floor_line = f", этаж {fl}/{ft}"
418
+ bt_map = {"monolith": "монолит", "brick": "кирпич", "panel": "панельный",
419
+ "block": "блочный", "wood": "деревянный", "mixed": "смешанный"}
420
+ bt = bt_map.get(str(fc_card.building_type or "").lower(), fc_card.building_type)
421
+ bt_txt = f"{bt}, " if bt else ""
422
+ lines.append(
423
+ f"🏢 Дом: {series}{(' (' + str(year) + ' г.)') if year != 'нет данных' else ''}, "
424
+ f"{bt_txt}{fm} перекрытия{floor_line}, "
425
+ f"лифты: {elev_text}, мусоропровод: {garbage}"
426
+ )
427
+ # params line
428
+ param_bits = []
429
+ if fc_card.rooms is not None:
430
+ param_bits.append(f"комн. {fc_card.rooms}")
431
+ if fc_card.area_living_m2 is not None:
432
+ param_bits.append(f"жил. {fc_card.area_living_m2:g} м²")
433
+ if fc_card.area_kitchen_m2 is not None:
434
+ param_bits.append(f"кухня {fc_card.area_kitchen_m2:g} м²")
435
+ if fc_card.area_total_m2 is not None:
436
+ param_bits.append(f"общ. {fc_card.area_total_m2:g} м²")
437
+ if c.parameters.bathroom:
438
+ param_bits.append(c.parameters.bathroom)
439
+ if c.parameters.balcony:
440
+ param_bits.append(c.parameters.balcony)
441
+ lines.append("📐 Параметры: " + ", ".join(param_bits) if param_bits else "📐 Параметры: нет данных")
442
+ if getattr(fc_card.listing, "missing_fields", None):
443
+ lines.append("⚠ Не хватает данных: " + ", ".join(fc_card.listing.missing_fields))
444
+ _poi = _poi_cached_block(fc_card.listing)
445
+ if _poi:
446
+ lines.append(_poi)
447
+ _dos = _dossier_enrichment(fc_card.listing)
448
+ if _dos:
449
+ lines.append(_dos)
450
+ _dev = _developer_str(fc_card.listing)
451
+ if _dev:
452
+ lines.append(_dev)
453
+ try:
454
+ from .legal_triage import triage as _triage
455
+ _tr = _triage(fc_card.listing)
456
+ if _tr.get("flags"):
457
+ lines.append("⚠ " + ", ".join(f["code"] for f in _tr["flags"]))
458
+ except Exception:
459
+ pass
460
+ lines.append("")
461
+ lines.append("✅ Плюсы:")
462
+ if fc_card.pros:
463
+ for pr in fc_card.pros:
464
+ lines.append(f"• {pr}")
465
+ else:
466
+ lines.append("• нет данных")
467
+ lines.append("")
468
+ lines.append("⚠️ На что обратить внимание:")
469
+ if fc_card.cons:
470
+ for cn in fc_card.cons:
471
+ lines.append(f"• {cn}")
472
+ else:
473
+ lines.append("• нет данных")
474
+ lines.append("")
475
+ contacts = _contacts_text(fc_card, with_contacts)
476
+ if contacts:
477
+ lines.append(f"📞 Контакты: {contacts}")
478
+ lines.append(f"📸 Фото: {len(fc_card.photos)} шт.")
479
+
480
+ text = "\n".join(lines)
481
+ # mask phones in free text (description not included, but safety)
482
+ from .pii import mask_phones
483
+ if not with_contacts:
484
+ text = mask_phones(text)
485
+ return text
486
+
487
+
488
+ def render_html(card: Any, with_contacts: bool = False, embed_photos: bool = False) -> str:
489
+ # resolve
490
+ if isinstance(card, ListingCard):
491
+ fc = build_family_card(card, with_contacts=with_contacts)
492
+ elif isinstance(card, FamilyCard):
493
+ fc = card
494
+ else:
495
+ try:
496
+ fc = build_family_card(card, with_contacts=with_contacts)
497
+ except Exception:
498
+ fc = build_family_card(_listing(card), with_contacts=with_contacts)
499
+
500
+ c = fc.listing
501
+ triage_codes = []
502
+ try:
503
+ from .legal_triage import triage as _triage
504
+ triage_codes = [f["code"] for f in _triage(c).get("flags", [])]
505
+ except Exception:
506
+ pass
507
+ # contacts
508
+ contacts_html = ""
509
+ phones = [str(p) for p in (c.seller.phones if c.seller else []) if str(p or "").strip()]
510
+ if phones:
511
+ if with_contacts:
512
+ shown = phones
513
+ else:
514
+ shown = [PHONE_MASK for _ in phones]
515
+ contacts_html = '<div class="contacts">📞 ' + " · ".join(html_mod.escape(p) for p in shown) + "</div>"
516
+
517
+ # photos carousel
518
+ slides = ""
519
+ dots = ""
520
+ if fc.photos:
521
+ safe_photos = [u for u in ( _safe_url(p) for p in fc.photos) if u]
522
+ slides = "".join(
523
+ f'<img src="{u}" alt="фото {i}" class="slide{" active" if i == 0 else ""}">'
524
+ for i, u in enumerate(safe_photos)
525
+ )
526
+ dots = "".join(
527
+ f'<span class="dot{" active" if i == 0 else ""}" onclick="showSlide(0,{i},this)"></span>'
528
+ for i in range(len(safe_photos))
529
+ )
530
+ carousel = f'<div class="carousel" id="car0">{slides}<div class="dots">{dots}</div></div>' if fc.photos else ""
531
+
532
+ # house details
533
+ def row(label, value):
534
+ return f"<tr><th>{html_mod.escape(label)}</th><td>{html_mod.escape(str(value))}</td></tr>"
535
+
536
+ house_rows = ""
537
+ house_rows += row("Серия дома", _none(fc.house_series))
538
+ house_rows += row("Год постройки", _none(fc.year_built))
539
+ house_rows += row("Материал/перекрытия", _none(fc.floor_material))
540
+ house_rows += row("Тип здания", _none(fc.building_type))
541
+ elev = "нет данных"
542
+ if fc.elevators_passenger is not None or fc.elevators_cargo is not None:
543
+ parts = []
544
+ if fc.elevators_passenger is not None:
545
+ parts.append(f"пасс. {fc.elevators_passenger}")
546
+ if fc.elevators_cargo is not None:
547
+ parts.append(f"груз. {fc.elevators_cargo}")
548
+ elev = ", ".join(parts) if parts else "нет данных"
549
+ house_rows += row("Лифты", elev)
550
+ house_rows += row("Мусоропровод", _garbage_text(fc.has_garbage_chute))
551
+ cap = "да" if fc.renovation_program else ("нет" if fc.renovation_program is False else "нет данных")
552
+ house_rows += row("Капремонт", cap)
553
+
554
+ walk_str = _walk_text(fc)
555
+ # pros/cons
556
+ pros_html = "".join(f"<li>{html_mod.escape(x)}</li>" for x in fc.pros) or "<li>нет данных</li>"
557
+ cons_html = "".join(f"<li>{html_mod.escape(x)}</li>" for x in fc.cons) or "<li>нет данных</li>"
558
+
559
+ params_html = []
560
+ if fc.rooms is not None:
561
+ params_html.append(f"Комнат: {fc.rooms}")
562
+ if fc.area_total_m2 is not None:
563
+ params_html.append(f"Общая: {fc.area_total_m2:g} м²")
564
+ if fc.area_living_m2 is not None:
565
+ params_html.append(f"Жилая: {fc.area_living_m2:g} м²")
566
+ if fc.area_kitchen_m2 is not None:
567
+ params_html.append(f"Кухня: {fc.area_kitchen_m2:g} м²")
568
+ if fc.floor is not None or fc.floors_total is not None:
569
+ params_html.append(f"Этаж: {fc.floor}/{fc.floors_total}")
570
+ params_str = " · ".join(params_html) if params_html else "нет данных"
571
+
572
+ html = f"""<!DOCTYPE html>
573
+ <html lang="ru"><head><meta charset="utf-8">
574
+ <title>Семейная карточка — nedviga-cli</title>
575
+ <style>
576
+ body{{font-family:system-ui,sans-serif;background:#f7f7f7;margin:0;padding:20px;font-size:18px;line-height:1.5;color:#222}}
577
+ .card{{max-width:900px;margin:0 auto;background:#fff;border-radius:12px;padding:24px;box-shadow:0 2px 8px rgba(0,0,0,.08)}}
578
+ h1{{font-size:1.6em;margin:0 0 8px}}
579
+ .price{{font-size:1.5em;font-weight:700;color:#0a7d34;margin:8px 0}}
580
+ .meta{{color:#444;margin:8px 0}}
581
+ .section{{margin:16px 0}}
582
+ .section h2{{font-size:1.2em;margin:0 0 8px;color:#333}}
583
+ table{{width:100%;border-collapse:collapse}}
584
+ th,td{{text-align:left;padding:6px 8px;border-bottom:1px solid #eee}}
585
+ th{{color:#666;width:40%}}
586
+ .carousel{{position:relative;height:360px;overflow:hidden;border-radius:10px;margin:12px 0;background:#eee}}
587
+ .slide{{display:none;width:100%;height:360px;object-fit:contain;background:#eee}}
588
+ .slide.active{{display:block}}
589
+ .dots{{position:absolute;bottom:10px;left:0;right:0;text-align:center}}
590
+ .dot{{display:inline-block;width:12px;height:12px;border-radius:50%;background:#fff;opacity:.6;margin:0 4px;cursor:pointer}}
591
+ .dot.active{{opacity:1;background:#0a7d34}}
592
+ ul{{margin:6px 0 6px 20px}}
593
+ .contacts{{color:#666;margin:8px 0}}
594
+ .walk{{margin:8px 0;color:#2c3e50;font-weight:600}}
595
+ </style></head><body>
596
+ <div class="card">
597
+ <h1>{html_mod.escape(fc.title or fc.listing.id)}</h1>
598
+ <div class="price">{_fmt_price(fc.price_total_rub)} ₽</div>
599
+ <div class="meta">{html_mod.escape(params_str)} · {_fmt_price(fc.price_per_m2_rub)} ₽/м²</div>
600
+ <div class="meta">{html_mod.escape(fc.address)}</div>
601
+ {carousel}
602
+ <div class="section">
603
+ <h2>Локация</h2>
604
+ <div class="walk">🚇 {html_mod.escape(walk_str)}</div>
605
+ </div>
606
+ <div class="section">
607
+ <h2>Дом</h2>
608
+ <table>{house_rows}</table>
609
+ </div>
610
+ <div class="section">
611
+ <h2>Параметры</h2>
612
+ <div>{html_mod.escape(params_str)}</div>
613
+ </div>
614
+ <div class="section">
615
+ <h2>Плюсы</h2>
616
+ <ul>{pros_html}</ul>
617
+ </div>
618
+ <div class="section">
619
+ <h2>На что обратить внимание</h2>
620
+ <ul>{cons_html}</ul>
621
+ </div>
622
+ {contacts_html}
623
+ <div class="meta">📸 Фото: {len(fc.photos)} шт.</div>
624
+ {'<div class="meta">⚠ Не хватает данных: ' + html_mod.escape(", ".join(fc.listing.missing_fields)) + '</div>' if getattr(fc.listing, "missing_fields", None) else ''}
625
+ {'<div class="meta">⚠ ' + html_mod.escape(", ".join(triage_codes)) + '</div>' if triage_codes else ''}
626
+ {('<div class="meta">' + html_mod.escape(_poi_cached_block(fc.listing)) + '</div>') if _poi_cached_block(fc.listing) else ''}
627
+ {('<div class="meta">' + html_mod.escape(_dossier_enrichment(fc.listing)) + '</div>') if _dossier_enrichment(fc.listing) else ''}
628
+ {('<div class="meta">' + html_mod.escape(_developer_str(fc.listing)) + '</div>') if _developer_str(fc.listing) else ''}
629
+ </div>
630
+ <script>
631
+ function showSlide(ci, si, dot) {{
632
+ const car = document.getElementById('car' + ci);
633
+ if (!car) return;
634
+ car.querySelectorAll('.slide').forEach((s, i) => s.classList.toggle('active', i === si));
635
+ car.querySelectorAll('.dot').forEach((d, i) => d.classList.toggle('active', i === si));
636
+ }}
637
+ document.querySelectorAll('.carousel').forEach((car, ci) => {{
638
+ const prev = document.createElement('button'); prev.textContent = '‹'; prev.className = 'navbtn prev';
639
+ const next = document.createElement('button'); next.textContent = '›'; next.className = 'navbtn next';
640
+ const go = (d) => {{
641
+ const slides = [...car.querySelectorAll('.slide')];
642
+ const dots = [...car.querySelectorAll('.dot')];
643
+ if (!slides.length) return;
644
+ let cur = dots.findIndex(d => d.classList.contains('active'));
645
+ if (cur < 0) cur = 0;
646
+ cur = (cur + d + slides.length) % slides.length;
647
+ showSlide(ci, cur, dots[cur]);
648
+ }};
649
+ prev.onclick = () => go(-1); next.onclick = () => go(1);
650
+ car.appendChild(prev); car.appendChild(next);
651
+ }});
652
+ </script>
653
+ </body></html>"""
654
+
655
+ # mask phones in html text if needed (already handled contacts)
656
+ if not with_contacts:
657
+ from .pii import mask_phones
658
+ html = mask_phones(html)
659
+ return html
660
+
661
+
662
+ # convenience: render methods on FamilyCard
663
+ def _attach_methods():
664
+ FamilyCard.render_telegram = lambda self, with_contacts=False: render_telegram(self, with_contacts=with_contacts)
665
+ FamilyCard.render_html = lambda self, with_contacts=False, embed_photos=False: render_html(self, with_contacts=with_contacts, embed_photos=embed_photos)
666
+ FamilyCard.to_telegram = FamilyCard.render_telegram
667
+ FamilyCard.to_html = FamilyCard.render_html
668
+
669
+
670
+ _attach_methods()