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/export_html.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""v0.3.5 (ТЗ реалтора): экспорт карточек в автономный HTML-каталог.
|
|
2
|
+
|
|
3
|
+
Команда: nedviga-cli export --input cards.json --format html
|
|
4
|
+
[--embed-photos] [--output catalog.html]
|
|
5
|
+
|
|
6
|
+
Карусель: чистый CSS/JS без внешних зависимостей (offline-first).
|
|
7
|
+
Фото: --embed-photos вшивает base64 (сжатие до 800px, JPEG q=70),
|
|
8
|
+
иначе — прямые ссылки (требуют сеть при просмотре).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import html as html_mod
|
|
14
|
+
import io
|
|
15
|
+
import json
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .models import ListingCard
|
|
19
|
+
|
|
20
|
+
_EMBED_MAX_BYTES = 300_000 # на фото после сжатия
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _fetch_photo_bytes(url: str, timeout: float = 15.0) -> bytes | None:
|
|
24
|
+
try:
|
|
25
|
+
# v0.3.5: netguard (egress-guard + breaker)
|
|
26
|
+
from .net_guard import guarded_get
|
|
27
|
+
|
|
28
|
+
# CDN Циан требует браузерный UA + Referer, иначе 403 (v0.3.4)
|
|
29
|
+
resp = guarded_get(
|
|
30
|
+
url,
|
|
31
|
+
timeout=timeout,
|
|
32
|
+
stream=True,
|
|
33
|
+
headers={
|
|
34
|
+
"User-Agent": (
|
|
35
|
+
"Mozilla/5.0 (X11; Linux x86_64; rv:130.0) "
|
|
36
|
+
"Gecko/20100101 Firefox/130.0"
|
|
37
|
+
),
|
|
38
|
+
"Referer": "https://www.cian.ru/",
|
|
39
|
+
"Accept": "image/avif,image/webp,image/png,image/jpeg,*/*",
|
|
40
|
+
},
|
|
41
|
+
)
|
|
42
|
+
if resp.status_code != 200:
|
|
43
|
+
return None
|
|
44
|
+
return resp.content
|
|
45
|
+
except Exception:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _compress(data: bytes, max_side: int = 800, quality: int = 70) -> bytes | None:
|
|
50
|
+
try:
|
|
51
|
+
from PIL import Image
|
|
52
|
+
|
|
53
|
+
img = Image.open(io.BytesIO(data)).convert("RGB")
|
|
54
|
+
w, h = img.size
|
|
55
|
+
if max(w, h) > max_side:
|
|
56
|
+
scale = max_side / max(w, h)
|
|
57
|
+
img = img.resize((int(w * scale), int(h * scale)), Image.LANCZOS)
|
|
58
|
+
buf = io.BytesIO()
|
|
59
|
+
img.save(buf, format="JPEG", quality=quality, optimize=True)
|
|
60
|
+
return buf.getvalue()
|
|
61
|
+
except Exception:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _photo_src(card: ListingCard, idx: int, embed: bool) -> str | None:
|
|
66
|
+
photos = [p.url for p in (card.images or []) if getattr(p, "url", None)]
|
|
67
|
+
if not photos:
|
|
68
|
+
return None
|
|
69
|
+
url = photos[0] # в карусель идут все, но первый — обложка
|
|
70
|
+
if not embed:
|
|
71
|
+
return url
|
|
72
|
+
data = _fetch_photo_bytes(url)
|
|
73
|
+
if data is None:
|
|
74
|
+
return url # fallback на ссылку
|
|
75
|
+
compressed = _compress(data)
|
|
76
|
+
if compressed is None or len(compressed) > _EMBED_MAX_BYTES * 2:
|
|
77
|
+
return url
|
|
78
|
+
return "data:image/jpeg;base64," + base64.b64encode(compressed).decode("ascii")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _safe_url(u: object) -> str:
|
|
82
|
+
"""v0.4.4 (независимый аудит sa-2, F-04): allow-list схем + экранирование.
|
|
83
|
+
|
|
84
|
+
Внешние данные (images/trading_link из import cards.json) не должны
|
|
85
|
+
превращаться в XSS в автономном HTML-каталоге. Пустая строка = не выводить.
|
|
86
|
+
"""
|
|
87
|
+
s = str(u or "").strip()
|
|
88
|
+
low = s.lower()
|
|
89
|
+
# v0.4.5 (review R3): data:image/svg+xml может нести <script> — allow-list только
|
|
90
|
+
# растров; наш собственный embed всегда jpeg, легитимных потребителей svg нет.
|
|
91
|
+
if not (low.startswith("http://") or low.startswith("https://")
|
|
92
|
+
or low.startswith("data:image/jpeg") or low.startswith("data:image/png")
|
|
93
|
+
or low.startswith("data:image/webp") or low.startswith("data:image/gif")):
|
|
94
|
+
return ""
|
|
95
|
+
return html_mod.escape(s, quote=True)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _photos_all(card: ListingCard, embed: bool, limit: int = 6) -> list[str]:
|
|
99
|
+
out: list[str] = []
|
|
100
|
+
for p in (card.images or [])[:limit]:
|
|
101
|
+
url = getattr(p, "url", None)
|
|
102
|
+
if not url:
|
|
103
|
+
continue
|
|
104
|
+
if not embed:
|
|
105
|
+
out.append(url)
|
|
106
|
+
continue
|
|
107
|
+
data = _fetch_photo_bytes(url)
|
|
108
|
+
compressed = _compress(data) if data else None
|
|
109
|
+
if compressed:
|
|
110
|
+
out.append("data:image/jpeg;base64," + base64.b64encode(compressed).decode("ascii"))
|
|
111
|
+
else:
|
|
112
|
+
out.append(url)
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _card_html(card: ListingCard, embed: bool, idx: int) -> str:
|
|
117
|
+
# P3-2: внешний текст мог прийти из cards.json с spotlight-маркерами —
|
|
118
|
+
# для human-facing каталога снимаем их (HTML и так экранируется).
|
|
119
|
+
from .net_guard import strip_spotlight
|
|
120
|
+
title_escaped = html_mod.escape(strip_spotlight(card.title or ""))
|
|
121
|
+
# v0.4.5 (review R3): card.id — внешние данные (import cards.json), в фолбэке экранируем
|
|
122
|
+
title = title_escaped or f"Объявление {html_mod.escape(str(card.id), quote=True)}"
|
|
123
|
+
price = f"{card.pricing.price_total_rub:,.0f}".replace(",", " ") if card.pricing.price_total_rub else "—"
|
|
124
|
+
area = card.parameters.area_total_m2
|
|
125
|
+
rooms = card.parameters.rooms
|
|
126
|
+
floor = card.parameters.floor
|
|
127
|
+
floors_total = card.parameters.floors_total
|
|
128
|
+
year = card.building.year_built
|
|
129
|
+
sale_type = card.pricing.sale_type
|
|
130
|
+
is_auction = card.pricing.is_auction
|
|
131
|
+
metro = card.geo.cian_claimed_metro.station if card.geo.cian_claimed_metro else None
|
|
132
|
+
walk = card.geo.honest_walk_route
|
|
133
|
+
|
|
134
|
+
walk_html = ""
|
|
135
|
+
if walk:
|
|
136
|
+
overclaim_flag = ""
|
|
137
|
+
oc = card.geo.honest_overclaim
|
|
138
|
+
if oc and oc.get("flag"):
|
|
139
|
+
overclaim_flag = (' <span style="color:#c0392b">⚠ Циан занижает на '
|
|
140
|
+
f'{html_mod.escape(str(oc["overclaim_minutes"]), quote=True)} мин</span>')
|
|
141
|
+
walk_html = (
|
|
142
|
+
f'<div class="walk">🚇 {html_mod.escape(walk.target_station or "")}: '
|
|
143
|
+
f'{html_mod.escape(str(walk.walk_distance_meters), quote=True)} м ≈ {walk.walk_duration_minutes:.0f} мин '
|
|
144
|
+
f'<span class="prov">({html_mod.escape(str(walk.routing_provider), quote=True)})</span>{overclaim_flag}</div>'
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
auction_html = ""
|
|
148
|
+
if is_auction:
|
|
149
|
+
link = _safe_url(card.pricing.trading_link) # v0.4.4 F-04: схемы фильтрует
|
|
150
|
+
auction_html = (
|
|
151
|
+
f'<div class="auction">🔨 Аукцион'
|
|
152
|
+
+ (f' — <a href="{link}">торги</a>' if link else "")
|
|
153
|
+
+ "</div>"
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
photos = _photos_all(card, embed)
|
|
157
|
+
slides = ""
|
|
158
|
+
dots = ""
|
|
159
|
+
if photos:
|
|
160
|
+
slides = "".join(
|
|
161
|
+
f'<img src="{_safe_url(p)}" alt="фото {idx}-{i}" class="slide{" active" if i == 0 else ""}">'
|
|
162
|
+
for i, p in enumerate(photos) if _safe_url(p)
|
|
163
|
+
)
|
|
164
|
+
dots = "".join(
|
|
165
|
+
f'<span class="dot{" active" if i == 0 else ""}" onclick="showSlide({idx},{i},this)"></span>'
|
|
166
|
+
for i in range(len(photos))
|
|
167
|
+
)
|
|
168
|
+
carousel = f'<div class="carousel" id="car{idx}">{slides}<div class="dots">{dots}</div></div>' if photos else ""
|
|
169
|
+
|
|
170
|
+
# v0.4.6 (the task brief F-13): контакты продавца. Сюда попадают уже маскированные
|
|
171
|
+
# card.seller.phones — export_html() применяет sanitize_contacts по умолчанию;
|
|
172
|
+
# сырые номера возможны только при явном with_contacts=True.
|
|
173
|
+
contacts_html = ""
|
|
174
|
+
seller_phones = [
|
|
175
|
+
str(p) for p in (card.seller.phones if card.seller else []) if str(p or "").strip()
|
|
176
|
+
]
|
|
177
|
+
if seller_phones:
|
|
178
|
+
contacts_html = (
|
|
179
|
+
'<div class="contacts">📞 '
|
|
180
|
+
+ " · ".join(html_mod.escape(p) for p in seller_phones)
|
|
181
|
+
+ "</div>"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
return f"""
|
|
185
|
+
<div class="card" id="c{idx}">
|
|
186
|
+
{carousel}
|
|
187
|
+
<h3>{title}</h3>
|
|
188
|
+
<div class="price">{price} ₽</div>
|
|
189
|
+
<div class="meta">
|
|
190
|
+
{'комн: ' + str(rooms) if rooms is not None else ''} {'· ' if rooms is not None else ''}{area or '—'} м²
|
|
191
|
+
{'· этаж ' + str(floor) + '/' + str(floors_total) if floor else ''}
|
|
192
|
+
{'· ' + str(year) + ' г.' if year else ''}
|
|
193
|
+
{'· ' + html_mod.escape(sale_type) if sale_type else ''}
|
|
194
|
+
</div>
|
|
195
|
+
{walk_html}
|
|
196
|
+
{auction_html}
|
|
197
|
+
{contacts_html}
|
|
198
|
+
<div class="url"><a href="{html_mod.escape(card.url)}">{html_mod.escape(card.url)}</a></div>
|
|
199
|
+
</div>"""
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
_TEMPLATE = """<!DOCTYPE html>
|
|
203
|
+
<html lang="ru"><head><meta charset="utf-8">
|
|
204
|
+
<title>Каталог квартир — nedviga-cli</title>
|
|
205
|
+
<style>
|
|
206
|
+
body{font-family:system-ui,sans-serif;background:#f5f6f7;margin:20px}
|
|
207
|
+
h1{font-size:1.4em}
|
|
208
|
+
.card{background:#fff;border-radius:10px;padding:16px;margin:14px 0;box-shadow:0 1px 4px rgba(0,0,0,.08)}
|
|
209
|
+
.price{font-size:1.3em;font-weight:700;color:#0a7d34}
|
|
210
|
+
.meta{color:#555;margin:6px 0}
|
|
211
|
+
.walk{margin:6px 0;color:#2c3e50}
|
|
212
|
+
.prov{color:#999;font-size:.85em}
|
|
213
|
+
.auction{color:#8e44ad;font-weight:600}
|
|
214
|
+
.contacts{color:#666;margin:4px 0;font-size:.95em}
|
|
215
|
+
.url a{color:#2471a3;font-size:.85em;word-break:break-all}
|
|
216
|
+
.carousel{position:relative;height:240px;overflow:hidden;border-radius:8px;margin-bottom:10px;background:#eee}
|
|
217
|
+
.slide{display:none;width:100%;height:240px;object-fit:cover}
|
|
218
|
+
.slide.active{display:block}
|
|
219
|
+
.dots{position:absolute;bottom:8px;left:0;right:0;text-align:center}
|
|
220
|
+
.dot{display:inline-block;width:10px;height:10px;border-radius:50%;background:#fff;opacity:.6;margin:0 3px;cursor:pointer}
|
|
221
|
+
.dot.active{opacity:1;background:#0a7d34}
|
|
222
|
+
.navbtn{position:absolute;top:50%;transform:translateY(-50%);background:rgba(255,255,255,.8);
|
|
223
|
+
border:none;border-radius:50%;width:34px;height:34px;font-size:18px;cursor:pointer}
|
|
224
|
+
.navbtn.prev{left:8px}.navbtn.next{right:8px}
|
|
225
|
+
</style></head><body>
|
|
226
|
+
<h1>Каталог квартир — nedviga-cli</h1>
|
|
227
|
+
{cards}
|
|
228
|
+
<script>
|
|
229
|
+
function showSlide(ci, si, dot) {{
|
|
230
|
+
const car = document.getElementById('car' + ci);
|
|
231
|
+
car.querySelectorAll('.slide').forEach((s, i) => s.classList.toggle('active', i === si));
|
|
232
|
+
car.querySelectorAll('.dot').forEach((d, i) => d.classList.toggle('active', i === si));
|
|
233
|
+
}}
|
|
234
|
+
document.querySelectorAll('.carousel').forEach((car, ci) => {{
|
|
235
|
+
const prev = document.createElement('button'); prev.textContent = '‹'; prev.className = 'navbtn prev';
|
|
236
|
+
const next = document.createElement('button'); next.textContent = '›'; next.className = 'navbtn next';
|
|
237
|
+
const go = (d) => {{
|
|
238
|
+
const slides = [...car.querySelectorAll('.slide')];
|
|
239
|
+
const dots = [...car.querySelectorAll('.dot')];
|
|
240
|
+
let cur = dots.findIndex(d => d.classList.contains('active'));
|
|
241
|
+
cur = (cur + d + slides.length) % slides.length;
|
|
242
|
+
showSlide(ci, cur, dots[cur]);
|
|
243
|
+
}};
|
|
244
|
+
prev.onclick = () => go(-1); next.onclick = () => go(1);
|
|
245
|
+
car.appendChild(prev); car.appendChild(next);
|
|
246
|
+
}});
|
|
247
|
+
</script>
|
|
248
|
+
</body></html>"""
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def export_html(
|
|
252
|
+
cards: list[ListingCard],
|
|
253
|
+
output: Path,
|
|
254
|
+
embed_photos: bool = False,
|
|
255
|
+
with_contacts: bool = False,
|
|
256
|
+
) -> Path:
|
|
257
|
+
"""Собрать автономный HTML-каталог. Возвращает путь к файлу.
|
|
258
|
+
|
|
259
|
+
v0.4.6 (the task brief F-13): fail-closed — по умолчанию телефоны во всех
|
|
260
|
+
строках карточек маскируются (``sanitize_contacts``), и в каталог
|
|
261
|
+
попадает только маска. Сырые контакты выводятся лишь при явном
|
|
262
|
+
``with_contacts=True`` (CLI-флаг ``--with-contacts``).
|
|
263
|
+
"""
|
|
264
|
+
if with_contacts:
|
|
265
|
+
prepared = list(cards)
|
|
266
|
+
else:
|
|
267
|
+
from .pii import sanitize_contacts
|
|
268
|
+
|
|
269
|
+
prepared = [
|
|
270
|
+
ListingCard(
|
|
271
|
+
**sanitize_contacts(card.model_dump(mode="json", exclude_none=False))
|
|
272
|
+
)
|
|
273
|
+
for card in cards
|
|
274
|
+
]
|
|
275
|
+
parts = [_card_html(c, embed_photos, i) for i, c in enumerate(prepared)]
|
|
276
|
+
html = _TEMPLATE.replace("{cards}", "\n".join(parts))
|
|
277
|
+
# Убираем лишние {{}} из f-string-безопасного шаблона
|
|
278
|
+
html = html.replace("{{", "{").replace("}}", "}")
|
|
279
|
+
output.write_text(html, encoding="utf-8")
|
|
280
|
+
return output
|
nedviga/export_pdf.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""v0.4.7 (the task brief) F-11: PDF export via WeasyPrint (lazy import)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from .family_card import FamilyCard, build_family_card, render_html
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _as_html(card: Any, with_contacts: bool = False) -> str:
|
|
11
|
+
if isinstance(card, FamilyCard):
|
|
12
|
+
fc = card
|
|
13
|
+
else:
|
|
14
|
+
fc = build_family_card(card, with_contacts=with_contacts)
|
|
15
|
+
return render_html(fc, with_contacts=with_contacts)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def render_pdf(card: Any, with_contacts: bool = False) -> bytes:
|
|
19
|
+
"""Render PDF bytes via WeasyPrint. Raises RuntimeError('pdf-format-unavailable') if missing."""
|
|
20
|
+
html = _as_html(card, with_contacts=with_contacts)
|
|
21
|
+
try:
|
|
22
|
+
from weasyprint import HTML
|
|
23
|
+
except Exception as exc: # pragma: no cover - depends on env
|
|
24
|
+
raise RuntimeError("pdf-format-unavailable") from exc
|
|
25
|
+
try:
|
|
26
|
+
return HTML(string=html).write_pdf()
|
|
27
|
+
except Exception as exc:
|
|
28
|
+
raise RuntimeError(f"pdf-render-failed: {exc}") from exc
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def export_pdf(card: Any, output: Any, with_contacts: bool = False) -> Path:
|
|
32
|
+
"""Write PDF to output path. Returns Path."""
|
|
33
|
+
out = Path(output)
|
|
34
|
+
data = render_pdf(card, with_contacts=with_contacts)
|
|
35
|
+
out.write_bytes(data)
|
|
36
|
+
return out
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def catalog_pdf(cards, output, title=None, profile=None) -> "Path":
|
|
40
|
+
cards = list(cards or [])
|
|
41
|
+
if len(cards) > 30:
|
|
42
|
+
raise RuntimeError("too_many")
|
|
43
|
+
# limit photos to 6 per card
|
|
44
|
+
prepared = []
|
|
45
|
+
for c in cards:
|
|
46
|
+
try:
|
|
47
|
+
import copy
|
|
48
|
+
cc = copy.copy(c)
|
|
49
|
+
if hasattr(cc, "images"):
|
|
50
|
+
cc.images = list(cc.images or [])[:6]
|
|
51
|
+
prepared.append(cc)
|
|
52
|
+
except Exception:
|
|
53
|
+
prepared.append(c)
|
|
54
|
+
# title page info
|
|
55
|
+
districts = []
|
|
56
|
+
for c in prepared:
|
|
57
|
+
try:
|
|
58
|
+
d = c.geo.district if getattr(c, "geo", None) else None
|
|
59
|
+
if d:
|
|
60
|
+
districts.append(d)
|
|
61
|
+
except Exception:
|
|
62
|
+
pass
|
|
63
|
+
district_str = ", ".join(sorted(set(districts))) if districts else "—"
|
|
64
|
+
title = title or "Каталог подбора"
|
|
65
|
+
date_str = __import__("datetime").date.today().isoformat()
|
|
66
|
+
who = profile or "nedviga-cli"
|
|
67
|
+
parts = []
|
|
68
|
+
parts.append(f"<h1>{__import__('html').escape(title)}</h1>")
|
|
69
|
+
parts.append(f"<p>Дата: {date_str}</p>")
|
|
70
|
+
parts.append(f"<p>Район: {__import__('html').escape(district_str)}</p>")
|
|
71
|
+
parts.append(f"<p>Объектов: {len(prepared)}</p>")
|
|
72
|
+
parts.append(f"<p>Собрал: {__import__('html').escape(str(who))}</p>")
|
|
73
|
+
parts.append("<hr/>")
|
|
74
|
+
for c in prepared:
|
|
75
|
+
try:
|
|
76
|
+
from .family_card import build_family_card, render_html
|
|
77
|
+
fc = build_family_card(c)
|
|
78
|
+
html = render_html(fc, with_contacts=False)
|
|
79
|
+
# extract body content roughly: keep full html but page break
|
|
80
|
+
parts.append('<div style="page-break-after: always;"></div>')
|
|
81
|
+
parts.append(html)
|
|
82
|
+
except Exception as exc:
|
|
83
|
+
parts.append(f"<p>render error: {__import__('html').escape(str(exc))}</p>")
|
|
84
|
+
full_html = "<!DOCTYPE html><html><head><meta charset='utf-8'><style>body{font-family:system-ui}</style></head><body>" + "\n".join(parts) + "</body></html>"
|
|
85
|
+
try:
|
|
86
|
+
from weasyprint import HTML
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
raise RuntimeError("pdf-format-unavailable") from exc
|
|
89
|
+
out = Path(output)
|
|
90
|
+
HTML(string=full_html).write_pdf(str(out))
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# aliases
|
|
95
|
+
pdf_from_card = export_pdf
|
|
96
|
+
to_pdf = export_pdf
|