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