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/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()
|